feat(quests): add quest schema, entities and migration

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 22:30:59 +02:00
parent d0669c56a9
commit f312d845af
8 changed files with 893 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CharacterQuestStatus } from '../quest.types';
import { QuestDefinition } from './quest-definition.entity';
/**
* Where one character stands with one quest (Slice 0.9 §10, §11).
*
* Player state, kept away from the quest content it points at (AGENTS.md §7).
* There is deliberately no per-objective progress table: a collect step's
* progress is read from what the character owns right now (spec §11), so
* selling the pelts moves the objective back on its own instead of leaving a
* stored counter lying about the inventory.
*
* `currentObjectiveIndex` is therefore a *floor*, not the answer. It only moves
* when a talk step is performed -- those are irreversible -- and the effective
* step is derived from it by `resolveCurrentObjectiveIndex`.
*
* The unique index is what makes "accepted only once" a database guarantee
* rather than a disabled button (AGENTS.md §30, spec §13).
*/
@Entity({ name: 'character_quests' })
@Index('IDX_character_quests_character_quest', ['characterId', 'questId'], {
unique: true,
})
export class CharacterQuest {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'quest_id', type: 'uuid' })
questId!: string;
@Column({
name: 'status',
type: 'enum',
enum: CharacterQuestStatus,
enumName: 'character_quest_status_enum',
})
status!: CharacterQuestStatus;
@Column({ name: 'current_objective_index', type: 'integer', default: 0 })
currentObjectiveIndex!: number;
@Column({ name: 'accepted_at', type: 'timestamptz' })
acceptedAt!: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt!: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
// RESTRICT, not CASCADE: a quest a character is standing on must not vanish
// underneath them because a content row was deleted.
@ManyToOne(() => QuestDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'quest_id' })
quest!: QuestDefinition;
}

View File

@@ -0,0 +1,68 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcQuestRole } from '../quest.types';
import { QuestDefinition } from './quest-definition.entity';
/**
* Which NPC does what for a quest (NPC spec §14).
*
* §14 rejects `npc.isQuestGiver = true` on purpose: a quest may be offered by
* one person, pushed forward by a second and handed in to a third. This chain
* uses that immediately -- the warden offers and receives, Borin sits in the
* middle -- which is why the unique index below includes `role`: the same NPC
* legitimately holds two of them.
*
* Slice 0.8's NPC spec §40 listed this table as its Abweichung 1, deferred
* because there was no `quest_definitions` to point at. There is now.
*/
@Entity({ name: 'npc_quest_assignments' })
@Index(
'IDX_npc_quest_assignments_npc_quest_role',
['npcId', 'questId', 'role'],
{ unique: true },
)
@Index('IDX_npc_quest_assignments_npc', ['npcId'])
export class NpcQuestAssignment {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'npc_id', type: 'uuid' })
npcId!: string;
@Column({ name: 'quest_id', type: 'uuid' })
questId!: string;
@Column({
name: 'role',
type: 'enum',
enum: NpcQuestRole,
enumName: 'npc_quest_role_enum',
})
role!: NpcQuestRole;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'npc_id' })
npc!: NpcDefinition;
@ManyToOne(() => QuestDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'quest_id' })
quest!: QuestDefinition;
}

View File

