From f312d845af7b1083e0a13f24f9a7af720e4b5d66 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 22:30:59 +0200 Subject: [PATCH] feat(quests): add quest schema, entities and migration Co-Authored-By: Claude Opus 5 --- .../1797000000000-CreateQuestSystem.ts | 188 ++++++++++++++++++ .../create-quest-system.migration.spec.ts | 143 +++++++++++++ .../quests/entities/character-quest.entity.ts | 77 +++++++ .../entities/npc-quest-assignment.entity.ts | 68 +++++++ .../entities/quest-definition.entity.ts | 63 ++++++ .../entities/quest-entities.metadata.spec.ts | 129 ++++++++++++ .../quests/entities/quest-objective.entity.ts | 135 +++++++++++++ apps/api/src/quests/quest.types.ts | 90 +++++++++ 8 files changed, 893 insertions(+) create mode 100644 apps/api/src/database/migrations/1797000000000-CreateQuestSystem.ts create mode 100644 apps/api/src/database/migrations/create-quest-system.migration.spec.ts create mode 100644 apps/api/src/quests/entities/character-quest.entity.ts create mode 100644 apps/api/src/quests/entities/npc-quest-assignment.entity.ts create mode 100644 apps/api/src/quests/entities/quest-definition.entity.ts create mode 100644 apps/api/src/quests/entities/quest-entities.metadata.spec.ts create mode 100644 apps/api/src/quests/entities/quest-objective.entity.ts create mode 100644 apps/api/src/quests/quest.types.ts diff --git a/apps/api/src/database/migrations/1797000000000-CreateQuestSystem.ts b/apps/api/src/database/migrations/1797000000000-CreateQuestSystem.ts new file mode 100644 index 0000000..5701850 --- /dev/null +++ b/apps/api/src/database/migrations/1797000000000-CreateQuestSystem.ts @@ -0,0 +1,188 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The quest system (Playable Slice 0.9 §10; NPC spec §14, §40). + * + * Four tables, split the way AGENTS.md §7 asks: `quest_definitions` and + * `quest_objectives` are content, `npc_quest_assignments` is the content link + * NPC spec §14 specifies, and `character_quests` is the only player state. + * + * There is deliberately no per-objective progress table. Slice 0.9 §11 asks + * objectives to "derive progress from current owned quantity where + * appropriate", and doing exactly that is what makes every softlock case in + * §11 fall out for free: sell the pelts and the objective moves back, own some + * before accepting and they already count. + */ +export class CreateQuestSystem1797000000000 implements MigrationInterface { + name = 'CreateQuestSystem1797000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "quest_objective_type_enum" AS ENUM ('COLLECT_ITEM', 'TALK_TO_NPC')`, + ); + await queryRunner.query( + `CREATE TYPE "npc_quest_role_enum" AS ENUM ('OFFER', 'TURN_IN', 'PROGRESS')`, + ); + await queryRunner.query( + `CREATE TYPE "character_quest_status_enum" AS ENUM ('ACTIVE', 'COMPLETED')`, + ); + + await queryRunner.query(` + CREATE TABLE "quest_definitions" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "key" character varying(100) NOT NULL, + "title" character varying(150) NOT NULL, + "description" text NOT NULL, + "reward_faction_key" character varying(100), + "reward_reputation" integer NOT NULL DEFAULT 0, + "reward_silver" integer NOT NULL DEFAULT 0, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_quest_definitions" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_quest_definitions_key" ON "quest_definitions" ("key")`, + ); + + await queryRunner.query(` + CREATE TABLE "quest_objectives" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "quest_id" uuid NOT NULL, + "key" character varying(100) NOT NULL, + "order_index" integer NOT NULL, + "type" "quest_objective_type_enum" NOT NULL, + "target_key" character varying(100) NOT NULL, + "required_quantity" integer NOT NULL DEFAULT 1, + "description" character varying(255) NOT NULL, + "npc_line" text, + "hint_text" text, + "advance_when_blocked" boolean NOT NULL DEFAULT false, + "consume_on_complete" boolean NOT NULL DEFAULT false, + "grants_loot_bag_key" character varying(100), + "sets_flag_key" character varying(100), + "sets_flag_npc_key" character varying(100), + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_quest_objectives" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + ALTER TABLE "quest_objectives" + ADD CONSTRAINT "FK_quest_objectives_quest" + FOREIGN KEY ("quest_id") + REFERENCES "quest_definitions"("id") ON DELETE CASCADE + `); + // A step asking for zero of something would be satisfied before the player + // did anything, which is a content bug the database can simply refuse. + await queryRunner.query(` + ALTER TABLE "quest_objectives" + ADD CONSTRAINT "CHK_quest_objectives_required_quantity" + CHECK ("required_quantity" >= 1) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_quest_objectives_quest_key" ON "quest_objectives" ("quest_id", "key")`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_quest_objectives_quest_order" ON "quest_objectives" ("quest_id", "order_index")`, + ); + + await queryRunner.query(` + CREATE TABLE "npc_quest_assignments" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "npc_id" uuid NOT NULL, + "quest_id" uuid NOT NULL, + "role" "npc_quest_role_enum" NOT NULL, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_npc_quest_assignments" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + ALTER TABLE "npc_quest_assignments" + ADD CONSTRAINT "FK_npc_quest_assignments_npc" + FOREIGN KEY ("npc_id") + REFERENCES "npc_definitions"("id") ON DELETE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "npc_quest_assignments" + ADD CONSTRAINT "FK_npc_quest_assignments_quest" + FOREIGN KEY ("quest_id") + REFERENCES "quest_definitions"("id") ON DELETE CASCADE + `); + // The role is part of the key on purpose: NPC spec §14 wants one person to + // be able to both offer and receive the same quest, which the warden does. + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_npc_quest_assignments_npc_quest_role" ON "npc_quest_assignments" ("npc_id", "quest_id", "role")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_npc_quest_assignments_npc" ON "npc_quest_assignments" ("npc_id")`, + ); + + await queryRunner.query(` + CREATE TABLE "character_quests" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "character_id" uuid NOT NULL, + "quest_id" uuid NOT NULL, + "status" "character_quest_status_enum" NOT NULL, + "current_objective_index" integer NOT NULL DEFAULT 0, + "accepted_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "completed_at" TIMESTAMP WITH TIME ZONE, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_quests" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + ALTER TABLE "character_quests" + ADD CONSTRAINT "FK_character_quests_character" + FOREIGN KEY ("character_id") + REFERENCES "characters"("id") ON DELETE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "character_quests" + ADD CONSTRAINT "FK_character_quests_quest" + FOREIGN KEY ("quest_id") + REFERENCES "quest_definitions"("id") ON DELETE RESTRICT + `); + await queryRunner.query(` + ALTER TABLE "character_quests" + ADD CONSTRAINT "CHK_character_quests_objective_index" + CHECK ("current_objective_index" >= 0) + `); + // "Accepted only once" (slice §13) is guaranteed here, not by the UI + // disabling a button (AGENTS.md §30). + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_character_quests_character_quest" ON "character_quests" ("character_id", "quest_id")`, + ); + + // Slice 0.9 decision D5. `vertical-slice.seed.ts` handed the demo character + // a free Basic Hide Bag as a stopgap, with a comment saying to remove it + // "once 0.9 hands the Hide Bag over in the quest". This is that moment: + // leaving the row in place would hide the default HIDE capacity of 1, and + // that limit is the entire premise of the slice (§4). Scoped to the demo + // character and that one bag definition; no other row is touched. + await queryRunner.query(` + DELETE FROM "character_loot_bags" + WHERE "character_id" = '10000000-0000-4000-8000-000000000001' + AND "loot_bag_definition_id" = 'a0000000-0000-4000-8000-000000000001' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // The deleted stopgap bag is deliberately not restored: a rollback cannot + // tell it apart from a bag the player actually earned, and re-inserting a + // bag somebody may have bought would be the worse mistake. + await queryRunner.query(`DROP TABLE "character_quests"`); + await queryRunner.query(`DROP TABLE "npc_quest_assignments"`); + await queryRunner.query(`DROP TABLE "quest_objectives"`); + await queryRunner.query(`DROP TABLE "quest_definitions"`); + + await queryRunner.query(`DROP TYPE "character_quest_status_enum"`); + await queryRunner.query(`DROP TYPE "npc_quest_role_enum"`); + await queryRunner.query(`DROP TYPE "quest_objective_type_enum"`); + } +} diff --git a/apps/api/src/database/migrations/create-quest-system.migration.spec.ts b/apps/api/src/database/migrations/create-quest-system.migration.spec.ts new file mode 100644 index 0000000..6529fad --- /dev/null +++ b/apps/api/src/database/migrations/create-quest-system.migration.spec.ts @@ -0,0 +1,143 @@ +import 'reflect-metadata'; +import { QueryRunner } from 'typeorm'; +import { CreateQuestSystem1797000000000 } from './1797000000000-CreateQuestSystem'; + +/** + * The migration writes multi-line SQL, so every assertion below reads it with + * runs of whitespace collapsed. Otherwise a statement would have to be quoted + * back with its exact indentation, and reindenting the migration would break + * tests that still describe the right schema. + */ +function collapse(statements: string[]): string { + return statements.map((sql) => sql.replace(/\s+/g, ' ').trim()).join('\n'); +} + +async function runUp(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + await new CreateQuestSystem1797000000000().up(queryRunner); + return collapse(query.mock.calls.map(([sql]) => sql as string)); +} + +async function runDown(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateQuestSystem1797000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return collapse(query.mock.calls.slice(upCount).map(([sql]) => sql as string)); +} + +describe('CreateQuestSystem1797000000000', () => { + it('creates the four quest tables', async () => { + const joined = await runUp(); + + expect(joined).toContain('CREATE TABLE "quest_definitions"'); + expect(joined).toContain('CREATE TABLE "quest_objectives"'); + expect(joined).toContain('CREATE TABLE "npc_quest_assignments"'); + expect(joined).toContain('CREATE TABLE "character_quests"'); + }); + + it('creates the three quest enum types', async () => { + const joined = await runUp(); + + expect(joined).toContain('CREATE TYPE "quest_objective_type_enum"'); + expect(joined).toContain('CREATE TYPE "npc_quest_role_enum"'); + expect(joined).toContain('CREATE TYPE "character_quest_status_enum"'); + }); + + it('makes a quest acceptable only once per character', async () => { + const joined = await runUp(); + + // The database, not a disabled button, is what guarantees this + // (AGENTS.md §30). + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_character_quests_character_quest" ON "character_quests" ("character_id", "quest_id")', + ); + }); + + it('keeps objective keys and order unique within a quest', async () => { + const joined = await runUp(); + + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_quest_objectives_quest_key" ON "quest_objectives" ("quest_id", "key")', + ); + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_quest_objectives_quest_order" ON "quest_objectives" ("quest_id", "order_index")', + ); + }); + + it('lets one quest use several NPCs in different roles', async () => { + const joined = await runUp(); + + // The unique triple includes the role, so the same NPC can both offer and + // receive a quest (NPC spec §14). + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_npc_quest_assignments_npc_quest_role" ON "npc_quest_assignments" ("npc_id", "quest_id", "role")', + ); + }); + + it('stores step effects as content columns', async () => { + const joined = await runUp(); + + expect(joined).toContain('"advance_when_blocked" boolean'); + expect(joined).toContain('"consume_on_complete" boolean'); + expect(joined).toContain('"grants_loot_bag_key" character varying'); + expect(joined).toContain('"sets_flag_key" character varying'); + expect(joined).toContain('"sets_flag_npc_key" character varying'); + expect(joined).toContain('"npc_line" text'); + expect(joined).toContain('"hint_text" text'); + }); + + it('rejects nonsensical quantities and indexes', async () => { + const joined = await runUp(); + + expect(joined).toContain('CHK_quest_objectives_required_quantity'); + expect(joined).toContain('CHK_character_quests_objective_index'); + }); + + it('cascades objectives and assignments but never deletes a quest in use', async () => { + const joined = await runUp(); + + expect(joined).toContain('FK_quest_objectives_quest'); + expect(joined).toContain('FK_npc_quest_assignments_npc'); + expect(joined).toContain('FK_npc_quest_assignments_quest'); + expect(joined).toContain('FK_character_quests_character'); + // A quest a character is on must not vanish underneath them. + expect(joined).toContain( + 'FOREIGN KEY ("quest_id") REFERENCES "quest_definitions"("id") ON DELETE RESTRICT', + ); + }); + + it('removes the demo character stopgap hide bag', async () => { + const joined = await runUp(); + + // Slice 0.9 D5: the seed handed this bag over for free so the hunting loop + // stayed playable between 0.8.5 and 0.9. Leaving it would hide the default + // HIDE capacity of 1, which is the whole premise of this slice. + expect(joined).toContain('DELETE FROM "character_loot_bags"'); + expect(joined).toContain('10000000-0000-4000-8000-000000000001'); + expect(joined).toContain('a0000000-0000-4000-8000-000000000001'); + }); + + it('drops every table and type it created on down', async () => { + const joined = await runDown(); + + expect(joined).toContain('DROP TABLE "character_quests"'); + expect(joined).toContain('DROP TABLE "npc_quest_assignments"'); + expect(joined).toContain('DROP TABLE "quest_objectives"'); + expect(joined).toContain('DROP TABLE "quest_definitions"'); + expect(joined).toContain('DROP TYPE "character_quest_status_enum"'); + expect(joined).toContain('DROP TYPE "npc_quest_role_enum"'); + expect(joined).toContain('DROP TYPE "quest_objective_type_enum"'); + }); + + it('does not resurrect the deleted hide bag on down', async () => { + const joined = await runDown(); + + // A rollback cannot tell the seeded stopgap from a bag the player earned, + // so it restores neither. + expect(joined).not.toContain('INSERT INTO "character_loot_bags"'); + }); +}); diff --git a/apps/api/src/quests/entities/character-quest.entity.ts b/apps/api/src/quests/entities/character-quest.entity.ts new file mode 100644 index 0000000..dc01bf7 --- /dev/null +++ b/apps/api/src/quests/entities/character-quest.entity.ts @@ -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; +} diff --git a/apps/api/src/quests/entities/npc-quest-assignment.entity.ts b/apps/api/src/quests/entities/npc-quest-assignment.entity.ts new file mode 100644 index 0000000..fdf7d9c --- /dev/null +++ b/apps/api/src/quests/entities/npc-quest-assignment.entity.ts @@ -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; +} diff --git a/apps/api/src/quests/entities/quest-definition.entity.ts b/apps/api/src/quests/entities/quest-definition.entity.ts new file mode 100644 index 0000000..696b3ac --- /dev/null +++ b/apps/api/src/quests/entities/quest-definition.entity.ts @@ -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; +} diff --git a/apps/api/src/quests/entities/quest-entities.metadata.spec.ts b/apps/api/src/quests/entities/quest-entities.metadata.spec.ts new file mode 100644 index 0000000..12249b4 --- /dev/null +++ b/apps/api/src/quests/entities/quest-entities.metadata.spec.ts @@ -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', + ]); + }); +}); diff --git a/apps/api/src/quests/entities/quest-objective.entity.ts b/apps/api/src/quests/entities/quest-objective.entity.ts new file mode 100644 index 0000000..e8dd5f6 --- /dev/null +++ b/apps/api/src/quests/entities/quest-objective.entity.ts @@ -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; +} diff --git a/apps/api/src/quests/quest.types.ts b/apps/api/src/quests/quest.types.ts new file mode 100644 index 0000000..ded12d5 --- /dev/null +++ b/apps/api/src/quests/quest.types.ts @@ -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; +}