@@ -0,0 +1,63 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* One quest, as content (Playable Slice 0.9 §10).
*
* Content, never player state: what a quest *is* lives here, where a character
* stands with it lives in `CharacterQuest` (AGENTS.md §7). The reward columns
* sit on the quest rather than on the final step because they are paid once,
* on completion, whichever NPC happens to receive it.
*
* `rewardSilver` is seeded at 0 and `renownMilestoneKey` is deliberately absent
* (Slice 0.9 decisions D2/D3): the bag is the reward, a Silver payout would
* undercut the merchant trade loop, and a renown milestone here would reach
* World Renown 3 and open the Bandit Blade that Slice 0.8.5 parked until 0.11.
* Both stay tunable as content instead of needing a code change.
*/
@Entity({ name: 'quest_definitions' })
@Index('IDX_quest_definitions_key', ['key'], { unique: true })
export class QuestDefinition {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
/** Stable business key used by seeds, conditions and tests (AGENTS.md §8). */
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'title', type: 'varchar', length: 150 })
title!: string;
@Column({ name: 'description', type: 'text' })
description!: string;
/** Which faction the reputation reward is paid to. Null pays none. */
@Column({
name: 'reward_faction_key',
type: 'varchar',
length: 100,
nullable: true,
})
rewardFactionKey!: string | null;
@Column({ name: 'reward_reputation', type: 'integer', default: 0 })
rewardReputation!: number;
@Column({ name: 'reward_silver', type: 'integer', default: 0 })
rewardSilver!: number;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,129 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import {
CharacterQuestStatus,
NpcQuestRole,
QuestObjectiveType,
} from '../quest.types';
import { CharacterQuest } from './character-quest.entity';
import { NpcQuestAssignment } from './npc-quest-assignment.entity';
import { QuestDefinition } from './quest-definition.entity';
import { QuestObjective } from './quest-objective.entity';
function tableName(target: unknown): string | undefined {
return getMetadataArgsStorage().tables.find(
(table) => table.target === target,
)?.name;
}
function columnFor(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(column) => column.target === target && column.propertyName === propertyName,
);
}
function columnNames(target: unknown): string[] {
return getMetadataArgsStorage()
.columns.filter((column) => column.target === target)
.map((column) => column.propertyName);
}
function uniqueIndexFor(target: unknown, columns: string[]): boolean {
const index = getMetadataArgsStorage().indices.find(
(candidate) =>
candidate.target === target &&
Array.isArray(candidate.columns) &&
candidate.columns.length === columns.length &&
columns.every((column) => candidate.columns?.includes(column)),
);
const meta = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
return (meta?.options?.unique ?? meta?.unique) === true;
}
describe('Slice 0.9 quest entity metadata', () => {
it('maps every quest entity to the table the migration creates', () => {
expect(tableName(QuestDefinition)).toBe('quest_definitions');
expect(tableName(QuestObjective)).toBe('quest_objectives');
expect(tableName(NpcQuestAssignment)).toBe('npc_quest_assignments');
expect(tableName(CharacterQuest)).toBe('character_quests');
});
it('names the enum types the migration declares', () => {
// A mismatch here is invisible until TypeORM writes a cast at runtime, so
// it is worth asserting alongside the SQL-string migration spec.
expect(columnFor(QuestObjective, 'type')?.options.enumName).toBe(
'quest_objective_type_enum',
);
expect(columnFor(NpcQuestAssignment, 'role')?.options.enumName).toBe(
'npc_quest_role_enum',
);
expect(columnFor(CharacterQuest, 'status')?.options.enumName).toBe(
'character_quest_status_enum',
);
});
it('keeps the objective effect columns on the objective', () => {
const names = columnNames(QuestObjective);
expect(names).toEqual(
expect.arrayContaining([
'advanceWhenBlocked',
'consumeOnComplete',
'grantsLootBagKey',
'setsFlagKey',
'setsFlagNpcKey',
'npcLine',
'hintText',
]),
);
});
it('stores no per-objective progress on the character', () => {
// Progress is derived from owned quantity (slice §11). A stored counter
// here would be the thing that lies about the inventory.
const names = columnNames(CharacterQuest);
expect(names).toContain('currentObjectiveIndex');
expect(names).not.toContain('progress');
expect(names).not.toContain('objectiveProgress');
});
it('enforces one quest row per character per quest', () => {
expect(uniqueIndexFor(CharacterQuest, ['characterId', 'questId'])).toBe(
true,
);
});
it('keeps objective keys and order unique within a quest', () => {
expect(uniqueIndexFor(QuestObjective, ['questId', 'key'])).toBe(true);
expect(uniqueIndexFor(QuestObjective, ['questId', 'orderIndex'])).toBe(true);
});
it('lets one NPC hold several roles for one quest', () => {
expect(
uniqueIndexFor(NpcQuestAssignment, ['npcId', 'questId', 'role']),
).toBe(true);
// The pair alone must NOT be unique, or the warden could not both offer
// and receive (NPC spec §14).
expect(uniqueIndexFor(NpcQuestAssignment, ['npcId', 'questId'])).toBe(false);
});
it('exposes the enum values the content vocabulary needs', () => {
expect(Object.values(QuestObjectiveType).sort()).toEqual([
'COLLECT_ITEM',
'TALK_TO_NPC',
]);
expect(Object.values(NpcQuestRole).sort()).toEqual([
'OFFER',
'PROGRESS',
'TURN_IN',
]);
expect(Object.values(CharacterQuestStatus).sort()).toEqual([
'ACTIVE',
'COMPLETED',
]);
});
});

View File

@@ -0,0 +1,135 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { QuestObjectiveType } from '../quest.types';
import { QuestDefinition } from './quest-definition.entity';
/**
* One ordered step of a quest, and everything that step does (Slice 0.9 §10).
*
* The effect columns are what keep `QuestService` a state machine instead of a
* switch on quest keys: a step *declares* that it sets a flag, hands over a
* bag, or consumes what it asked for, and the service applies whatever it finds
* (AGENTS.md §9). Adding the next quest is then content work.
*/
@Entity({ name: 'quest_objectives' })
@Index('IDX_quest_objectives_quest_key', ['questId', 'key'], { unique: true })
@Index('IDX_quest_objectives_quest_order', ['questId', 'orderIndex'], {
unique: true,
})
export class QuestObjective {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'quest_id', type: 'uuid' })
questId!: string;
/** Unique within its quest, not globally -- two quests may both "turn-in". */
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'order_index', type: 'integer' })
orderIndex!: number;
@Column({
name: 'type',
type: 'enum',
enum: QuestObjectiveType,
enumName: 'quest_objective_type_enum',
})
type!: QuestObjectiveType;
/** An item key for a collect step, an NPC key for a talk step. */
@Column({ name: 'target_key', type: 'varchar', length: 100 })
targetKey!: string;
@Column({ name: 'required_quantity', type: 'integer', default: 1 })
requiredQuantity!: number;
/** The objective line the quest UI shows (spec §12). */
@Column({ name: 'description', type: 'varchar', length: 255 })
description!: string;
/** What the NPC says when this step is performed (spec §5, §6, §8). */
@Column({ name: 'npc_line', type: 'text', nullable: true })
npcLine!: string | null;
/** Shown while the step is blocked rather than merely unfinished (spec §4). */
@Column({ name: 'hint_text', type: 'text', nullable: true })
hintText!: string | null;
/**
* The capacity lesson, as one boolean (spec §4).
*
* A collect step with this set also counts as done when the character
* physically cannot carry more of the target's loot category. That is what
* hands a bagless player over to the warden at 1 / 5 instead of stranding
* them, and it is content because only the *first* pelt hunt should behave
* that way -- the second one has the bag and is expected to finish.
*/
@Column({ name: 'advance_when_blocked', type: 'boolean', default: false })
advanceWhenBlocked!: boolean;
/**
* Whether this step's items are taken at turn-in (spec §8).
*
* Per step, not per quest: this chain asks for five pelts twice and must
* consume five, not ten.
*/
@Column({ name: 'consume_on_complete', type: 'boolean', default: false })
consumeOnComplete!: boolean;
/** A `LootBagDefinition.key` this step hands over, once (spec §6, §11). */
@Column({
name: 'grants_loot_bag_key',
type: 'varchar',
length: 100,
nullable: true,
})
grantsLootBagKey!: string | null;
/** A dialogue flag this step sets (spec §5). */
@Column({
name: 'sets_flag_key',
type: 'varchar',
length: 100,
nullable: true,
})
setsFlagKey!: string | null;
/**
* *Whose* state the flag is written to.
*
* Dialogue flags are per-NPC player state (NPC spec §7), so the warden's
* referral has to land on the character's Borin row -- that is the row the
* 0.8.5 `bypassConditions` gate reads when it decides whether the Basic Hide
* Bag offer is open (spec §6).
*/
@Column({
name: 'sets_flag_npc_key',
type: 'varchar',
length: 100,
nullable: true,
})
setsFlagNpcKey!: string | null;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => QuestDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'quest_id' })
quest!: QuestDefinition;
}

View File

@@ -0,0 +1,90 @@
import type { LootCategory } from '../items/loot-category.enum';
/**
* What one quest step asks of the player (Playable Slice 0.9 §10).
*
* Two kinds is the whole vocabulary this slice needs, and §10 explicitly asks
* for a step-based state machine rather than a branching narrative engine.
* A collect step is *observed* -- its progress is read from what the character
* currently owns -- while a talk step is *performed* through an endpoint.
*/
export enum QuestObjectiveType {
COLLECT_ITEM = 'COLLECT_ITEM',
TALK_TO_NPC = 'TALK_TO_NPC',
}
/**
* What an NPC does for a quest (NPC spec §14).
*
* Modelled as a link row rather than an `npc.isQuestGiver` flag so one quest
* can span several people: the warden offers and receives, Borin sits in the
* middle. That is exactly §14's worked example.
*/
export enum NpcQuestRole {
OFFER = 'OFFER',
TURN_IN = 'TURN_IN',
PROGRESS = 'PROGRESS',
}
/** Where a character stands with one quest. */
export enum CharacterQuestStatus {
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
}
/** A quest the character has not taken on yet is `AVAILABLE` (no row at all). */
export type QuestStatus = 'AVAILABLE' | 'ACTIVE' | 'COMPLETED';
export interface QuestObjectiveDto {
key: string;
description: string;
type: QuestObjectiveType;
targetKey: string;
required: number;
current: number;
completed: boolean;
}
export interface QuestDto {
key: string;
title: string;
description: string;
status: QuestStatus;
objectives: QuestObjectiveDto[];
currentObjectiveKey: string | null;
/**
* Why the active step cannot progress right now (spec §4, §12).
*
* The one piece of text that keeps a bagless player from reading "1 / 5" as
* a dead end. Null whenever the step is simply unfinished.
*/
hint: string | null;
}
export interface GrantedLootBagDto {
key: string;
name: string;
lootCategory: LootCategory;
capacity: number;
}
export interface QuestRewardDto {
factionKey: string | null;
reputation: number;
silver: number;
}
export interface QuestConsumedItemDto {
itemKey: string;
quantity: number;
}
export interface QuestInteractionResultDto {
quest: QuestDto;
/** What the NPC says for the step just performed (spec §5, §6, §8). */
npcLine: string | null;
/** Non-null only on the step that hands a bag over (spec §12 Bag UI). */
grantedBag: GrantedLootBagDto | null;
consumedItems: QuestConsumedItemDto[];
rewards: QuestRewardDto | null;
}