diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 23c9278..e50686f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,6 +10,7 @@ import { HuntingModule } from './hunting/hunting.module'; import { InventoryModule } from './inventory/inventory.module'; import { LootBagsModule } from './loot-bags/loot-bags.module'; import { NpcsModule } from './npcs/npcs.module'; +import { QuestsModule } from './quests/quests.module'; import { RenownModule } from './renown/renown.module'; import { ReputationModule } from './reputation/reputation.module'; import { ShopsModule } from './shops/shops.module'; @@ -34,6 +35,7 @@ import { WorldModule } from './world/world.module'; NpcsModule, ShopsModule, ExchangesModule, + QuestsModule, ], }) export class AppModule {} diff --git a/apps/api/src/conditions/conditions.module.ts b/apps/api/src/conditions/conditions.module.ts index 60cce83..ef46a04 100644 --- a/apps/api/src/conditions/conditions.module.ts +++ b/apps/api/src/conditions/conditions.module.ts @@ -4,6 +4,8 @@ import { Character } from '../characters/entities/character.entity'; import { CharacterItem } from '../items/entities/character-item.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity'; import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity'; +import { CharacterQuest } from '../quests/entities/character-quest.entity'; +import { QuestDefinition } from '../quests/entities/quest-definition.entity'; import { CharacterReputation } from '../reputation/entities/character-reputation.entity'; import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { GameConditionService } from './game-condition.service'; @@ -22,7 +24,9 @@ import { GameConditionService } from './game-condition.service'; CharacterItem, ItemDefinition, CharacterNpcState, + CharacterQuest, CharacterReputation, + QuestDefinition, ReputationFaction, ]), ], diff --git a/apps/api/src/conditions/game-condition.service.spec.ts b/apps/api/src/conditions/game-condition.service.spec.ts index e083ad7..4154cf3 100644 --- a/apps/api/src/conditions/game-condition.service.spec.ts +++ b/apps/api/src/conditions/game-condition.service.spec.ts @@ -3,6 +3,9 @@ import { Character } from '../characters/entities/character.entity'; import { CharacterItem } from '../items/entities/character-item.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity'; import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity'; +import { CharacterQuest } from '../quests/entities/character-quest.entity'; +import { QuestDefinition } from '../quests/entities/quest-definition.entity'; +import { CharacterQuestStatus } from '../quests/quest.types'; import { CharacterReputation } from '../reputation/entities/character-reputation.entity'; import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { GameConditionService } from './game-condition.service'; @@ -11,6 +14,8 @@ import { ComparisonOperator, GameConditionType } from './game-condition.types'; const CHARACTER_ID = 'character-1'; const NPC_ID = 'npc-1'; const FACTION_ID = 'faction-1'; +const QUEST_ID = 'quest-1'; +const QUEST_KEY = 'trouble-beyond-the-gate'; interface Fixture { renown?: number; @@ -18,6 +23,9 @@ interface Fixture { factionEnabled?: boolean; flags?: Record | null; itemQuantity?: number; + questEnabled?: boolean; + /** Undefined means the character never took the quest on. */ + questStatus?: CharacterQuestStatus; } function createService(fixture: Fixture = {}): GameConditionService { @@ -81,6 +89,27 @@ function createService(fixture: Fixture = {}): GameConditionService { ), }; } + if (entity === QuestDefinition) { + return { + findOneBy: (criteria: { key: string; enabled: boolean }) => + Promise.resolve( + criteria.key === QUEST_KEY && + (fixture.questEnabled ?? true) === criteria.enabled + ? { id: QUEST_ID, key: QUEST_KEY } + : null, + ), + }; + } + if (entity === CharacterQuest) { + return { + findOneBy: () => + Promise.resolve( + fixture.questStatus === undefined + ? null + : { questId: QUEST_ID, status: fixture.questStatus }, + ), + }; + } throw new Error('Unexpected repository'); }, } as unknown as DataSource; @@ -314,4 +343,93 @@ describe('GameConditionService', () => { expect(outcomes[0].actual).toBeNull(); }); + + it('treats a quest in progress as active and not completed', async () => { + const service = createService({ questStatus: CharacterQuestStatus.ACTIVE }); + + await expect( + service.evaluate({ characterId: CHARACTER_ID }, [ + { type: GameConditionType.QUEST_ACTIVE, key: QUEST_KEY }, + ]), + ).resolves.toBe(true); + await expect( + service.evaluate({ characterId: CHARACTER_ID }, [ + { type: GameConditionType.QUEST_COMPLETED, key: QUEST_KEY }, + ]), + ).resolves.toBe(false); + }); + + it('treats a finished quest as completed and no longer active', async () => { + const service = createService({ + questStatus: CharacterQuestStatus.COMPLETED, + }); + + await expect( + service.evaluate({ characterId: CHARACTER_ID }, [ + { type: GameConditionType.QUEST_COMPLETED, key: QUEST_KEY }, + ]), + ).resolves.toBe(true); + await expect( + service.evaluate({ characterId: CHARACTER_ID }, [ + { type: GameConditionType.QUEST_ACTIVE, key: QUEST_KEY }, + ]), + ).resolves.toBe(false); + }); + + it('lets content ask for the negative with value false', async () => { + // How the warden's offer line asks for "not started and not finished" + // without a NOT operator in the vocabulary -- the same shape FLAG_SET uses. + const service = createService({ questStatus: undefined }); + + await expect( + service.evaluate({ characterId: CHARACTER_ID }, [ + { type: GameConditionType.QUEST_ACTIVE, key: QUEST_KEY, value: false }, + { + type: GameConditionType.QUEST_COMPLETED, + key: QUEST_KEY, + value: false, + }, + ]), + ).resolves.toBe(true); + }); + + it('closes a quest gate for an unknown or disabled quest', async () => { + // Fail-closed: a gate must never open because the content behind it is + // missing. + await expect( + createService({ questStatus: CharacterQuestStatus.ACTIVE }).evaluate( + { characterId: CHARACTER_ID }, + [{ type: GameConditionType.QUEST_ACTIVE, key: 'no-such-quest' }], + ), + ).resolves.toBe(false); + await expect( + createService({ + questStatus: CharacterQuestStatus.ACTIVE, + questEnabled: false, + }).evaluate({ characterId: CHARACTER_ID }, [ + { type: GameConditionType.QUEST_ACTIVE, key: QUEST_KEY }, + ]), + ).resolves.toBe(false); + }); + + it('closes a quest gate that names no quest at all', async () => { + await expect( + createService({ questStatus: CharacterQuestStatus.ACTIVE }).evaluate( + { characterId: CHARACTER_ID }, + [{ type: GameConditionType.QUEST_ACTIVE }], + ), + ).resolves.toBe(false); + }); + + it('reports no measured value for a quest condition', async () => { + // A quest is active or it is not. "Current: 0" would be a lie about a + // boolean, and the shop view renders these as player-facing requirements. + const service = createService({ questStatus: undefined }); + + const outcomes = await service.describe({ characterId: CHARACTER_ID }, [ + { type: GameConditionType.QUEST_COMPLETED, key: QUEST_KEY }, + ]); + + expect(outcomes[0]).toMatchObject({ met: false, actual: null }); + }); }); diff --git a/apps/api/src/conditions/game-condition.service.ts b/apps/api/src/conditions/game-condition.service.ts index af5ebaa..62c8739 100644 --- a/apps/api/src/conditions/game-condition.service.ts +++ b/apps/api/src/conditions/game-condition.service.ts @@ -4,6 +4,9 @@ import { Character } from '../characters/entities/character.entity'; import { CharacterItem } from '../items/entities/character-item.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity'; import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity'; +import { CharacterQuest } from '../quests/entities/character-quest.entity'; +import { QuestDefinition } from '../quests/entities/quest-definition.entity'; +import { CharacterQuestStatus } from '../quests/quest.types'; import { CharacterReputation } from '../reputation/entities/character-reputation.entity'; import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { @@ -124,11 +127,63 @@ export class GameConditionService { return this.evaluateFlag(context, condition, scope); case GameConditionType.HAS_ITEM: return this.evaluateHasItem(context, condition, scope); + case GameConditionType.QUEST_ACTIVE: + return this.evaluateQuestStatus( + context, + condition, + scope, + CharacterQuestStatus.ACTIVE, + ); + case GameConditionType.QUEST_COMPLETED: + return this.evaluateQuestStatus( + context, + condition, + scope, + CharacterQuestStatus.COMPLETED, + ); default: return { met: false, actual: null }; } } + /** + * Whether the character stands in a given place with a given quest (§19). + * + * Boolean-shaped like `FLAG_SET` rather than numeric: a quest is active or it + * is not, so `actual` stays null -- reporting "Current: 0" for it would be a + * lie about a boolean, and the shop view renders these outcomes as + * player-facing requirements. + * + * `value: false` is how content asks for the negative. The warden's offer + * line needs "not active and not completed", and this keeps that expressible + * without adding a NOT to the condition vocabulary. + */ + private async evaluateQuestStatus( + context: ConditionContext, + condition: GameCondition, + scope: RepositoryScope, + status: CharacterQuestStatus, + ): Promise { + if (!condition.key) { + return { met: false, actual: null }; + } + + const quest = await scope + .getRepository(QuestDefinition) + .findOneBy({ key: condition.key, enabled: true }); + if (!quest) { + return { met: false, actual: null }; + } + + const row = await scope.getRepository(CharacterQuest).findOneBy({ + characterId: context.characterId, + questId: quest.id, + }); + + const expected = condition.value ?? true; + return { met: (row?.status === status) === expected, actual: null }; + } + private async evaluateRegionReputation( context: ConditionContext, condition: GameCondition, diff --git a/apps/api/src/conditions/game-condition.types.ts b/apps/api/src/conditions/game-condition.types.ts index d4ad473..afb2f72 100644 --- a/apps/api/src/conditions/game-condition.types.ts +++ b/apps/api/src/conditions/game-condition.types.ts @@ -41,10 +41,12 @@ export interface GameCondition { * Condition types this build can actually answer. * * The remaining types are part of the V1 vocabulary (spec §19) but have no - * backing system yet: quests arrive in Slice 0.9, bosses in 0.11, and location - * discovery is not tracked per character at all. They are listed in the enum - * so content and migrations do not need rewriting later, and rejected at - * evaluation time so an unbacked gate can never silently read as "passed". + * backing system yet: bosses arrive in Slice 0.11, and location discovery is + * not tracked per character at all. They are listed in the enum so content and + * migrations do not need rewriting later, and rejected at evaluation time so an + * unbacked gate can never silently read as "passed". + * + * The two quest types joined the list in Slice 0.9, when quests became real. */ export const SUPPORTED_CONDITION_TYPES: ReadonlySet = new Set([ @@ -52,6 +54,8 @@ export const SUPPORTED_CONDITION_TYPES: ReadonlySet = GameConditionType.WORLD_RENOWN, GameConditionType.FLAG_SET, GameConditionType.HAS_ITEM, + GameConditionType.QUEST_ACTIVE, + GameConditionType.QUEST_COMPLETED, ]); export function compare( 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/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts index b041d2e..fe48588 100644 --- a/apps/api/src/database/seeds/local-location.content.ts +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -160,19 +160,19 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = { 'Weathered notices flutter in the wind. A fresh one warns of raiders on the Burned Road and promises silver for every bandit killed.', resultImg: '/images/environment/notice-board.png', }, + // Was authored scenery with a line of result text until Slice 0.9 made the + // warden a real NPC with a quest to give. A hotspot carries result text or + // an `npcKey`, never both, so the text goes and the doorway stays. { key: 'gate-watch', - title: 'Gate Watch', + title: 'Halvik, Warden of the South Gate', actionLabel: 'Talk', type: 'NPC', iconKey: 'speak', xPercent: 45, yPercent: 52, enabled: true, - resultTitle: 'Gate Watch', - resultText: - '"Beyond the gate, Graufurt\'s protection ends. Whoever heads south does so at their own risk — and rarely comes back the way they left."', - resultImg: '/images/npcs/graufurt-gate-watch.png', + npcKey: 'south-gate-warden', }, // Borin stands at the gate rather than deeper in a town that does not // exist yet as a location. Carries `npcKey` instead of result text, so @@ -202,12 +202,13 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = { localPrimaryActions: [ { key: 'talk-to-watch', - label: 'Talk to the watch', - description: 'Ask about the situation', + label: 'Talk to the warden', + description: 'Ask about the road south', type: 'NPC', iconKey: 'speak', enabled: true, poiKey: 'gate-watch', + npcKey: 'south-gate-warden', }, { key: 'trade-with-borin', diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index 46324c9..12a4cf5 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -10,13 +10,16 @@ import type { } from '../../npcs/npc.types'; import { ITEM_IDS } from './item.constants'; import { BASIC_HIDE_BAG_ID, BASIC_TROPHY_POUCH_ID } from './loot-bag-content'; +import { TROUBLE_BEYOND_THE_GATE_KEY } from './quest.constants'; import { BORDER_GUARD_FACTION_ID } from './reputation-content'; export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001'; +export const SOUTH_GATE_WARDEN_NPC_ID = 'b0000000-0000-4000-8000-000000000002'; export const BORIN_SHOP_ID = 'b1000000-0000-4000-8000-000000000001'; export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001'; export const BORIN_KEY = 'borin-quartermaster'; +export const SOUTH_GATE_WARDEN_KEY = 'south-gate-warden'; export const BORIN_SHOP_KEY = 'borin-supplies'; export const BORIN_EXCHANGE_KEY = 'borin-trade-in'; @@ -114,6 +117,44 @@ export const NPC_DEFINITIONS: SeedNpcDefinition[] = [ ], enabled: true, }, + /** + * Halvik, the South Gate Warden (Playable Slice 0.9 §2). + * + * §2 says to reuse an existing named gate NPC rather than inventing a + * duplicate. There was none: the gate watch was an authored hotspot with a + * line of result text, not an `NpcDefinition`. That hotspot becomes this + * person's doorway in `local-location.content.ts` instead of a second one + * being pinned beside it. + * + * Named rather than left as "South Gate Warden", because §2's own warning + * about duplicates assumes NPCs have names, and the screen renders a name + * and a title the way Borin's does. + * + * QUEST_GIVER and QUEST_TURN_IN sit together for the same reason Borin + * carries MERCHANT and RESOURCE_EXCHANGE: capabilities describe a person, + * they do not subclass one (NPC spec §2, §5). Nothing branches on them -- + * what the warden can actually do comes from the quest assignments. + */ + { + id: SOUTH_GATE_WARDEN_NPC_ID, + key: SOUTH_GATE_WARDEN_KEY, + name: 'Halvik', + title: 'Warden of the South Gate', + description: + 'They have watched the road long enough to stop expecting good news from it. Whatever comes back through the gate, they want it counted.', + locationKey: 'south-gate', + factionKey: 'border-guard', + // The gate watch's own portrait, painted before this slice made them a + // person. Reused rather than duplicated: it is the same figure. + portraitPath: '/images/npcs/graufurt-gate-watch.png', + artworkPath: null, + capabilities: [ + NpcCapability.DIALOGUE, + NpcCapability.QUEST_GIVER, + NpcCapability.QUEST_TURN_IN, + ], + enabled: true, + }, ]; export interface SeedDialogueNode { @@ -182,6 +223,78 @@ export const DIALOGUE_NODES: SeedDialogueNode[] = [ responses: [], enabled: true, }, + /** + * The warden's four lines (Slice 0.9 §3, §8). + * + * Gated on quest state rather than on which step is current: the beat-specific + * lines -- the referral, the thanks -- are content on the objectives and come + * back from the step endpoint that performs them (decision D4). These four + * cover what the warden says when you simply walk up, which is all a priority + * list needs to answer. + */ + { + npcId: SOUTH_GATE_WARDEN_NPC_ID, + key: 'warden-quest-offer', + text: 'The road has gone bad. Start small. Bring me five Ashen Pelts from the rats beyond the gate. I want to know what the ash is doing to them.', + priority: 900, + conditions: [ + { + type: GameConditionType.QUEST_ACTIVE, + key: TROUBLE_BEYOND_THE_GATE_KEY, + value: false, + }, + { + type: GameConditionType.QUEST_COMPLETED, + key: TROUBLE_BEYOND_THE_GATE_KEY, + value: false, + }, + ], + actions: [], + responses: [], + enabled: true, + }, + { + npcId: SOUTH_GATE_WARDEN_NPC_ID, + key: 'warden-quest-done', + text: 'The road is no safer, but at least someone is walking it. Whatever you drag back from now on, take it to Borin.', + priority: 700, + conditions: [ + { + type: GameConditionType.QUEST_COMPLETED, + key: TROUBLE_BEYOND_THE_GATE_KEY, + value: true, + }, + ], + actions: [], + responses: [], + enabled: true, + }, + { + npcId: SOUTH_GATE_WARDEN_NPC_ID, + key: 'warden-quest-active', + text: 'Still out there, then. Five pelts, and no fewer. I am not paying for a guess.', + priority: 500, + conditions: [ + { + type: GameConditionType.QUEST_ACTIVE, + key: TROUBLE_BEYOND_THE_GATE_KEY, + value: true, + }, + ], + actions: [], + responses: [], + enabled: true, + }, + { + npcId: SOUTH_GATE_WARDEN_NPC_ID, + key: 'warden-default', + text: "Beyond the gate, Graufurt's protection ends. Whoever heads south does so at their own risk.", + priority: 100, + conditions: [], + actions: [], + responses: [], + enabled: true, + }, ]; export interface SeedNpcShop { diff --git a/apps/api/src/database/seeds/quest-content.ts b/apps/api/src/database/seeds/quest-content.ts new file mode 100644 index 0000000..4e35fc9 --- /dev/null +++ b/apps/api/src/database/seeds/quest-content.ts @@ -0,0 +1,235 @@ +import { NpcQuestRole, QuestObjectiveType } from '../../quests/quest.types'; +import { + BORIN_KEY, + BORIN_NPC_ID, + SOUTH_GATE_REFERRAL_FLAG, + SOUTH_GATE_WARDEN_KEY, + SOUTH_GATE_WARDEN_NPC_ID, +} from './npc-content'; +import { + QUEST_ASSIGNMENT_IDS, + QUEST_OBJECTIVE_IDS, + TROUBLE_BEYOND_THE_GATE_KEY, + TROUBLE_BEYOND_THE_GATE_QUEST_ID, +} from './quest.constants'; + +export interface SeedQuestDefinition { + id: string; + key: string; + title: string; + description: string; + rewardFactionKey: string | null; + rewardReputation: number; + rewardSilver: number; + enabled: boolean; +} + +/** + * The first quest (Playable Slice 0.9 §2). + * + * Rewards are deliberately thin. Slice 0.9 §9 names the Basic Hide Bag and + * system knowledge as the point of the chain, and warns against a Silver reward + * that undermines the merchant trade loop the quest exists to teach -- so + * `rewardSilver` is 0 (decision D3). There is no renown milestone either + * (decision D2): the demo character sits at Renown 1, the first trade-in takes + * them to 2, and a third point would reach the World Renown 3 gate that Slice + * 0.8.5 deliberately parked out of reach until Slice 0.11. §9 lists the + * milestone as optional, so leaving it out is the reading that keeps both + * slices honest. Both values are content, so retuning either is a seed change. + */ +export const QUEST_DEFINITIONS: SeedQuestDefinition[] = [ + { + id: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + key: TROUBLE_BEYOND_THE_GATE_KEY, + title: 'Trouble Beyond the Gate', + description: + 'The warden at the South Gate wants to know what the ash is doing to the creatures on the road. Five Ashen Pelts is how you show them.', + rewardFactionKey: 'border-guard', + rewardReputation: 10, + rewardSilver: 0, + enabled: true, + }, +]; + +export interface SeedQuestObjective { + id: string; + questId: string; + key: string; + orderIndex: number; + type: QuestObjectiveType; + targetKey: string; + requiredQuantity: number; + description: string; + npcLine: string | null; + hintText: string | null; + advanceWhenBlocked: boolean; + consumeOnComplete: boolean; + grantsLootBagKey: string | null; + setsFlagKey: string | null; + setsFlagNpcKey: string | null; + enabled: boolean; +} + +/** + * The chain, as five ordered steps (Slice 0.9 §3–§8). + * + * Two collect steps rather than one with a mid-step hint, because the trip back + * to the warden is something the player *does*, and a step is what the state + * machine can hold. `advanceWhenBlocked` on the first one is what hands them + * over at 1 / 5 instead of stranding them (§4, §13 "return step activates + * correctly"); the second one has the bag and is expected to finish (§7). + * + * Only the second collect step consumes. Both ask for five pelts, so consuming + * both would quietly demand ten (§8). + * + * Every player-facing line here is quoted from the slice document. + */ +export const QUEST_OBJECTIVES: SeedQuestObjective[] = [ + { + id: QUEST_OBJECTIVE_IDS.collectPeltsFirst, + questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + key: 'collect-pelts-first', + orderIndex: 0, + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + requiredQuantity: 5, + description: 'Collect Ashen Pelts', + npcLine: null, + hintText: + 'You cannot carry enough pelts. Return to the South Gate Warden.', + advanceWhenBlocked: true, + consumeOnComplete: false, + grantsLootBagKey: null, + setsFlagKey: null, + setsFlagNpcKey: null, + enabled: true, + }, + { + id: QUEST_OBJECTIVE_IDS.reportCapacity, + questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + key: 'report-capacity', + orderIndex: 1, + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: SOUTH_GATE_WARDEN_KEY, + requiredQuantity: 1, + description: 'Return to the South Gate Warden', + npcLine: + "Right. You're not equipped for hauling spoils yet. Go see Borin in Graufurt. Tell him I sent you. He'll complain, but he'll give you something useful.", + hintText: null, + advanceWhenBlocked: false, + consumeOnComplete: false, + grantsLootBagKey: null, + // Written onto Borin's row, not the warden's: a dialogue flag is per-NPC + // player state (NPC spec §7), and Borin's row is what the Hide Bag offer's + // 0.8.5 bypass reads (slice §6). + setsFlagKey: SOUTH_GATE_REFERRAL_FLAG, + setsFlagNpcKey: BORIN_KEY, + enabled: true, + }, + { + id: QUEST_OBJECTIVE_IDS.collectBag, + questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + key: 'collect-bag', + orderIndex: 2, + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: BORIN_KEY, + requiredQuantity: 1, + description: 'Speak with Borin in Graufurt', + npcLine: + 'But the South Gate Warden sent you. Fine. Take this. Bring it back full and make it worth my trouble.', + hintText: null, + advanceWhenBlocked: false, + consumeOnComplete: false, + // Slice 0.9 decision D1. §6 has Borin say "Take this" while also pointing + // at the 35-Silver offer the referral unlocks; a player standing here has + // no Silver, so the purchase alone would stall the chain. The step hands + // the bag over, and the referral flag from the previous step still opens + // the offer -- a visible consequence and a way back to a bag that is lost. + grantsLootBagKey: 'basic-hide-bag', + setsFlagKey: null, + setsFlagNpcKey: null, + enabled: true, + }, + { + id: QUEST_OBJECTIVE_IDS.collectPelts, + questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + key: 'collect-pelts', + orderIndex: 3, + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + requiredQuantity: 5, + description: 'Collect Ashen Pelts', + npcLine: null, + // The bag holds five of *any* hide, not five pelts: a player carrying + // Tough Hides alongside them can fill it before the fifth pelt. This step + // must not advance for that -- it is meant to be finished (§7) -- so it + // says what to do instead, which is the same courtesy §4 asks for at the + // bagless limit. + hintText: + 'Your hide bag is full of other goods. Trade some to Borin to make room for pelts.', + advanceWhenBlocked: false, + consumeOnComplete: true, + grantsLootBagKey: null, + setsFlagKey: null, + setsFlagNpcKey: null, + enabled: true, + }, + { + id: QUEST_OBJECTIVE_IDS.turnIn, + questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + key: 'turn-in', + orderIndex: 4, + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: SOUTH_GATE_WARDEN_KEY, + requiredQuantity: 1, + description: 'Bring the pelts to the South Gate Warden', + npcLine: + "Good. That's enough for me. From now on, take hides and trophies to Borin. He'll pay for useful spoils, and word gets around when you keep the roads clear.", + hintText: null, + advanceWhenBlocked: false, + consumeOnComplete: false, + grantsLootBagKey: null, + setsFlagKey: null, + setsFlagNpcKey: null, + enabled: true, + }, +]; + +export interface SeedNpcQuestAssignment { + id: string; + npcId: string; + questId: string; + role: NpcQuestRole; + enabled: boolean; +} + +/** + * Who does what (NPC spec §14). + * + * §14's own example is a quest offered by one person, moved forward by a second + * and handed back to the first. That is exactly this chain, and it is why the + * assignment is a row rather than an `isQuestGiver` flag on the NPC. + */ +export const NPC_QUEST_ASSIGNMENTS: SeedNpcQuestAssignment[] = [ + { + id: QUEST_ASSIGNMENT_IDS.wardenOffer, + npcId: SOUTH_GATE_WARDEN_NPC_ID, + questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + role: NpcQuestRole.OFFER, + enabled: true, + }, + { + id: QUEST_ASSIGNMENT_IDS.wardenTurnIn, + npcId: SOUTH_GATE_WARDEN_NPC_ID, + questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + role: NpcQuestRole.TURN_IN, + enabled: true, + }, + { + id: QUEST_ASSIGNMENT_IDS.borinProgress, + npcId: BORIN_NPC_ID, + questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID, + role: NpcQuestRole.PROGRESS, + enabled: true, + }, +]; diff --git a/apps/api/src/database/seeds/quest.constants.ts b/apps/api/src/database/seeds/quest.constants.ts new file mode 100644 index 0000000..f73be03 --- /dev/null +++ b/apps/api/src/database/seeds/quest.constants.ts @@ -0,0 +1,29 @@ +// Stable quest content ids and keys (AGENTS.md §8). +// +// Their own file because both `npc-content.ts` and `quest-content.ts` need +// them: the warden's dialogue gates on the quest key, and the quest content +// needs the warden's NPC id. Sharing a constants module is how the seeds +// already break that kind of knot (see `item.constants.ts`). + +export const TROUBLE_BEYOND_THE_GATE_KEY = 'trouble-beyond-the-gate'; +export const TROUBLE_BEYOND_THE_GATE_QUEST_ID = + 'c0000000-0000-4000-8000-000000000001'; + +/** + * An objective's `key` is only unique within its quest, so it cannot serve as + * an upsert conflict target. These ids are what make a re-seed re-tune a step + * instead of inserting a second one. + */ +export const QUEST_OBJECTIVE_IDS = { + collectPeltsFirst: 'c1000000-0000-4000-8000-000000000001', + reportCapacity: 'c1000000-0000-4000-8000-000000000002', + collectBag: 'c1000000-0000-4000-8000-000000000003', + collectPelts: 'c1000000-0000-4000-8000-000000000004', + turnIn: 'c1000000-0000-4000-8000-000000000005', +} as const; + +export const QUEST_ASSIGNMENT_IDS = { + wardenOffer: 'c2000000-0000-4000-8000-000000000001', + wardenTurnIn: 'c2000000-0000-4000-8000-000000000002', + borinProgress: 'c2000000-0000-4000-8000-000000000003', +} as const; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index f3bf441..781a4e4 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -31,8 +31,21 @@ import { ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; +import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity'; +import { QuestDefinition } from '../../quests/entities/quest-definition.entity'; +import { QuestObjective } from '../../quests/entities/quest-objective.entity'; +import { NpcQuestRole, QuestObjectiveType } from '../../quests/quest.types'; import { BASIC_HIDE_BAG_ID } from './loot-bag-content'; -import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content'; +import { + BORIN_KEY, + BORIN_NPC_ID, + BORIN_OFFER_IDS, + SHOP_OFFERS, + SOUTH_GATE_REFERRAL_FLAG, + SOUTH_GATE_WARDEN_KEY, + SOUTH_GATE_WARDEN_NPC_ID, +} from './npc-content'; +import { TROUBLE_BEYOND_THE_GATE_KEY } from './quest.constants'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; @@ -92,53 +105,108 @@ class InMemoryRepository { }); } +/** + * Every table the seed touches, addressed by name. + * + * Named rather than positional: the seed writes more than twenty tables now, + * and a test that only cares about quest objectives should not have to count + * out placeholder repositories to reach them. + */ +interface SeedRepositories { + location: InMemoryRepository; + connection: InMemoryRepository; + character: InMemoryRepository; + monster: InMemoryRepository; + locationMonster: InMemoryRepository; + item: InMemoryRepository; + lootTable: InMemoryRepository; + lootEntry: InMemoryRepository; + characterItem: InMemoryRepository; + characterEquipment: InMemoryRepository; + reputationFaction: InMemoryRepository; + renownMilestone: InMemoryRepository; + lootBagDefinition: InMemoryRepository; + characterLootBag: InMemoryRepository; + npcDefinition: InMemoryRepository; + dialogueNode: InMemoryRepository; + npcShop: InMemoryRepository; + shopOffer: InMemoryRepository; + npcExchangeProfile: InMemoryRepository; + exchangeRule: InMemoryRepository; + questDefinition: InMemoryRepository; + questObjective: InMemoryRepository; + npcQuestAssignment: InMemoryRepository; +} + +const REPOSITORY_KEYS: ReadonlyArray = [ + 'location', + 'connection', + 'character', + 'monster', + 'locationMonster', + 'item', + 'lootTable', + 'lootEntry', + 'characterItem', + 'characterEquipment', + 'reputationFaction', + 'renownMilestone', + 'lootBagDefinition', + 'characterLootBag', + 'npcDefinition', + 'dialogueNode', + 'npcShop', + 'shopOffer', + 'npcExchangeProfile', + 'exchangeRule', + 'questDefinition', + 'questObjective', + 'npcQuestAssignment', +]; + function createDataSource( - locationRepository: InMemoryRepository, - connectionRepository: InMemoryRepository, - characterRepository: InMemoryRepository, - monsterRepository: InMemoryRepository, - locationMonsterRepository: InMemoryRepository, - itemRepository: InMemoryRepository = new InMemoryRepository(), - lootTableRepository: InMemoryRepository = new InMemoryRepository(), - lootEntryRepository: InMemoryRepository = new InMemoryRepository(), - characterItemRepository: InMemoryRepository = new InMemoryRepository(), - characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(), - reputationFactionRepository: InMemoryRepository = new InMemoryRepository(), - renownMilestoneRepository: InMemoryRepository = new InMemoryRepository(), - lootBagDefinitionRepository: InMemoryRepository = new InMemoryRepository(), - characterLootBagRepository: InMemoryRepository = new InMemoryRepository(), - npcDefinitionRepository: InMemoryRepository = new InMemoryRepository(), - dialogueNodeRepository: InMemoryRepository = new InMemoryRepository(), - npcShopRepository: InMemoryRepository = new InMemoryRepository(), - shopOfferRepository: InMemoryRepository = new InMemoryRepository(), - npcExchangeProfileRepository: InMemoryRepository = new InMemoryRepository(), - exchangeRuleRepository: InMemoryRepository = new InMemoryRepository(), + overrides: Partial = {}, ): DataSource { + const repositories = Object.fromEntries( + REPOSITORY_KEYS.map((key) => [ + key, + overrides[key] ?? new InMemoryRepository(), + ]), + ) as SeedRepositories; + + const byEntity = new Map([ + [LocationDefinition, repositories.location], + [LocationConnection, repositories.connection], + [Character, repositories.character], + [MonsterDefinition, repositories.monster], + [LocationMonster, repositories.locationMonster], + [ItemDefinition, repositories.item], + [LootTable, repositories.lootTable], + [LootTableEntry, repositories.lootEntry], + [CharacterItem, repositories.characterItem], + [CharacterEquipment, repositories.characterEquipment], + [ReputationFaction, repositories.reputationFaction], + [RenownMilestoneDefinition, repositories.renownMilestone], + [NpcDefinition, repositories.npcDefinition], + [DialogueNode, repositories.dialogueNode], + [NpcShop, repositories.npcShop], + [ShopOffer, repositories.shopOffer], + [NpcExchangeProfile, repositories.npcExchangeProfile], + [ExchangeRule, repositories.exchangeRule], + [LootBagDefinition, repositories.lootBagDefinition], + [CharacterLootBag, repositories.characterLootBag], + [QuestDefinition, repositories.questDefinition], + [QuestObjective, repositories.questObjective], + [NpcQuestAssignment, repositories.npcQuestAssignment], + ]); + return { getRepository: jest.fn((entity: unknown) => { - if (entity === LocationDefinition) return locationRepository; - if (entity === LocationConnection) return connectionRepository; - if (entity === Character) return characterRepository; - if (entity === MonsterDefinition) return monsterRepository; - if (entity === LocationMonster) return locationMonsterRepository; - if (entity === ItemDefinition) return itemRepository; - if (entity === LootTable) return lootTableRepository; - if (entity === LootTableEntry) return lootEntryRepository; - if (entity === CharacterItem) return characterItemRepository; - if (entity === CharacterEquipment) return characterEquipmentRepository; - if (entity === ReputationFaction) return reputationFactionRepository; - if (entity === RenownMilestoneDefinition) - return renownMilestoneRepository; - if (entity === NpcDefinition) return npcDefinitionRepository; - if (entity === DialogueNode) return dialogueNodeRepository; - if (entity === NpcShop) return npcShopRepository; - if (entity === ShopOffer) return shopOfferRepository; - if (entity === NpcExchangeProfile) return npcExchangeProfileRepository; - if (entity === ExchangeRule) return exchangeRuleRepository; - if (entity === LootBagDefinition) return lootBagDefinitionRepository; - if (entity === CharacterLootBag) return characterLootBagRepository; - - throw new Error('Unexpected repository'); + const repository = byEntity.get(entity); + if (!repository) { + throw new Error('Unexpected repository'); + } + return repository; }), } as unknown as DataSource; } @@ -150,13 +218,13 @@ describe('seedVisibleVerticalSlice', () => { const characterRepository = new InMemoryRepository(); const monsterRepository = new InMemoryRepository(); const locationMonsterRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + }); await seedVisibleVerticalSlice(dataSource); Object.assign(characterRepository.rows[0], { @@ -308,13 +376,9 @@ describe('seedVisibleVerticalSlice', () => { it('seeds the local view content of the Burned Road with four points of interest', async () => { const locationRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - ); + const dataSource = createDataSource({ + location: locationRepository, + }); await seedVisibleVerticalSlice(dataSource); @@ -364,13 +428,9 @@ describe('seedVisibleVerticalSlice', () => { it('gives the South Gate its own local content so a second location needs no new component', async () => { const locationRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - ); + const dataSource = createDataSource({ + location: locationRepository, + }); await seedVisibleVerticalSlice(dataSource); @@ -406,13 +466,13 @@ describe('seedVisibleVerticalSlice', () => { key: 'south-gate', name: 'Outdated South Gate', }); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + }); await seedVisibleVerticalSlice(dataSource); @@ -458,16 +518,16 @@ describe('seedVisibleVerticalSlice', () => { const itemRepository = new InMemoryRepository(); const lootTableRepository = new InMemoryRepository(); const lootEntryRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - itemRepository, - lootTableRepository, - lootEntryRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + item: itemRepository, + lootTable: lootTableRepository, + lootEntry: lootEntryRepository, + }); await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); @@ -529,20 +589,28 @@ describe('seedVisibleVerticalSlice', () => { expect(lootTableRepository.rows).toHaveLength(4); expect(lootEntryRepository.rows).toHaveLength(12); - // Every Burned Road enemy guarantees exactly one trade good (spec §5). - const guaranteedTradeGoods: Array<[string, string]> = [ - [ASH_RAT_LOOT_TABLE_ID, ITEM_IDS['ash-pelt']], - [WILD_ROAD_DOG_LOOT_TABLE_ID, ITEM_IDS['tough-hide']], - [ROAD_BANDIT_LOOT_TABLE_ID, ITEM_IDS['bandit-insignia']], - [CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS['charred-raider-insignia']], + // Every Burned Road enemy carries exactly one trade good as its first + // entry (spec §5). The three common ones were retuned from a guaranteed + // drop to 60%; the rare Charred Raider Insignia stayed certain, because a + // 2%-weight encounter that then withholds its reward is just a lost trip. + const tradeGoodDropChances: Array<[string, string, string]> = [ + [ASH_RAT_LOOT_TABLE_ID, ITEM_IDS['ash-pelt'], '0.6000'], + [WILD_ROAD_DOG_LOOT_TABLE_ID, ITEM_IDS['tough-hide'], '0.6000'], + [ROAD_BANDIT_LOOT_TABLE_ID, ITEM_IDS['bandit-insignia'], '0.6000'], + [ + CHARRED_LOOTER_LOOT_TABLE_ID, + ITEM_IDS['charred-raider-insignia'], + '1.0000', + ], ]; - for (const [lootTableId, itemDefinitionId] of guaranteedTradeGoods) { + for (const [lootTableId, itemDefinitionId, dropChance] of + tradeGoodDropChances) { expect(lootEntryRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ lootTableId, itemDefinitionId, - dropChance: '1.0000', + dropChance, minQuantity: 1, maxQuantity: 1, enabled: true, @@ -605,21 +673,9 @@ describe('seedVisibleVerticalSlice', () => { it('seeds both starter loot bags idempotently', async () => { const lootBagDefinitionRepository = new InMemoryRepository(); - const dataSource = createDataSource( - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - lootBagDefinitionRepository, - ); + const dataSource = createDataSource({ + lootBagDefinition: lootBagDefinitionRepository, + }); await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); @@ -643,40 +699,21 @@ describe('seedVisibleVerticalSlice', () => { ); }); - it('gives the demo character only the Hide Bag, idempotently', async () => { + it('no longer hands the demo character a loot bag', async () => { const characterLootBagRepository = new InMemoryRepository(); - const dataSource = createDataSource( - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - characterLootBagRepository, - ); + const dataSource = createDataSource({ + characterLootBag: characterLootBagRepository, + }); await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); - // Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer, - // so handing it to the demo character for free would undercut the - // showcase. The Hide Bag stays -- without it, HIDE capacity would drop to - // the bagless default of 1 with no way to raise it before Slice 0.9 grants - // it through the warden's referral. See the ASSUMPTION note in the seed. - expect(characterLootBagRepository.rows).toHaveLength(1); - expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([ - true, - ]); - expect( - characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId), - ).toEqual(['a0000000-0000-4000-8000-000000000001']); + // Slice 0.9 decision D5. The Hide Bag was a stopgap: Slice 0.8.5 turned the + // Trophy Pouch into a reputation-gated offer and left the Hide Bag in the + // seed so the hunting loop stayed playable until a quest could hand one + // over. That quest exists now, and the default HIDE capacity of 1 is the + // whole lesson the chain teaches (slice §4) -- a free bag would hide it. + expect(characterLootBagRepository.rows).toHaveLength(0); }); it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { @@ -687,18 +724,15 @@ describe('seedVisibleVerticalSlice', () => { const locationMonsterRepository = new InMemoryRepository(); const characterItemRepository = new InMemoryRepository(); const characterEquipmentRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - characterItemRepository, - characterEquipmentRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + characterItem: characterItemRepository, + characterEquipment: characterEquipmentRepository, + }); await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); @@ -729,18 +763,15 @@ describe('seedVisibleVerticalSlice', () => { const locationMonsterRepository = new InMemoryRepository(); const characterItemRepository = new InMemoryRepository(); const characterEquipmentRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - characterItemRepository, - characterEquipmentRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + characterItem: characterItemRepository, + characterEquipment: characterEquipmentRepository, + }); await seedVisibleVerticalSlice(dataSource); // Simulate the player having equipped earned loot instead. @@ -764,18 +795,15 @@ describe('seedVisibleVerticalSlice', () => { const locationMonsterRepository = new InMemoryRepository(); const characterItemRepository = new InMemoryRepository(); const characterEquipmentRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - characterItemRepository, - characterEquipmentRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + characterItem: characterItemRepository, + characterEquipment: characterEquipmentRepository, + }); // Simulate the demo character having already looted a worn-short-sword // naturally, under a DB-generated id that differs from the seed's @@ -803,19 +831,9 @@ describe('seedVisibleVerticalSlice', () => { it('seeds the Border Watch faction', async () => { const reputationFactionRepository = new InMemoryRepository(); - const dataSource = createDataSource( - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - reputationFactionRepository, - ); + const dataSource = createDataSource({ + reputationFaction: reputationFactionRepository, + }); await seedVisibleVerticalSlice(dataSource); @@ -830,40 +848,24 @@ describe('seedVisibleVerticalSlice', () => { const npcDefinitionRepository = new InMemoryRepository(); const npcShopRepository = new InMemoryRepository(); const exchangeRuleRepository = new InMemoryRepository(); - const dataSource = createDataSource( - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - npcDefinitionRepository, - new InMemoryRepository(), - npcShopRepository, - new InMemoryRepository(), - new InMemoryRepository(), - exchangeRuleRepository, - ); + const dataSource = createDataSource({ + npcDefinition: npcDefinitionRepository, + npcShop: npcShopRepository, + exchangeRule: exchangeRuleRepository, + }); // Twice: seeds must be idempotent (NPC spec §32). await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); - expect(npcDefinitionRepository.rows).toHaveLength(1); - expect(npcDefinitionRepository.rows[0]).toMatchObject({ - key: 'borin-quartermaster', - enabled: true, - }); + // Looked up by key rather than by position: Slice 0.9 put the warden at the + // same gate, so "the first row" stopped meaning "Borin". + const borin = npcDefinitionRepository.rows.find( + (row) => row.key === BORIN_KEY, + ) as Row; + expect(borin).toMatchObject({ key: BORIN_KEY, enabled: true }); // Placed in Graufurt via the location key, not a hardcoded id. - expect(npcDefinitionRepository.rows[0].locationId).toBe(SOUTH_GATE_ID); + expect(borin.locationId).toBe(SOUTH_GATE_ID); expect(npcShopRepository.rows).toHaveLength(1); // All four Burned Road trade goods are accepted (slice 0.8 §5), and the @@ -882,20 +884,9 @@ describe('seedVisibleVerticalSlice', () => { it('seeds the renown milestone the first trade-in completes', async () => { const renownMilestoneRepository = new InMemoryRepository(); - const dataSource = createDataSource( - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - renownMilestoneRepository, - ); + const dataSource = createDataSource({ + renownMilestone: renownMilestoneRepository, + }); await seedVisibleVerticalSlice(dataSource); @@ -962,26 +953,9 @@ describe('seedVisibleVerticalSlice', () => { it('still holds exactly five offers after a re-seed, at the tuned numbers', async () => { const shopOfferRepository = new InMemoryRepository(); - const dataSource = createDataSource( - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - shopOfferRepository, - ); + const dataSource = createDataSource({ + shopOffer: shopOfferRepository, + }); // Twice, because that is the whole point of the stable ids (NPC spec §32). // Both bag offers carry `itemDefinitionId: null`, so the pre-0.8.5 conflict @@ -1053,4 +1027,234 @@ describe('seedVisibleVerticalSlice', () => { ], }); }); + + it('seeds the South Gate Warden beside Borin at the gate', async () => { + const npcDefinitionRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + npcDefinition: npcDefinitionRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + // Slice 0.9 §2 says to reuse an existing named gate NPC. There was none -- + // the gate watch was authored scenery, not an NpcDefinition -- so the + // warden is new, and stands where Borin already does. + expect(npcDefinitionRepository.rows).toHaveLength(2); + const warden = npcDefinitionRepository.rows.find( + (row) => row.key === SOUTH_GATE_WARDEN_KEY, + ) as Row; + expect(warden).toMatchObject({ + id: SOUTH_GATE_WARDEN_NPC_ID, + enabled: true, + locationId: SOUTH_GATE_ID, + factionKey: 'border-guard', + }); + expect(warden.capabilities).toEqual( + expect.arrayContaining(['QUEST_GIVER', 'QUEST_TURN_IN']), + ); + }); + + it('seeds one quest with five ordered objectives, idempotently', async () => { + const questDefinitionRepository = new InMemoryRepository(); + const questObjectiveRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + questDefinition: questDefinitionRepository, + questObjective: questObjectiveRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + expect(questDefinitionRepository.rows).toHaveLength(1); + expect(questDefinitionRepository.rows[0]).toMatchObject({ + key: TROUBLE_BEYOND_THE_GATE_KEY, + title: 'Trouble Beyond the Gate', + enabled: true, + }); + + expect(questObjectiveRepository.rows).toHaveLength(5); + expect( + questObjectiveRepository.rows.map((row) => row.orderIndex), + ).toEqual([0, 1, 2, 3, 4]); + expect(questObjectiveRepository.rows.map((row) => row.type)).toEqual([ + QuestObjectiveType.COLLECT_ITEM, + QuestObjectiveType.TALK_TO_NPC, + QuestObjectiveType.TALK_TO_NPC, + QuestObjectiveType.COLLECT_ITEM, + QuestObjectiveType.TALK_TO_NPC, + ]); + }); + + it('routes the first pelt hunt back to the warden when the bag is missing', async () => { + const questObjectiveRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + questObjective: questObjectiveRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const collectSteps = questObjectiveRepository.rows.filter( + (row) => row.type === QuestObjectiveType.COLLECT_ITEM, + ); + + // Only the first one gives up at the carrying limit (slice §4). The second + // one happens with the bag in hand and is meant to be finished (§7). + expect(collectSteps.map((row) => row.advanceWhenBlocked)).toEqual([ + true, + false, + ]); + expect(collectSteps[0].hintText).toBe( + 'You cannot carry enough pelts. Return to the South Gate Warden.', + ); + expect(collectSteps.map((row) => row.targetKey)).toEqual([ + 'ash-pelt', + 'ash-pelt', + ]); + expect(collectSteps.map((row) => row.requiredQuantity)).toEqual([5, 5]); + }); + + it('tells the player what to do when the bag fills with other hides', async () => { + const questObjectiveRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + questObjective: questObjectiveRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + // The Hide Bag holds five of any hide, so Tough Hides can crowd out the + // fifth pelt. That step must not give up -- it is meant to be finished + // (§7) -- so it needs a way forward instead. + const secondCollect = questObjectiveRepository.rows.find( + (row) => row.orderIndex === 3, + ) as Row; + expect(secondCollect.advanceWhenBlocked).toBe(false); + expect(secondCollect.hintText).toBe( + 'Your hide bag is full of other goods. Trade some to Borin to make room for pelts.', + ); + }); + + it('writes the referral flag onto Borin, not onto the warden', async () => { + const questObjectiveRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + questObjective: questObjectiveRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const referralStep = questObjectiveRepository.rows.find( + (row) => row.setsFlagKey === SOUTH_GATE_REFERRAL_FLAG, + ) as Row; + + // Dialogue flags are per-NPC state (NPC spec §7), and the 0.8.5 bypass on + // the Hide Bag offer is evaluated with Borin in context (slice §6). + expect(referralStep.setsFlagNpcKey).toBe(BORIN_KEY); + expect(referralStep.targetKey).toBe(SOUTH_GATE_WARDEN_KEY); + }); + + it('hands the Basic Hide Bag over on the step at Borin', async () => { + const questObjectiveRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + questObjective: questObjectiveRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const bagSteps = questObjectiveRepository.rows.filter( + (row) => row.grantsLootBagKey !== null, + ); + + expect(bagSteps).toHaveLength(1); + expect(bagSteps[0]).toMatchObject({ + targetKey: BORIN_KEY, + grantsLootBagKey: 'basic-hide-bag', + }); + }); + + it('consumes pelts only on the second collect step', async () => { + const questObjectiveRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + questObjective: questObjectiveRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + // Both steps ask for five pelts; consuming both would demand ten (§8). + const consuming = questObjectiveRepository.rows.filter( + (row) => row.consumeOnComplete === true, + ); + expect(consuming).toHaveLength(1); + expect(consuming[0].orderIndex).toBe(3); + }); + + it('pays reputation only, with no silver and no renown milestone', async () => { + const questDefinitionRepository = new InMemoryRepository(); + const renownMilestoneRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + questDefinition: questDefinitionRepository, + renownMilestone: renownMilestoneRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + // Slice 0.9 decisions D2/D3: a Silver payout would undercut the trade loop + // the quest exists to teach, and a renown milestone would reach World + // Renown 3 and open the Bandit Blade that Slice 0.8.5 parked until 0.11. + expect(questDefinitionRepository.rows[0]).toMatchObject({ + rewardFactionKey: 'border-guard', + rewardReputation: 10, + rewardSilver: 0, + }); + expect(renownMilestoneRepository.rows).toHaveLength(1); + expect(renownMilestoneRepository.rows[0].key).toBe('first-goods-returned'); + }); + + it('splits the quest across two NPCs by role', async () => { + const npcQuestAssignmentRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + npcQuestAssignment: npcQuestAssignmentRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + // NPC spec §14's worked example, in content: one person offers and + // receives, another moves it forward. + expect(npcQuestAssignmentRepository.rows).toHaveLength(3); + const roleByNpc = npcQuestAssignmentRepository.rows.map((row) => [ + row.npcId, + row.role, + ]); + expect(roleByNpc).toEqual( + expect.arrayContaining([ + [SOUTH_GATE_WARDEN_NPC_ID, NpcQuestRole.OFFER], + [SOUTH_GATE_WARDEN_NPC_ID, NpcQuestRole.TURN_IN], + [BORIN_NPC_ID, NpcQuestRole.PROGRESS], + ]), + ); + }); + + it('turns the gate watch hotspot into the warden doorway', async () => { + const locationRepository = new InMemoryRepository(); + const dataSource = createDataSource({ location: locationRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const southGate = locationRepository.rows.find( + (row) => row.key === 'south-gate', + ) as Row; + const watch = ( + southGate.localPointsOfInterest as Array> + ).find((poi) => poi.key === 'gate-watch') as Record; + + // A hotspot carries authored result text or an npcKey, never both -- the + // warden is a person now, not a plaque. + expect(watch.npcKey).toBe(SOUTH_GATE_WARDEN_KEY); + expect(watch.resultText).toBeUndefined(); + + const action = ( + southGate.localPrimaryActions as Array> + ).find((entry) => entry.poiKey === 'gate-watch') as Record; + expect(action.npcKey).toBe(SOUTH_GATE_WARDEN_KEY); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index f5be0f9..7a65c84 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -11,8 +11,10 @@ import { EquipmentSlot } from '../../items/equipment-slot.enum'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTable } from '../../loot/entities/loot-table.entity'; -import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity'; import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity'; +import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity'; +import { QuestDefinition } from '../../quests/entities/quest-definition.entity'; +import { QuestObjective } from '../../quests/entities/quest-objective.entity'; import { EncounterType } from '../../monsters/entities/encounter-type.enum'; import { MonsterCategory } from '../../monsters/monster-category.enum'; import { LocationMonster } from '../../monsters/entities/location-monster.entity'; @@ -43,7 +45,12 @@ import { BURNED_ROAD_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT, } from './local-location.content'; -import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content'; +import { LOOT_BAG_DEFINITIONS } from './loot-bag-content'; +import { + NPC_QUEST_ASSIGNMENTS, + QUEST_DEFINITIONS, + QUEST_OBJECTIVES, +} from './quest-content'; import { REPUTATION_FACTIONS } from './reputation-content'; import { DIALOGUE_NODES, @@ -88,6 +95,10 @@ export async function seedVisibleVerticalSlice( const npcExchangeProfileRepository = dataSource.getRepository(NpcExchangeProfile); const exchangeRuleRepository = dataSource.getRepository(ExchangeRule); + const questDefinitionRepository = dataSource.getRepository(QuestDefinition); + const questObjectiveRepository = dataSource.getRepository(QuestObjective); + const npcQuestAssignmentRepository = + dataSource.getRepository(NpcQuestAssignment); const locations = [ { @@ -358,28 +369,14 @@ export async function seedVisibleVerticalSlice( }); } - // ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag - // and no longer starts with the Trophy Pouch. + // The demo character starts with no loot bag at all (Slice 0.9 decision D5). // - // The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over - // for free would make the slice's own showcase pointless. The Hide Bag stays - // until Slice 0.9 grants it through the warden's referral -- removing both - // now would drop HIDE capacity to the bagless default of 1 with no way to - // raise it, and the hunting loop would be unplayable in between. - // - // Delete this block once 0.9 hands the Hide Bag over in the quest. - const characterLootBagRepository = dataSource.getRepository(CharacterLootBag); - const existingBag = await characterLootBagRepository.findOneBy({ - characterId: DEMO_CHARACTER_ID, - lootBagDefinitionId: BASIC_HIDE_BAG_ID, - }); - if (!existingBag) { - await characterLootBagRepository.insert({ - characterId: DEMO_CHARACTER_ID, - lootBagDefinitionId: BASIC_HIDE_BAG_ID, - active: true, - }); - } + // Slice 0.8.5 left the Hide Bag here as a stopgap, with a note to remove it + // "once 0.9 hands the Hide Bag over in the quest". This is that moment: the + // bagless HIDE capacity of 1 is what the first quest is built to teach + // (0.9 §4), and a free bag would hide the lesson entirely. The quest's step + // at Borin grants the bag instead, and the migration clears the row from + // databases that were seeded before this change. // NPC content (NPC Specification V1 §32; Playable Slice 0.8). // @@ -401,6 +398,16 @@ export async function seedVisibleVerticalSlice( ]); } + // Quest content (Playable Slice 0.9 §10; NPC spec §14). + // + // After the NPC upsert, because an assignment points at an NPC id. Objectives + // and assignments conflict on `id` rather than on a natural key: an + // objective's key is unique only within its quest, and an assignment has no + // natural key at all beyond the triple its unique index already covers. + await questDefinitionRepository.upsert(QUEST_DEFINITIONS, ['key']); + await questObjectiveRepository.upsert(QUEST_OBJECTIVES, ['id']); + await npcQuestAssignmentRepository.upsert(NPC_QUEST_ASSIGNMENTS, ['id']); + await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']); await npcShopRepository.upsert(NPC_SHOPS, ['key']); // By id, not by (shop, item): an offer may now sell a bag instead of an diff --git a/apps/api/src/npcs/npc.controller.ts b/apps/api/src/npcs/npc.controller.ts index 63fece6..1a589a4 100644 --- a/apps/api/src/npcs/npc.controller.ts +++ b/apps/api/src/npcs/npc.controller.ts @@ -12,7 +12,7 @@ export class NpcController { getNpcsAtLocation( @Param('locationId') locationId: string, ): Promise { - return this.npcService.getNpcsAtLocation(locationId); + return this.npcService.getNpcsAtLocation(DEMO_CHARACTER_ID, locationId); } /** diff --git a/apps/api/src/npcs/npc.service.spec.ts b/apps/api/src/npcs/npc.service.spec.ts index 9bc5a75..58f7454 100644 --- a/apps/api/src/npcs/npc.service.spec.ts +++ b/apps/api/src/npcs/npc.service.spec.ts @@ -7,6 +7,11 @@ import { NpcShop } from '../shops/entities/npc-shop.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity'; import { DialogueNode } from './entities/dialogue-node.entity'; import { NpcDefinition } from './entities/npc-definition.entity'; +import { + NpcQuestState, + QuestProgressService, +} from '../quests/quest-progress.service'; +import { NpcQuestRole, QuestObjectiveType } from '../quests/quest.types'; import { NpcService } from './npc.service'; import { NpcCapability } from './npc.types'; @@ -24,6 +29,12 @@ interface Fixture { hasExchangeProfile?: boolean; exchangeRuleCount?: number; existingState?: Record | null; + questSituation?: + | 'offers' + | 'offers-and-receives' + | 'step-is-here' + | 'step-is-elsewhere' + | 'completed'; } function createWorld(fixture: Fixture = {}) { @@ -124,7 +135,79 @@ function createWorld(fixture: Fixture = {}) { }), } as unknown as GameConditionService; - return { service: new NpcService(dataSource, conditions), savedStates, npc }; + const questProgress = { + getNpcQuestStates: jest + .fn() + .mockResolvedValue(questStatesFor(fixture)), + } as unknown as QuestProgressService; + + return { + service: new NpcService(dataSource, conditions, questProgress), + savedStates, + npc, + }; +} + +/** + * The quest situations `QuestProgressService` can report for one NPC. + * + * Kept as a fixture switch rather than a hand-built state per test: what + * `NpcService` does with a quest state is a small precedence decision, and the + * derivation itself is already covered by `quest-progress.service.spec.ts`. + */ +function questStatesFor(fixture: Fixture): NpcQuestState[] { + const quest = { id: 'quest-1', key: 'trouble-beyond-the-gate' }; + + const build = ( + status: 'AVAILABLE' | 'ACTIVE' | 'COMPLETED', + currentIndex: number | null, + roles: NpcQuestRole[], + ): NpcQuestState => + ({ + state: { + quest, + status, + currentIndex, + row: null, + objectives: [ + { + objective: { + key: 'collect', + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + }, + }, + { + objective: { + key: 'talk', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: 'borin-quartermaster', + }, + }, + ], + }, + roles, + }) as unknown as NpcQuestState; + + switch (fixture.questSituation) { + case 'offers': + return [build('AVAILABLE', null, [NpcQuestRole.OFFER])]; + case 'offers-and-receives': + // One person with something to start *and* a step waiting: exactly what + // the South Gate Warden looks like mid-chain (NPC spec §14). + return [ + build('AVAILABLE', null, [NpcQuestRole.OFFER]), + build('ACTIVE', 1, [NpcQuestRole.TURN_IN]), + ]; + case 'step-is-here': + return [build('ACTIVE', 1, [NpcQuestRole.TURN_IN])]; + case 'step-is-elsewhere': + return [build('ACTIVE', 0, [NpcQuestRole.PROGRESS])]; + case 'completed': + return [build('COMPLETED', null, [NpcQuestRole.OFFER])]; + default: + return []; + } } function node(overrides: Partial): Partial { @@ -146,7 +229,10 @@ describe('NpcService', () => { it('lists the people at a location with their markers', async () => { const world = createWorld(); - const npcs = await world.service.getNpcsAtLocation(LOCATION_ID); + const npcs = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); expect(npcs).toHaveLength(1); expect(npcs[0]).toMatchObject({ @@ -156,6 +242,109 @@ describe('NpcService', () => { expect(npcs[0].markers).toEqual(['MERCHANT', 'EXCHANGE']); }); + it('marks an NPC that has a quest to give', async () => { + const world = createWorld({ questSituation: 'offers' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect(borin.markers).toContain('QUEST_AVAILABLE'); + }); + + it('marks the NPC the current step points at', async () => { + const world = createWorld({ questSituation: 'step-is-here' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect(borin.markers).toContain('QUEST_TURN_IN'); + }); + + it('marks an assigned NPC whose step is elsewhere as in progress', async () => { + const world = createWorld({ questSituation: 'step-is-elsewhere' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect(borin.markers).toContain('QUEST_IN_PROGRESS'); + }); + + it('prefers the waiting step over a quest that is merely available', async () => { + // Otherwise the warden mid-chain shows two badges and neither tells the + // player where to go. + const world = createWorld({ questSituation: 'offers-and-receives' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect(borin.markers).toContain('QUEST_TURN_IN'); + expect(borin.markers).not.toContain('QUEST_AVAILABLE'); + }); + + it('emits at most one quest marker', async () => { + const world = createWorld({ questSituation: 'offers-and-receives' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect( + borin.markers.filter((marker) => marker.startsWith('QUEST_')), + ).toHaveLength(1); + }); + + it('emits no quest marker for an NPC with no assignment', async () => { + const world = createWorld(); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect( + borin.markers.some((marker) => marker.startsWith('QUEST_')), + ).toBe(false); + }); + + it('emits no quest marker once the quest is finished', async () => { + const world = createWorld({ questSituation: 'completed' }); + + const [borin] = await world.service.getNpcsAtLocation( + CHARACTER_ID, + LOCATION_ID, + ); + + expect( + borin.markers.some((marker) => marker.startsWith('QUEST_')), + ).toBe(false); + }); + + it('offers a quests action exactly when a quest marker applies', async () => { + const withQuest = await createWorld({ + questSituation: 'offers', + }).service.getInteraction(CHARACTER_ID, 'borin-quartermaster'); + const withoutQuest = await createWorld().service.getInteraction( + CHARACTER_ID, + 'borin-quartermaster', + ); + + expect(withQuest.availableActions.map((action) => action.type)).toContain( + 'VIEW_QUESTS', + ); + expect( + withoutQuest.availableActions.map((action) => action.type), + ).not.toContain('VIEW_QUESTS'); + }); + it('refuses an NPC the character has not travelled to', async () => { // Reachability comes from the character's own location, never the request. const world = createWorld({ characterLocationId: 'somewhere-else' }); diff --git a/apps/api/src/npcs/npc.service.ts b/apps/api/src/npcs/npc.service.ts index 87f322a..83379bd 100644 --- a/apps/api/src/npcs/npc.service.ts +++ b/apps/api/src/npcs/npc.service.ts @@ -4,6 +4,8 @@ import { Character } from '../characters/entities/character.entity'; import { GameConditionService } from '../conditions/game-condition.service'; import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity'; import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.entity'; +import { QuestProgressService } from '../quests/quest-progress.service'; +import { NpcQuestRole, QuestObjectiveType } from '../quests/quest.types'; import { NpcShop } from '../shops/entities/npc-shop.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity'; import { DialogueNode } from './entities/dialogue-node.entity'; @@ -30,10 +32,19 @@ export class NpcService { constructor( private readonly dataSource: DataSource, private readonly conditions: GameConditionService, + private readonly questProgress: QuestProgressService, ) {} - /** Every enabled NPC at a location, for the local view (spec §22, §24). */ - async getNpcsAtLocation(locationId: string): Promise { + /** + * Every enabled NPC at a location, for the local view (spec §22, §24). + * + * Takes the character because markers are per-player: whether the warden has + * something to ask depends on what this character has already done. + */ + async getNpcsAtLocation( + characterId: string, + locationId: string, + ): Promise { const npcs = await this.dataSource.getRepository(NpcDefinition).find({ where: { locationId, enabled: true }, order: { key: 'ASC' }, @@ -47,7 +58,7 @@ export class NpcService { name: npc.name, title: npc.title, portraitPath: npc.portraitPath, - markers: await this.resolveMarkers(npc), + markers: await this.resolveMarkers(characterId, npc), }); } return summaries; @@ -84,7 +95,7 @@ export class NpcService { capabilities: npc.capabilities ?? [], }, dialogue, - availableActions: await this.resolveActions(npc), + availableActions: await this.resolveActions(characterId, npc), }; } @@ -165,7 +176,10 @@ export class NpcService { * declared capability list -- an NPC that claims MERCHANT but has no * enabled shop offers no shop button (spec §5). */ - private async resolveActions(npc: NpcDefinition): Promise { + private async resolveActions( + characterId: string, + npc: NpcDefinition, + ): Promise { const actions: NpcActionDto[] = [ { type: 'TALK', label: 'Talk', key: null }, ]; @@ -186,11 +200,21 @@ export class NpcService { }); } + // Offered on the same rule as the marker: if this person has nothing to + // say about a quest, the screen shows one fewer button rather than an + // empty panel. + if ((await this.resolveQuestMarker(characterId, npc)) !== null) { + actions.push({ type: 'VIEW_QUESTS', label: 'Quests', key: null }); + } + return actions; } /** Markers for the local view. Only backed interactions get one (spec §24). */ - private async resolveMarkers(npc: NpcDefinition): Promise { + private async resolveMarkers( + characterId: string, + npc: NpcDefinition, + ): Promise { const markers: NpcMarker[] = []; const shop = await this.dataSource @@ -204,9 +228,74 @@ export class NpcService { markers.push('EXCHANGE'); } + const quest = await this.resolveQuestMarker(characterId, npc); + if (quest) { + markers.push(quest); + } + return markers; } + /** + * The one quest marker this NPC earns right now, or null (Slice 0.9 §12). + * + * Precedence is load-bearing rather than cosmetic: the South Gate Warden both + * offers this quest and receives it, so without an order they would show two + * badges at once and the player would learn nothing from either. "Your next + * step is here" beats "something starts here" beats "you are on a quest this + * person is part of". + * + * Read through `QuestProgressService` rather than by querying the quest + * tables directly, so "which step am I on" is answered in exactly one place + * -- including the capacity-blocked case, which is the whole reason the + * warden lights up while the player is stuck at 1 / 5. + */ + private async resolveQuestMarker( + characterId: string, + npc: NpcDefinition, + ): Promise { + const entries = await this.questProgress.getNpcQuestStates( + characterId, + npc.id, + ); + if (entries.length === 0) { + return null; + } + + let available = false; + let inProgress = false; + + for (const { state, roles } of entries) { + if (state.status === 'ACTIVE') { + const currentIndex = state.currentIndex; + const step = + currentIndex === null + ? undefined + : state.objectives[currentIndex]?.objective; + + // Matched on the NPC's business key, because that is what a talk step + // names -- content should not have to know generated ids. + if ( + step?.type === QuestObjectiveType.TALK_TO_NPC && + step.targetKey === npc.key + ) { + return 'QUEST_TURN_IN'; + } + inProgress = true; + continue; + } + + if (state.status === 'AVAILABLE' && roles.includes(NpcQuestRole.OFFER)) { + available = true; + } + } + + if (available) { + return 'QUEST_AVAILABLE'; + } + return inProgress ? 'QUEST_IN_PROGRESS' : null; + } + /** * An enabled exchange profile that actually has an enabled rule. * diff --git a/apps/api/src/npcs/npc.types.ts b/apps/api/src/npcs/npc.types.ts index a53ce26..de90a8b 100644 --- a/apps/api/src/npcs/npc.types.ts +++ b/apps/api/src/npcs/npc.types.ts @@ -27,10 +27,13 @@ export enum NpcCapability { /** * Actions a dialogue node may trigger (spec §13). * - * START_QUEST and COMPLETE_QUEST are part of the V1 vocabulary but have no - * quest system behind them yet (Slice 0.9). They are listed so content and - * the stored enum do not need rewriting later; `NpcService` refuses to offer - * an action it cannot actually carry out. + * START_QUEST and COMPLETE_QUEST stay inert even now that quests exist + * (Slice 0.9 decision D4). Carrying them out would need a dialogue-response + * endpoint and an action executor, and Slice 0.9 §10 rules out building a + * branching narrative engine for one tutorial chain. Quest steps run through + * `POST /api/npcs/:npcKey/quests/:questKey/{accept,advance}` instead, and the + * line for a step is content on the objective. They stay listed so the stored + * enum does not need rewriting if that changes. */ export enum DialogueActionType { OPEN_SHOP = 'OPEN_SHOP', @@ -76,13 +79,23 @@ export interface NpcSummaryDto { } /** - * Presentation markers (spec §24). + * Presentation markers (spec §24, Slice 0.9 §12). * * Only markers backed by a real, currently available interaction are emitted. * The view does not turn every capability into a permanent symbol. + * + * The three quest markers answer three different questions, in this order of + * usefulness: `QUEST_TURN_IN` means "your next step is here", `QUEST_AVAILABLE` + * means "something starts here", and `QUEST_IN_PROGRESS` means "this person is + * part of a quest you are on, but not right now". At most one is emitted per + * NPC -- a row of badges on one portrait tells the player nothing. */ export type NpcMarker = - 'MERCHANT' | 'EXCHANGE' | 'QUEST_AVAILABLE' | 'QUEST_TURN_IN'; + | 'MERCHANT' + | 'EXCHANGE' + | 'QUEST_AVAILABLE' + | 'QUEST_IN_PROGRESS' + | 'QUEST_TURN_IN'; export interface DialogueResponseDto { key: string; diff --git a/apps/api/src/npcs/npcs.module.ts b/apps/api/src/npcs/npcs.module.ts index a73bf7e..c5817ff 100644 --- a/apps/api/src/npcs/npcs.module.ts +++ b/apps/api/src/npcs/npcs.module.ts @@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity'; import { ConditionsModule } from '../conditions/conditions.module'; import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity'; import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.entity'; +import { QuestProgressModule } from '../quests/quest-progress.module'; import { NpcShop } from '../shops/entities/npc-shop.entity'; import { CharacterNpcState } from './entities/character-npc-state.entity'; import { DialogueNode } from './entities/dialogue-node.entity'; @@ -31,6 +32,9 @@ import { NpcService } from './npc.service'; ExchangeRule, ]), ConditionsModule, + // The read-only half of the quest system, which exists as its own module + // precisely so this import does not close a cycle with `QuestsModule`. + QuestProgressModule, ], controllers: [NpcController], providers: [NpcService], 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-progress.module.ts b/apps/api/src/quests/quest-progress.module.ts new file mode 100644 index 0000000..71525a1 --- /dev/null +++ b/apps/api/src/quests/quest-progress.module.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootBagsModule } from '../loot-bags/loot-bags.module'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { QuestProgressService } from './quest-progress.service'; + +/** + * The read-only half of the quest system, on its own so the graph stays acyclic. + * + * `NpcService` needs quest state to decide which markers an NPC shows, and + * `QuestService` needs `NpcService` to verify the character is standing with + * the NPC they claim to be talking to. If both lived in `QuestsModule`, those + * two would import each other. Splitting the reads out costs one module and + * avoids `forwardRef`, which hides the cycle rather than removing it. + * + * `forFeature` is required even though the service resolves its repositories + * off the DataSource: the runtime config uses `autoLoadEntities`, which only + * registers entities a module declares. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + CharacterItem, + CharacterQuest, + ItemDefinition, + NpcQuestAssignment, + QuestDefinition, + QuestObjective, + ]), + LootBagsModule, + ], + providers: [QuestProgressService], + exports: [QuestProgressService], +}) +export class QuestProgressModule {} diff --git a/apps/api/src/quests/quest-progress.service.spec.ts b/apps/api/src/quests/quest-progress.service.spec.ts new file mode 100644 index 0000000..71531e4 --- /dev/null +++ b/apps/api/src/quests/quest-progress.service.spec.ts @@ -0,0 +1,386 @@ +import { DataSource } from 'typeorm'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootCategory } from '../items/loot-category.enum'; +import { + LootCapacityDto, + LootCapacityService, +} from '../loot-bags/loot-capacity.service'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { QuestProgressService } from './quest-progress.service'; +import { + CharacterQuestStatus, + NpcQuestRole, + QuestObjectiveType, +} from './quest.types'; + +const CHARACTER_ID = 'character-1'; +const QUEST_ID = 'quest-1'; +const WARDEN_ID = 'npc-warden'; +const BORIN_ID = 'npc-borin'; + +interface Fixture { + questEnabled?: boolean; + /** Owned quantity of `ash-pelt`. */ + pelts?: number; + /** Owned quantity of `bandit-hood`, an item with no loot category. */ + hoods?: number; + hideCarried?: number; + hideCapacity?: number; + row?: Partial | null; + objectiveOverrides?: Array>; + disabledObjectiveKeys?: string[]; + assignments?: Array<{ npcId: string; role: NpcQuestRole; enabled?: boolean }>; +} + +function objective(over: Partial): QuestObjective { + return { + id: `objective-${over.orderIndex ?? 0}`, + questId: QUEST_ID, + key: `step-${over.orderIndex ?? 0}`, + orderIndex: 0, + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + requiredQuantity: 5, + description: 'Collect Ashen Pelts', + npcLine: null, + hintText: null, + advanceWhenBlocked: false, + consumeOnComplete: false, + grantsLootBagKey: null, + setsFlagKey: null, + setsFlagNpcKey: null, + enabled: true, + ...over, + } as QuestObjective; +} + +/** The real five-step shape of Trouble Beyond the Gate. */ +function chainObjectives(): QuestObjective[] { + return [ + objective({ orderIndex: 0, key: 'collect-first', advanceWhenBlocked: true }), + objective({ + orderIndex: 1, + key: 'report', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: 'south-gate-warden', + requiredQuantity: 1, + }), + objective({ + orderIndex: 2, + key: 'bag', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: 'borin-quartermaster', + requiredQuantity: 1, + }), + objective({ orderIndex: 3, key: 'collect', consumeOnComplete: true }), + objective({ + orderIndex: 4, + key: 'turn-in', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: 'south-gate-warden', + requiredQuantity: 1, + }), + ]; +} + +function createService(fixture: Fixture = {}) { + const quest = { + id: QUEST_ID, + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'Five pelts.', + rewardFactionKey: 'border-guard', + rewardReputation: 10, + rewardSilver: 0, + enabled: fixture.questEnabled ?? true, + } as QuestDefinition; + + const objectives = (fixture.objectiveOverrides + ? fixture.objectiveOverrides.map((over) => objective(over)) + : chainObjectives() + ).filter( + (candidate) => !(fixture.disabledObjectiveKeys ?? []).includes(candidate.key), + ); + + const definitions = [ + { + id: 'item-pelt', + key: 'ash-pelt', + lootCategory: LootCategory.HIDE, + }, + { + id: 'item-hood', + key: 'bandit-hood', + // Equipment: outside every loot category, so a full bag cannot block it. + lootCategory: null, + }, + ] as ItemDefinition[]; + + const characterItems = [ + { itemDefinitionId: 'item-pelt', quantity: fixture.pelts ?? 0 }, + { itemDefinitionId: 'item-hood', quantity: fixture.hoods ?? 0 }, + ].filter((item) => item.quantity > 0) as CharacterItem[]; + + const rows = + fixture.row === null || fixture.row === undefined + ? [] + : ([ + { + id: 'character-quest-1', + characterId: CHARACTER_ID, + questId: QUEST_ID, + status: CharacterQuestStatus.ACTIVE, + currentObjectiveIndex: 0, + acceptedAt: new Date(), + completedAt: null, + ...fixture.row, + }, + ] as CharacterQuest[]); + + const assignments = (fixture.assignments ?? []).map((entry, index) => ({ + id: `assignment-${index}`, + npcId: entry.npcId, + questId: QUEST_ID, + role: entry.role, + enabled: entry.enabled ?? true, + })) as NpcQuestAssignment[]; + + const dataSource = { + getRepository: (entity: unknown) => { + if (entity === QuestDefinition) { + return { + find: async ({ where }: { where: { enabled: boolean } }) => + quest.enabled === where.enabled ? [quest] : [], + }; + } + if (entity === QuestObjective) { + return { find: async () => objectives }; + } + if (entity === CharacterQuest) { + return { find: async () => rows }; + } + if (entity === NpcQuestAssignment) { + return { + find: async ({ + where, + }: { + where: { npcId: string; enabled: boolean }; + }) => + assignments.filter( + (assignment) => + assignment.npcId === where.npcId && + assignment.enabled === where.enabled, + ), + }; + } + if (entity === ItemDefinition) { + return { find: async () => definitions }; + } + if (entity === CharacterItem) { + return { find: async () => characterItems }; + } + throw new Error('Unexpected repository'); + }, + } as unknown as DataSource; + + const capacities: LootCapacityDto[] = [ + { + category: LootCategory.HIDE, + current: fixture.hideCarried ?? 0, + capacity: fixture.hideCapacity ?? 1, + bag: null, + }, + ]; + const lootCapacity = { + getCapacities: jest.fn().mockResolvedValue(capacities), + } as unknown as LootCapacityService; + + return { + service: new QuestProgressService(dataSource, lootCapacity), + lootCapacity, + }; +} + +describe('QuestProgressService', () => { + it('reports an unstarted quest as available with no current step', async () => { + const { service } = createService({ row: null }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.status).toBe('AVAILABLE'); + expect(state.currentIndex).toBeNull(); + expect(state.objectives).toHaveLength(5); + expect(state.objectives[0].current).toBe(0); + }); + + it('counts pelts the character already owned before accepting', async () => { + // Slice 0.9 §11: owning quest goods up front must not have to be undone. + const { service } = createService({ + pelts: 2, + hideCarried: 2, + hideCapacity: 5, + row: { currentObjectiveIndex: 0 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.objectives[0].current).toBe(2); + expect(state.objectives[0].required).toBe(5); + expect(state.currentIndex).toBe(0); + }); + + it('marks a collect step blocked once the hide bag is full', async () => { + const { service } = createService({ + pelts: 1, + hideCarried: 1, + hideCapacity: 1, + row: { currentObjectiveIndex: 0 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.objectives[0].blocked).toBe(true); + // advanceWhenBlocked on step 0 hands the player to the warden (§4). + expect(state.currentIndex).toBe(1); + }); + + it('never blocks a step whose item has no loot category', async () => { + const { service } = createService({ + hoods: 1, + hideCarried: 1, + hideCapacity: 1, + objectiveOverrides: [ + { orderIndex: 0, key: 'collect-hoods', targetKey: 'bandit-hood' }, + ], + row: { currentObjectiveIndex: 0 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + // Equipment is unaffected by bag capacity (Slice 0.7.5 §8). + expect(state.objectives[0].blocked).toBe(false); + }); + + it('reads an item the character has never owned as zero', async () => { + const { service } = createService({ row: { currentObjectiveIndex: 0 } }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.objectives[0].current).toBe(0); + expect(state.objectives[0].satisfied).toBe(false); + }); + + it('derives the active step from the stored floor and current items', async () => { + const { service } = createService({ + pelts: 5, + hideCarried: 5, + hideCapacity: 5, + row: { currentObjectiveIndex: 3 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.currentIndex).toBe(4); + expect(state.objectives[3].satisfied).toBe(true); + }); + + it('reports a completed quest without a current step', async () => { + const { service } = createService({ + row: { + status: CharacterQuestStatus.COMPLETED, + currentObjectiveIndex: 5, + completedAt: new Date(), + }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.status).toBe('COMPLETED'); + expect(state.currentIndex).toBeNull(); + }); + + it('ignores a disabled quest entirely', async () => { + const { service } = createService({ questEnabled: false }); + + expect(await service.getQuestStates(CHARACTER_ID)).toEqual([]); + }); + + it('ignores a disabled objective', async () => { + const { service } = createService({ + disabledObjectiveKeys: ['bag'], + row: { currentObjectiveIndex: 0 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.objectives).toHaveLength(4); + expect(state.objectives.map((entry) => entry.objective.key)).not.toContain( + 'bag', + ); + }); + + it('measures every collect step against one capacity read', async () => { + const { service, lootCapacity } = createService({ + row: { currentObjectiveIndex: 0 }, + }); + + await service.getQuestStates(CHARACTER_ID); + + // Two collect steps in this chain; both are measured against the same + // carrying state, so re-reading it per step would be pure waste. + expect(lootCapacity.getCapacities).toHaveBeenCalledTimes(1); + }); + + it('finds one quest by key', async () => { + const { service } = createService({ row: null }); + + expect( + await service.getQuestState(CHARACTER_ID, 'trouble-beyond-the-gate'), + ).not.toBeNull(); + expect(await service.getQuestState(CHARACTER_ID, 'no-such-quest')).toBeNull(); + }); + + it('reports every role an NPC holds for a quest', async () => { + const { service } = createService({ + row: null, + assignments: [ + { npcId: WARDEN_ID, role: NpcQuestRole.OFFER }, + { npcId: WARDEN_ID, role: NpcQuestRole.TURN_IN }, + { npcId: BORIN_ID, role: NpcQuestRole.PROGRESS }, + ], + }); + + const warden = await service.getNpcQuestStates(CHARACTER_ID, WARDEN_ID); + + // One person, two roles (NPC spec §14). + expect(warden).toHaveLength(1); + expect(warden[0].roles).toEqual([ + NpcQuestRole.OFFER, + NpcQuestRole.TURN_IN, + ]); + }); + + it('reports nothing for an NPC with no assignment', async () => { + const { service } = createService({ + row: null, + assignments: [{ npcId: WARDEN_ID, role: NpcQuestRole.OFFER }], + }); + + expect(await service.getNpcQuestStates(CHARACTER_ID, BORIN_ID)).toEqual([]); + }); + + it('ignores a disabled assignment', async () => { + const { service } = createService({ + row: null, + assignments: [ + { npcId: WARDEN_ID, role: NpcQuestRole.OFFER, enabled: false }, + ], + }); + + expect(await service.getNpcQuestStates(CHARACTER_ID, WARDEN_ID)).toEqual([]); + }); +}); diff --git a/apps/api/src/quests/quest-progress.service.ts b/apps/api/src/quests/quest-progress.service.ts new file mode 100644 index 0000000..d28faf8 --- /dev/null +++ b/apps/api/src/quests/quest-progress.service.ts @@ -0,0 +1,267 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootCategory } from '../items/loot-category.enum'; +import { LootCapacityService } from '../loot-bags/loot-capacity.service'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { + isObjectiveSatisfied, + ObjectiveSnapshot, + resolveCurrentObjectiveIndex, +} from './quest-state'; +import { + CharacterQuestStatus, + NpcQuestRole, + QuestObjectiveType, + QuestStatus, +} from './quest.types'; + +// Both DataSource and EntityManager expose this; naming it keeps the read path +// usable inside and outside a transaction without a union type. +type RepositoryScope = Pick; + +export interface QuestObjectiveState { + objective: QuestObjective; + current: number; + required: number; + /** True when the character cannot carry more of this step's target. */ + blocked: boolean; + satisfied: boolean; +} + +export interface QuestState { + quest: QuestDefinition; + objectives: QuestObjectiveState[]; + row: CharacterQuest | null; + status: QuestStatus; + /** + * Index into `objectives`, or null unless the quest is active. + * `objectives.length` means every step is behind the player. + */ + currentIndex: number | null; +} + +export interface NpcQuestState { + state: QuestState; + roles: NpcQuestRole[]; +} + +/** + * Reads where a character stands with every quest (Slice 0.9 §10, §11). + * + * Read-only on purpose, and deliberately unaware of `NpcService`: `NpcService` + * needs this to work out quest markers, and `QuestService` needs `NpcService` + * to check that the player is standing with the NPC they claim to be talking + * to. Splitting the read half out is what keeps that dependency acyclic -- + * see `QuestProgressModule`. + * + * Nothing here writes. A read that quietly advanced a stored index would make + * "look at your quest log" a state-changing operation, and two clients opening + * the same screen would race. + */ +@Injectable() +export class QuestProgressService { + constructor( + private readonly dataSource: DataSource, + private readonly lootCapacity: LootCapacityService, + ) {} + + /** Every enabled quest, with this character's standing on it. */ + async getQuestStates( + characterId: string, + scope?: RepositoryScope, + ): Promise { + const db = scope ?? this.dataSource; + + const quests = await db + .getRepository(QuestDefinition) + .find({ where: { enabled: true }, order: { key: 'ASC' } }); + if (quests.length === 0) { + return []; + } + + const [objectives, rows, capacities] = await Promise.all([ + db + .getRepository(QuestObjective) + .find({ where: { enabled: true }, order: { orderIndex: 'ASC' } }), + db.getRepository(CharacterQuest).find({ where: { characterId } }), + // Read once for the whole call: every collect step of every quest is + // measured against the same carrying state, and this is the expensive + // read of the three. + this.lootCapacity.getCapacities(characterId, db), + ]); + + const owned = await this.loadOwnedQuantities(characterId, db); + const capacityFull = new Map( + capacities.map((entry) => [ + entry.category, + entry.current >= entry.capacity, + ]), + ); + const rowByQuest = new Map(rows.map((row) => [row.questId, row])); + + return quests.map((quest) => + this.buildState( + quest, + objectives.filter((objective) => objective.questId === quest.id), + rowByQuest.get(quest.id) ?? null, + owned, + capacityFull, + ), + ); + } + + /** One quest by key, or null when no enabled quest carries that key. */ + async getQuestState( + characterId: string, + questKey: string, + scope?: RepositoryScope, + ): Promise { + const states = await this.getQuestStates(characterId, scope); + return states.find((state) => state.quest.key === questKey) ?? null; + } + + /** + * The quests one NPC is involved in, with the roles they hold. + * + * An NPC may hold several roles for the same quest -- the warden both offers + * and receives this one (NPC spec §14) -- so the roles come back as a list + * rather than a single value. + */ + async getNpcQuestStates( + characterId: string, + npcId: string, + scope?: RepositoryScope, + ): Promise { + const db = scope ?? this.dataSource; + + const assignments = await db + .getRepository(NpcQuestAssignment) + .find({ where: { npcId, enabled: true } }); + if (assignments.length === 0) { + return []; + } + + const rolesByQuest = new Map(); + for (const assignment of assignments) { + const roles = rolesByQuest.get(assignment.questId) ?? []; + roles.push(assignment.role); + rolesByQuest.set(assignment.questId, roles); + } + + const states = await this.getQuestStates(characterId, db); + return states + .filter((state) => rolesByQuest.has(state.quest.id)) + .map((state) => ({ + state, + roles: rolesByQuest.get(state.quest.id) as NpcQuestRole[], + })); + } + + private buildState( + quest: QuestDefinition, + objectives: QuestObjective[], + row: CharacterQuest | null, + owned: Map, + capacityFull: Map, + ): QuestState { + const snapshots: ObjectiveSnapshot[] = objectives.map((objective) => + this.toSnapshot(objective, owned, capacityFull), + ); + + const objectiveStates: QuestObjectiveState[] = objectives.map( + (objective, index) => ({ + objective, + current: snapshots[index].current, + required: objective.requiredQuantity, + blocked: snapshots[index].blocked, + satisfied: isObjectiveSatisfied(snapshots[index]), + }), + ); + + const status: QuestStatus = + row === null + ? 'AVAILABLE' + : row.status === CharacterQuestStatus.COMPLETED + ? 'COMPLETED' + : 'ACTIVE'; + + return { + quest, + objectives: objectiveStates, + row, + status, + currentIndex: + status === 'ACTIVE' && row !== null + ? resolveCurrentObjectiveIndex(snapshots, row.currentObjectiveIndex) + : null, + }; + } + + private toSnapshot( + objective: QuestObjective, + owned: Map, + capacityFull: Map, + ): ObjectiveSnapshot { + if (objective.type !== QuestObjectiveType.COLLECT_ITEM) { + return { + type: objective.type, + requiredQuantity: objective.requiredQuantity, + advanceWhenBlocked: objective.advanceWhenBlocked, + current: 0, + blocked: false, + }; + } + + const target = owned.get(objective.targetKey); + const category = target?.lootCategory ?? null; + + return { + type: objective.type, + requiredQuantity: objective.requiredQuantity, + advanceWhenBlocked: objective.advanceWhenBlocked, + current: target?.quantity ?? 0, + // Items outside every loot category are not trade goods and are never + // limited by a bag (Slice 0.7.5 §8), so they can never block a step. + blocked: category === null ? false : (capacityFull.get(category) ?? false), + }; + } + + /** + * Owned quantity and loot category per item key. + * + * Keyed by the item's business key because that is what an objective names; + * an item the character has never owned is simply absent and reads as zero. + */ + private async loadOwnedQuantities( + characterId: string, + db: RepositoryScope, + ): Promise< + Map + > { + const [definitions, items] = await Promise.all([ + db.getRepository(ItemDefinition).find(), + db + .getRepository(CharacterItem) + .find({ where: { characterId }, relations: { itemDefinition: true } }), + ]); + + const quantityByDefinition = new Map( + items.map((item) => [item.itemDefinitionId, item.quantity]), + ); + + return new Map( + definitions.map((definition) => [ + definition.key, + { + quantity: quantityByDefinition.get(definition.id) ?? 0, + lootCategory: definition.lootCategory, + }, + ]), + ); + } +} diff --git a/apps/api/src/quests/quest-state.spec.ts b/apps/api/src/quests/quest-state.spec.ts new file mode 100644 index 0000000..de8dabc --- /dev/null +++ b/apps/api/src/quests/quest-state.spec.ts @@ -0,0 +1,194 @@ +import { QuestObjectiveType } from './quest.types'; +import { + isObjectiveSatisfied, + ObjectiveSnapshot, + resolveCurrentObjectiveIndex, +} from './quest-state'; + +function collect(over: Partial = {}): ObjectiveSnapshot { + return { + type: QuestObjectiveType.COLLECT_ITEM, + requiredQuantity: 5, + advanceWhenBlocked: false, + current: 0, + blocked: false, + ...over, + }; +} + +function talk(over: Partial = {}): ObjectiveSnapshot { + return { + type: QuestObjectiveType.TALK_TO_NPC, + requiredQuantity: 1, + advanceWhenBlocked: false, + current: 0, + blocked: false, + ...over, + }; +} + +/** The real shape of Trouble Beyond the Gate (Slice 0.9 §3–§8). */ +function troubleBeyondTheGate( + peltsCarried: number, + hideCapacityReached: boolean, +): ObjectiveSnapshot[] { + return [ + collect({ + current: peltsCarried, + blocked: hideCapacityReached, + advanceWhenBlocked: true, + }), + talk(), + talk(), + collect({ current: peltsCarried, blocked: hideCapacityReached }), + talk(), + ]; +} + +describe('isObjectiveSatisfied', () => { + it('needs the full required quantity for a collect step', () => { + expect(isObjectiveSatisfied(collect({ current: 4 }))).toBe(false); + expect(isObjectiveSatisfied(collect({ current: 5 }))).toBe(true); + expect(isObjectiveSatisfied(collect({ current: 6 }))).toBe(true); + }); + + it('gives up on a blocked collect step only when content allows it', () => { + // The capacity lesson (slice §4): the first pelt hunt is meant to fail at + // 1 / 5, the second one is not. + expect( + isObjectiveSatisfied( + collect({ current: 1, blocked: true, advanceWhenBlocked: true }), + ), + ).toBe(true); + expect( + isObjectiveSatisfied( + collect({ current: 1, blocked: true, advanceWhenBlocked: false }), + ), + ).toBe(false); + }); + + it('never satisfies a talk step on its own', () => { + // A talk step is performed, not observed -- nothing about the character's + // inventory can complete it. + expect(isObjectiveSatisfied(talk())).toBe(false); + expect(isObjectiveSatisfied(talk({ current: 99, blocked: true }))).toBe( + false, + ); + }); +}); + +describe('resolveCurrentObjectiveIndex', () => { + it('stays on an unmet collect step', () => { + expect(resolveCurrentObjectiveIndex([collect(), talk()], 0)).toBe(0); + }); + + it('walks past a collect step whose items are already owned', () => { + // Spec §11: a player who owned pelts before accepting does not have to + // throw them away and start again. + expect( + resolveCurrentObjectiveIndex([collect({ current: 5 }), talk()], 0), + ).toBe(1); + }); + + it('walks past a blocked collect step when content says to', () => { + const objectives = [ + collect({ current: 1, blocked: true, advanceWhenBlocked: true }), + talk(), + ]; + + expect(resolveCurrentObjectiveIndex(objectives, 0)).toBe(1); + }); + + it('holds a blocked collect step when content does not', () => { + const objectives = [ + collect({ current: 1, blocked: true, advanceWhenBlocked: false }), + talk(), + ]; + + expect(resolveCurrentObjectiveIndex(objectives, 0)).toBe(0); + }); + + it('never walks past a talk step', () => { + expect(resolveCurrentObjectiveIndex([talk(), talk()], 0)).toBe(0); + }); + + it('never walks behind the stored floor', () => { + // Talk steps are irreversible: once the warden has sent you to Borin, an + // empty bag does not un-send you. + expect( + resolveCurrentObjectiveIndex([collect(), talk(), collect()], 2), + ).toBe(2); + }); + + it('walks several satisfied steps in one go', () => { + const objectives = [ + collect({ current: 5 }), + collect({ current: 5 }), + talk(), + ]; + + expect(resolveCurrentObjectiveIndex(objectives, 0)).toBe(2); + }); + + it('reports the end of the list when nothing is left', () => { + expect(resolveCurrentObjectiveIndex([collect({ current: 5 })], 0)).toBe(1); + }); + + it('clamps a stored index past the end of the list', () => { + // Content shortened between deploys must read as "done", never crash. + expect(resolveCurrentObjectiveIndex([collect(), talk()], 9)).toBe(2); + }); + + it('clamps a negative stored index', () => { + expect(resolveCurrentObjectiveIndex([collect(), talk()], -1)).toBe(0); + }); + + it('treats a quest with no objectives as finished', () => { + expect(resolveCurrentObjectiveIndex([], 0)).toBe(0); + }); + + it('walks the whole first-quest chain state by state', () => { + // Fresh, no pelts, no bag: the hunt is the step. + expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(0, false), 0)).toBe( + 0, + ); + + // One pelt and the bagless HIDE limit reached (slice §4): the game sends + // the player back to the warden instead of leaving them at 1 / 5. + expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(1, true), 0)).toBe( + 1, + ); + + // Warden talked to; the floor moved to 2 and Borin is the step. + expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(1, false), 2)).toBe( + 2, + ); + + // Bag in hand: back to the hunt, and the pelt already carried still counts + // (slice §7) -- four more, not five. + expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(1, false), 3)).toBe( + 3, + ); + + // Five pelts: the turn-in opens. + expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(5, false), 3)).toBe( + 4, + ); + + // Turned in; the floor is past the last step. + expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(0, false), 5)).toBe( + 5, + ); + }); + + it('falls back to the hunt when the player sold the pelts again', () => { + // Spec §11: trading quest goods away mid-quest must not softlock. Progress + // is read from what is owned now, so the objective simply reopens. + const objectives = troubleBeyondTheGate(5, false); + expect(resolveCurrentObjectiveIndex(objectives, 3)).toBe(4); + + expect(resolveCurrentObjectiveIndex(troubleBeyondTheGate(0, false), 3)).toBe( + 3, + ); + }); +}); diff --git a/apps/api/src/quests/quest-state.ts b/apps/api/src/quests/quest-state.ts new file mode 100644 index 0000000..1626099 --- /dev/null +++ b/apps/api/src/quests/quest-state.ts @@ -0,0 +1,72 @@ +import { QuestObjectiveType } from './quest.types'; + +/** + * One objective reduced to what deciding "are we past this yet" needs. + * + * Deliberately not the entity: keeping this a plain value is what lets every + * softlock case in Slice 0.9 §11 be a table-driven unit test with no database + * anywhere near it. + */ +export interface ObjectiveSnapshot { + type: QuestObjectiveType; + requiredQuantity: number; + advanceWhenBlocked: boolean; + /** Owned quantity for a COLLECT_ITEM step; ignored for a TALK_TO_NPC step. */ + current: number; + /** True when the character cannot carry more of this step's target. */ + blocked: boolean; +} + +/** + * Whether this step needs anything further from the player. + * + * A talk step is never satisfied by observation -- it is *performed*, and the + * quest endpoint is the only thing that can move past it. A collect step is + * satisfied by owning enough, or, where content asks for it, by having run into + * the carrying limit: that is the whole capacity lesson of Slice 0.9 §4, and it + * is what turns "Ashen Pelts 1 / 5, hide capacity reached" from a dead end into + * the next objective. + */ +export function isObjectiveSatisfied(objective: ObjectiveSnapshot): boolean { + if (objective.type !== QuestObjectiveType.COLLECT_ITEM) { + return false; + } + + if (objective.current >= objective.requiredQuantity) { + return true; + } + + return objective.advanceWhenBlocked && objective.blocked; +} + +/** + * The step the player is actually on. + * + * Walks forward from the persisted floor past every collect step that is + * already satisfied. Talk steps stop the walk, which is what makes the floor + * meaningful: it only ever moves when one of them is performed, and those are + * irreversible. + * + * Everything else is re-derived from what the character owns right now + * (spec §11). That is not a shortcut -- it is what makes the §11 cases fall out + * instead of being special-cased: pelts owned before accepting already count, + * pelts sold mid-quest reopen the objective, and a bag obtained some other way + * simply lets the first hunt finish on its own. + * + * Returns `objectives.length` when every step is behind the player. + */ +export function resolveCurrentObjectiveIndex( + objectives: ObjectiveSnapshot[], + storedIndex: number, +): number { + // A stored index outside the list means the content changed underneath a + // character. Clamping beats throwing: a shortened quest reads as finished, + // and a nonsensical negative reads as "start at the beginning". + let index = Math.min(Math.max(storedIndex, 0), objectives.length); + + while (index < objectives.length && isObjectiveSatisfied(objectives[index])) { + index += 1; + } + + return index; +} diff --git a/apps/api/src/quests/quest.controller.spec.ts b/apps/api/src/quests/quest.controller.spec.ts new file mode 100644 index 0000000..442573c --- /dev/null +++ b/apps/api/src/quests/quest.controller.spec.ts @@ -0,0 +1,135 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { configureApplication } from '../app.config'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { NpcQuestController, QuestController } from './quest.controller'; +import { QuestService } from './quest.service'; +import { QuestObjectiveType } from './quest.types'; + +const QUEST = { + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'Five pelts.', + status: 'ACTIVE' as const, + objectives: [ + { + key: 'collect-pelts-first', + description: 'Collect Ashen Pelts', + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + required: 5, + current: 1, + completed: false, + }, + ], + currentObjectiveKey: 'collect-pelts-first', + hint: null, +}; + +describe('quest controllers', () => { + let app: INestApplication; + const getQuestLog = jest.fn(); + const acceptQuest = jest.fn(); + const advanceQuest = jest.fn(); + + beforeEach(async () => { + getQuestLog.mockReset(); + acceptQuest.mockReset(); + advanceQuest.mockReset(); + + const module = await Test.createTestingModule({ + controllers: [QuestController, NpcQuestController], + providers: [ + { + provide: QuestService, + useValue: { getQuestLog, acceptQuest, advanceQuest }, + }, + ], + }).compile(); + + app = module.createNestApplication(); + configureApplication(app); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('returns the quest log for the session character', async () => { + getQuestLog.mockResolvedValue([QUEST]); + + const response = await request(app.getHttpServer()) + .get('/api/quests') + .expect(200); + + expect(response.body).toEqual([QUEST]); + expect(getQuestLog).toHaveBeenCalledWith(DEMO_CHARACTER_ID); + }); + + it('accepts a quest through the NPC that offers it', async () => { + acceptQuest.mockResolvedValue({ + quest: QUEST, + npcLine: null, + grantedBag: null, + consumedItems: [], + rewards: null, + }); + + await request(app.getHttpServer()) + .post('/api/npcs/south-gate-warden/quests/trouble-beyond-the-gate/accept') + .expect(201); + + expect(acceptQuest).toHaveBeenCalledWith( + DEMO_CHARACTER_ID, + 'south-gate-warden', + 'trouble-beyond-the-gate', + ); + }); + + it('advances a quest step at an NPC', async () => { + advanceQuest.mockResolvedValue({ + quest: QUEST, + npcLine: 'Go see Borin in Graufurt.', + grantedBag: null, + consumedItems: [], + rewards: null, + }); + + const response = await request(app.getHttpServer()) + .post('/api/npcs/south-gate-warden/quests/trouble-beyond-the-gate/advance') + .expect(201); + + expect(response.body.npcLine).toBe('Go see Borin in Graufurt.'); + expect(advanceQuest).toHaveBeenCalledWith( + DEMO_CHARACTER_ID, + 'south-gate-warden', + 'trouble-beyond-the-gate', + ); + }); + + it('never lets the request name the character', async () => { + // The body is ignored entirely: which step is current, what it grants and + // what it consumes are the server's to decide (AGENTS.md §5). + advanceQuest.mockResolvedValue({ + quest: QUEST, + npcLine: null, + grantedBag: null, + consumedItems: [], + rewards: null, + }); + + await request(app.getHttpServer()) + .post('/api/npcs/south-gate-warden/quests/trouble-beyond-the-gate/advance') + .send({ characterId: 'somebody-else', objectiveKey: 'turn-in' }) + .expect(201); + + expect(advanceQuest).toHaveBeenCalledWith( + DEMO_CHARACTER_ID, + 'south-gate-warden', + 'trouble-beyond-the-gate', + ); + }); +}); diff --git a/apps/api/src/quests/quest.controller.ts b/apps/api/src/quests/quest.controller.ts new file mode 100644 index 0000000..8fdb2c2 --- /dev/null +++ b/apps/api/src/quests/quest.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Param, Post } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { QuestService } from './quest.service'; +import { QuestDto, QuestInteractionResultDto } from './quest.types'; + +/** + * The character's quest log (Playable Slice 0.9 §12). + * + * The character comes from the session stand-in, never from the request, so a + * caller cannot read somebody else's quests. + */ +@Controller('quests') +export class QuestController { + constructor(private readonly questService: QuestService) {} + + @Get() + getQuestLog(): Promise { + return this.questService.getQuestLog(DEMO_CHARACTER_ID); + } +} + +/** + * The two things a player can do to a quest at an NPC (spec §3, §5, §6, §8). + * + * Neither route takes a body. Which step is current, what it grants and what it + * consumes are all the server's to decide (AGENTS.md §5) -- the request only + * names who is being spoken to and about what. + * + * Deliberately not dialogue actions (decision D4): carrying `START_QUEST` out + * through the dialogue tree would need a response-selection endpoint and an + * action executor, which is the branching narrative engine §10 rules out. + */ +@Controller('npcs/:npcKey/quests/:questKey') +export class NpcQuestController { + constructor(private readonly questService: QuestService) {} + + @Post('accept') + acceptQuest( + @Param('npcKey') npcKey: string, + @Param('questKey') questKey: string, + ): Promise { + return this.questService.acceptQuest(DEMO_CHARACTER_ID, npcKey, questKey); + } + + @Post('advance') + advanceQuest( + @Param('npcKey') npcKey: string, + @Param('questKey') questKey: string, + ): Promise { + return this.questService.advanceQuest(DEMO_CHARACTER_ID, npcKey, questKey); + } +} diff --git a/apps/api/src/quests/quest.errors.ts b/apps/api/src/quests/quest.errors.ts new file mode 100644 index 0000000..081c38f --- /dev/null +++ b/apps/api/src/quests/quest.errors.ts @@ -0,0 +1,81 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export type QuestErrorCode = + | 'QUEST_NOT_FOUND' + | 'QUEST_NOT_OFFERED_HERE' + | 'QUEST_ALREADY_ACCEPTED' + | 'QUEST_NOT_ACTIVE' + | 'QUEST_STEP_NOT_HERE' + | 'QUEST_OBJECTIVE_INCOMPLETE'; + +export class QuestDomainError extends HttpException { + constructor( + public readonly code: QuestErrorCode, + status: HttpStatus, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +export function questNotFound(): QuestDomainError { + return new QuestDomainError( + 'QUEST_NOT_FOUND', + HttpStatus.NOT_FOUND, + 'This quest could not be found.', + ); +} + +export function questNotOfferedHere(): QuestDomainError { + return new QuestDomainError( + 'QUEST_NOT_OFFERED_HERE', + HttpStatus.CONFLICT, + 'This person has nothing to ask of you.', + ); +} + +/** + * Raised when the quest is already on the character's list. + * + * Also what a lost race resolves to: the unique index on + * (character_id, quest_id) is the real guarantee, and this turns the + * constraint violation into something the client can explain (AGENTS.md §30). + */ +export function questAlreadyAccepted(): QuestDomainError { + return new QuestDomainError( + 'QUEST_ALREADY_ACCEPTED', + HttpStatus.CONFLICT, + 'You have already taken this on.', + ); +} + +export function questNotActive(): QuestDomainError { + return new QuestDomainError( + 'QUEST_NOT_ACTIVE', + HttpStatus.CONFLICT, + 'You are not on this quest.', + ); +} + +/** + * Raised when this NPC is not what the quest currently needs. + * + * Covers both "you are talking to the wrong person" and "you came back too + * early": the current step is derived, so a turn-in attempt with four pelts + * simply is not the step the character is on. + */ +export function questStepNotHere(): QuestDomainError { + return new QuestDomainError( + 'QUEST_STEP_NOT_HERE', + HttpStatus.CONFLICT, + 'This is not what the quest needs from you right now.', + ); +} + +export function questObjectiveIncomplete(): QuestDomainError { + return new QuestDomainError( + 'QUEST_OBJECTIVE_INCOMPLETE', + HttpStatus.CONFLICT, + 'You do not have what this step needs yet.', + ); +} diff --git a/apps/api/src/quests/quest.service.spec.ts b/apps/api/src/quests/quest.service.spec.ts new file mode 100644 index 0000000..c59a4ed --- /dev/null +++ b/apps/api/src/quests/quest.service.spec.ts @@ -0,0 +1,867 @@ +import { DataSource, EntityManager } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootCategory } from '../items/loot-category.enum'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity'; +import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity'; +import { NpcDefinition } from '../npcs/entities/npc-definition.entity'; +import { NpcService } from '../npcs/npc.service'; +import { ReputationService } from '../reputation/reputation.service'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { + QuestObjectiveState, + QuestProgressService, + QuestState, +} from './quest-progress.service'; +import { QuestService } from './quest.service'; +import { + CharacterQuestStatus, + NpcQuestRole, + QuestObjectiveType, + QuestStatus, +} from './quest.types'; + +const CHARACTER_ID = 'character-1'; +const QUEST_ID = 'quest-1'; +const QUEST_KEY = 'trouble-beyond-the-gate'; +const WARDEN_ID = 'npc-warden'; +const WARDEN_KEY = 'south-gate-warden'; +const BORIN_ID = 'npc-borin'; +const BORIN_KEY = 'borin-quartermaster'; +const REFERRAL_FLAG = 'referred-by-south-gate-warden'; +const PELT_DEFINITION_ID = 'item-pelt'; +const HIDE_BAG_DEFINITION_ID = 'bag-hide'; + +function objective(over: Partial): QuestObjective { + return { + id: `objective-${over.orderIndex ?? 0}`, + questId: QUEST_ID, + key: `step-${over.orderIndex ?? 0}`, + orderIndex: 0, + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + requiredQuantity: 5, + description: 'Collect Ashen Pelts', + npcLine: null, + hintText: null, + advanceWhenBlocked: false, + consumeOnComplete: false, + grantsLootBagKey: null, + setsFlagKey: null, + setsFlagNpcKey: null, + enabled: true, + ...over, + } as QuestObjective; +} + +/** The real five-step chain (Slice 0.9 §3–§8). */ +function chain(): QuestObjective[] { + return [ + objective({ + orderIndex: 0, + key: 'collect-pelts-first', + advanceWhenBlocked: true, + hintText: 'You cannot carry enough pelts. Return to the South Gate Warden.', + }), + objective({ + orderIndex: 1, + key: 'report-capacity', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: WARDEN_KEY, + requiredQuantity: 1, + description: 'Return to the South Gate Warden', + npcLine: 'Go see Borin in Graufurt.', + setsFlagKey: REFERRAL_FLAG, + setsFlagNpcKey: BORIN_KEY, + }), + objective({ + orderIndex: 2, + key: 'collect-bag', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: BORIN_KEY, + requiredQuantity: 1, + description: 'Speak with Borin in Graufurt', + npcLine: 'Take this.', + grantsLootBagKey: 'basic-hide-bag', + }), + objective({ + orderIndex: 3, + key: 'collect-pelts', + consumeOnComplete: true, + }), + objective({ + orderIndex: 4, + key: 'turn-in', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: WARDEN_KEY, + requiredQuantity: 1, + description: 'Bring the pelts to the South Gate Warden', + npcLine: "Good. That's enough for me.", + }), + ]; +} + +interface Fixture { + /** Which NPC the character is standing with; null means unreachable. */ + standingWith?: 'warden' | 'borin' | null; + questEnabled?: boolean; + hasOfferAssignment?: boolean; + /** Undefined means the quest was never accepted. */ + status?: CharacterQuestStatus; + storedIndex?: number; + /** The step the derivation says the character is on. */ + currentIndex?: number | null; + pelts?: number; + blockedIndexes?: number[]; + ownsHideBag?: boolean; + borinFlags?: Record; + silver?: number; + rewardReputation?: number; + rewardSilver?: number; + /** Makes the insert fail the way a lost unique-index race would. */ + insertRace?: boolean; +} + +function createWorld(fixture: Fixture = {}) { + const quest = { + id: QUEST_ID, + key: QUEST_KEY, + title: 'Trouble Beyond the Gate', + description: 'Five pelts.', + rewardFactionKey: 'border-guard', + rewardReputation: fixture.rewardReputation ?? 10, + rewardSilver: fixture.rewardSilver ?? 0, + enabled: fixture.questEnabled ?? true, + } as QuestDefinition; + + const objectives = chain(); + + const npcs: Record = { + warden: { id: WARDEN_ID, key: WARDEN_KEY } as NpcDefinition, + borin: { id: BORIN_ID, key: BORIN_KEY } as NpcDefinition, + }; + + const questRow: CharacterQuest | null = + fixture.status === undefined + ? null + : ({ + id: 'character-quest-1', + characterId: CHARACTER_ID, + questId: QUEST_ID, + status: fixture.status, + currentObjectiveIndex: fixture.storedIndex ?? 0, + acceptedAt: new Date('2026-01-01T00:00:00Z'), + completedAt: null, + } as CharacterQuest); + + const character = { + id: CHARACTER_ID, + silver: fixture.silver ?? 0, + } as Character; + + const peltStack = + (fixture.pelts ?? 0) > 0 + ? ({ + id: 'character-item-pelt', + characterId: CHARACTER_ID, + itemDefinitionId: PELT_DEFINITION_ID, + quantity: fixture.pelts as number, + } as CharacterItem) + : null; + + const state = { + characterItems: peltStack ? [peltStack] : ([] as CharacterItem[]), + lootBags: fixture.ownsHideBag + ? [ + { + characterId: CHARACTER_ID, + lootBagDefinitionId: HIDE_BAG_DEFINITION_ID, + active: true, + }, + ] + : ([] as Array>), + npcStates: fixture.borinFlags + ? [ + { + characterId: CHARACTER_ID, + npcId: BORIN_ID, + flags: { ...fixture.borinFlags }, + }, + ] + : ([] as Array>), + questRows: questRow ? [questRow] : ([] as CharacterQuest[]), + savedQuestRows: [] as CharacterQuest[], + savedNpcStates: [] as Array>, + savedLootBags: [] as Array>, + removedItems: [] as CharacterItem[], + savedCharacters: [] as Character[], + }; + + const manager = { + getRepository: (entity: unknown) => { + if (entity === QuestDefinition) { + return { + findOneBy: async (criteria: { key: string; enabled: boolean }) => + quest.key === criteria.key && quest.enabled === criteria.enabled + ? quest + : null, + }; + } + if (entity === NpcQuestAssignment) { + return { + findOneBy: async (criteria: { npcId: string; role: NpcQuestRole }) => + (fixture.hasOfferAssignment ?? true) && + criteria.npcId === WARDEN_ID && + criteria.role === NpcQuestRole.OFFER + ? { id: 'assignment-1' } + : null, + }; + } + if (entity === CharacterQuest) { + return { + findOne: async () => state.questRows[0] ?? null, + create: (value: Record) => value, + save: async (value: CharacterQuest) => { + state.savedQuestRows.push(value); + if (fixture.insertRace && state.questRows.length === 0) { + // What Postgres raises when the unique index refuses a second + // row for the same (character, quest). + throw Object.assign(new Error('duplicate key'), { + driverError: { code: '23505' }, + }); + } + return value; + }, + }; + } + if (entity === NpcDefinition) { + return { + findOneBy: async (criteria: { key: string }) => + Object.values(npcs).find((npc) => npc.key === criteria.key) ?? null, + }; + } + if (entity === CharacterNpcState) { + return { + findOneBy: async (criteria: { npcId: string }) => + state.npcStates.find((row) => row.npcId === criteria.npcId) ?? null, + create: (value: Record) => value, + save: async (value: Record) => { + state.savedNpcStates.push(value); + return value; + }, + }; + } + if (entity === LootBagDefinition) { + return { + findOneBy: async (criteria: { key: string }) => + criteria.key === 'basic-hide-bag' + ? { + id: HIDE_BAG_DEFINITION_ID, + key: 'basic-hide-bag', + name: 'Basic Hide Bag', + lootCategory: LootCategory.HIDE, + capacity: 5, + } + : null, + }; + } + if (entity === CharacterLootBag) { + return { + findOne: async () => state.lootBags[0] ?? null, + create: (value: Record) => value, + save: async (value: Record) => { + state.savedLootBags.push(value); + state.lootBags.push(value); + return value; + }, + }; + } + if (entity === ItemDefinition) { + return { + findOneBy: async (criteria: { key: string }) => + criteria.key === 'ash-pelt' + ? { id: PELT_DEFINITION_ID, key: 'ash-pelt' } + : null, + }; + } + if (entity === CharacterItem) { + return { + findOne: async () => state.characterItems[0] ?? null, + save: async (value: CharacterItem) => value, + remove: async (value: CharacterItem) => { + state.removedItems.push(value); + state.characterItems = state.characterItems.filter( + (item) => item !== value, + ); + return value; + }, + }; + } + if (entity === Character) { + return { + findOne: async () => character, + save: async (value: Character) => { + state.savedCharacters.push(value); + return value; + }, + }; + } + throw new Error('Unexpected repository'); + }, + } as unknown as EntityManager; + + const dataSource = { + getRepository: manager.getRepository.bind(manager), + transaction: async (run: (m: EntityManager) => Promise) => run(manager), + } as unknown as DataSource; + + const objectiveStates: QuestObjectiveState[] = objectives.map( + (candidate, index) => ({ + objective: candidate, + current: + candidate.type === QuestObjectiveType.COLLECT_ITEM + ? (fixture.pelts ?? 0) + : 0, + required: candidate.requiredQuantity, + blocked: (fixture.blockedIndexes ?? []).includes(index), + satisfied: + candidate.type === QuestObjectiveType.COLLECT_ITEM && + (fixture.pelts ?? 0) >= candidate.requiredQuantity, + }), + ); + + const status: QuestStatus = + fixture.status === undefined + ? 'AVAILABLE' + : fixture.status === CharacterQuestStatus.COMPLETED + ? 'COMPLETED' + : 'ACTIVE'; + + const questState: QuestState = { + quest, + objectives: objectiveStates, + row: questRow, + status, + currentIndex: + fixture.currentIndex === undefined + ? status === 'ACTIVE' + ? (fixture.storedIndex ?? 0) + : null + : fixture.currentIndex, + }; + + const progress = { + getQuestStates: jest.fn().mockResolvedValue([questState]), + getQuestState: jest.fn().mockResolvedValue(questState), + getNpcQuestStates: jest.fn().mockResolvedValue([]), + } as unknown as QuestProgressService; + + const npcService = { + requireReachableNpc: jest.fn(async (_characterId: string, key: string) => { + // Not `??`: the fixture uses an explicit null to mean "nowhere near + // anyone", which `??` would quietly turn back into the warden. + const standing = + fixture.standingWith === undefined ? 'warden' : fixture.standingWith; + if (standing === null) { + throw new Error('NPC_UNAVAILABLE'); + } + if (npcs[standing].key !== key) { + throw new Error('NPC_UNAVAILABLE'); + } + return npcs[standing]; + }), + } as unknown as NpcService; + + const reputation = { + grantReputation: jest.fn().mockResolvedValue({ + factionKey: 'border-guard', + previousReputation: 0, + newReputation: 10, + previousRank: 'NEUTRAL', + newRank: 'NEUTRAL', + rankChanged: false, + }), + } as unknown as ReputationService; + + return { + service: new QuestService(dataSource, progress, npcService, reputation), + state, + character, + reputation, + questState, + }; +} + +describe('QuestService.acceptQuest', () => { + it('records the quest as active at the first step', async () => { + const { service, state } = createWorld({ status: undefined }); + + const result = await service.acceptQuest( + CHARACTER_ID, + WARDEN_KEY, + QUEST_KEY, + ); + + expect(state.savedQuestRows[0]).toMatchObject({ + characterId: CHARACTER_ID, + questId: QUEST_ID, + status: CharacterQuestStatus.ACTIVE, + currentObjectiveIndex: 0, + }); + // The offer line is dialogue content; the accept itself says nothing. + expect(result.npcLine).toBeNull(); + expect(result.grantedBag).toBeNull(); + }); + + it('refuses an NPC that does not offer the quest', async () => { + const { service } = createWorld({ + status: undefined, + hasOfferAssignment: false, + }); + + await expect( + service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ response: { code: 'QUEST_NOT_OFFERED_HERE' } }); + }); + + it('refuses an unknown or disabled quest', async () => { + const { service } = createWorld({ status: undefined, questEnabled: false }); + + await expect( + service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ response: { code: 'QUEST_NOT_FOUND' } }); + }); + + it('accepts the quest only once', async () => { + const { service } = createWorld({ status: CharacterQuestStatus.ACTIVE }); + + await expect( + service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ response: { code: 'QUEST_ALREADY_ACCEPTED' } }); + }); + + it('turns a lost unique-index race into the same refusal', async () => { + // Two clicks that both pass the existence check: the database refuses the + // second, and the player must not see a 500 for it (AGENTS.md §30). + const { service } = createWorld({ status: undefined, insertRace: true }); + + await expect( + service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ response: { code: 'QUEST_ALREADY_ACCEPTED' } }); + }); + + it('never accepts a quest the character cannot reach', async () => { + const { service } = createWorld({ status: undefined, standingWith: null }); + + await expect( + service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toThrow('NPC_UNAVAILABLE'); + }); +}); + +describe('QuestService.advanceQuest', () => { + it('sets the referral flag on Borin, not on the warden', async () => { + const { service, state } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 0, + currentIndex: 1, + pelts: 1, + blockedIndexes: [0, 3], + }); + + const result = await service.advanceQuest( + CHARACTER_ID, + WARDEN_KEY, + QUEST_KEY, + ); + + // Slice 0.9 §6: the 0.8.5 bypass is evaluated with Borin in context, so + // the flag has to live on his row (NPC spec §7). + expect(state.savedNpcStates[0]).toMatchObject({ + npcId: BORIN_ID, + flags: { [REFERRAL_FLAG]: true }, + }); + expect(result.npcLine).toBe('Go see Borin in Graufurt.'); + }); + + it('keeps the flags an NPC row already carried', async () => { + const { service, state } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 0, + currentIndex: 1, + pelts: 1, + borinFlags: { met: true }, + }); + + await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY); + + // `met` drives Borin's first-meeting line. Replacing the map instead of + // merging into it would make him greet the player as a stranger again. + expect(state.savedNpcStates[0].flags).toEqual({ + met: true, + [REFERRAL_FLAG]: true, + }); + }); + + it('advances the stored floor past the step it performed', async () => { + const { service, state } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 0, + currentIndex: 1, + pelts: 1, + }); + + await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY); + + expect(state.savedQuestRows[0].currentObjectiveIndex).toBe(2); + }); + + it('hands the Basic Hide Bag over on the step at Borin', async () => { + const { service, state } = createWorld({ + standingWith: 'borin', + status: CharacterQuestStatus.ACTIVE, + storedIndex: 2, + currentIndex: 2, + pelts: 1, + }); + + const result = await service.advanceQuest( + CHARACTER_ID, + BORIN_KEY, + QUEST_KEY, + ); + + expect(state.savedLootBags[0]).toMatchObject({ + lootBagDefinitionId: HIDE_BAG_DEFINITION_ID, + active: true, + }); + expect(result.grantedBag).toEqual({ + key: 'basic-hide-bag', + name: 'Basic Hide Bag', + lootCategory: LootCategory.HIDE, + capacity: 5, + }); + }); + + it('does not hand over a bag the character already carries', async () => { + // Slice 0.9 §11: the grant is idempotent, so development data or a repeat + // click cannot produce a second bag or an error. + const { service, state } = createWorld({ + standingWith: 'borin', + status: CharacterQuestStatus.ACTIVE, + storedIndex: 2, + currentIndex: 2, + ownsHideBag: true, + }); + + const result = await service.advanceQuest( + CHARACTER_ID, + BORIN_KEY, + QUEST_KEY, + ); + + expect(state.savedLootBags).toHaveLength(0); + expect(result.grantedBag).toBeNull(); + // The step still counts: the chain must not stall on an already-owned bag. + expect(state.savedQuestRows[0].currentObjectiveIndex).toBe(3); + }); + + it('refuses a step at the wrong NPC', async () => { + const { service } = createWorld({ + standingWith: 'borin', + status: CharacterQuestStatus.ACTIVE, + storedIndex: 0, + currentIndex: 1, + }); + + await expect( + service.advanceQuest(CHARACTER_ID, BORIN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ response: { code: 'QUEST_STEP_NOT_HERE' } }); + }); + + it('refuses the turn-in while the pelts are still short', async () => { + const { service } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 3, + pelts: 4, + }); + + await expect( + service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ response: { code: 'QUEST_STEP_NOT_HERE' } }); + }); + + it('refuses to advance a quest that was never accepted', async () => { + const { service } = createWorld({ status: undefined }); + + await expect( + service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ response: { code: 'QUEST_NOT_ACTIVE' } }); + }); + + it('refuses to advance a completed quest', async () => { + const { service } = createWorld({ + status: CharacterQuestStatus.COMPLETED, + storedIndex: 5, + }); + + await expect( + service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ response: { code: 'QUEST_NOT_ACTIVE' } }); + }); + + it('consumes exactly the pelts the quest asked for', async () => { + const { service, state } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 4, + pelts: 7, + }); + + const result = await service.advanceQuest( + CHARACTER_ID, + WARDEN_KEY, + QUEST_KEY, + ); + + // Two collect steps ask for five each; only the second consumes, so the + // turn-in takes five and leaves the surplus (§8). + expect(result.consumedItems).toEqual([{ itemKey: 'ash-pelt', quantity: 5 }]); + expect(state.characterItems[0].quantity).toBe(2); + expect(state.removedItems).toHaveLength(0); + }); + + it('removes the item row when the last pelt is consumed', async () => { + const { service, state } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 4, + pelts: 5, + }); + + await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY); + + expect(state.removedItems).toHaveLength(1); + }); + + it('refuses the turn-in when the pelts vanished between read and write', async () => { + // The derived step said "turn-in", but the transaction is where ownership + // is actually decided. + const { service } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 4, + pelts: 0, + }); + + await expect( + service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY), + ).rejects.toMatchObject({ + response: { code: 'QUEST_OBJECTIVE_INCOMPLETE' }, + }); + }); + + it('completes the quest and stamps the time', async () => { + const { service, state } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 4, + pelts: 5, + }); + + await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY); + + const saved = state.savedQuestRows[0]; + expect(saved.status).toBe(CharacterQuestStatus.COMPLETED); + expect(saved.completedAt).toBeInstanceOf(Date); + expect(saved.currentObjectiveIndex).toBe(5); + }); + + it('pays the regional reputation reward on completion', async () => { + const { service, reputation } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 4, + pelts: 5, + }); + + const result = await service.advanceQuest( + CHARACTER_ID, + WARDEN_KEY, + QUEST_KEY, + ); + + expect(reputation.grantReputation).toHaveBeenCalledWith( + CHARACTER_ID, + 'border-guard', + 10, + expect.anything(), + ); + expect(result.rewards).toEqual({ + factionKey: 'border-guard', + reputation: 10, + silver: 0, + }); + }); + + it('pays no silver, because the content asks for none', async () => { + // Slice 0.9 decision D3. The branch exists so retuning is a seed change, + // but the seeded quest deliberately pays nothing. + const { service, state } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 4, + pelts: 5, + }); + + await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY); + + expect(state.savedCharacters).toHaveLength(0); + }); + + it('pays silver when content asks for it', async () => { + const { service, state, character } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 4, + pelts: 5, + rewardSilver: 25, + silver: 4, + }); + + const result = await service.advanceQuest( + CHARACTER_ID, + WARDEN_KEY, + QUEST_KEY, + ); + + expect(character.silver).toBe(29); + expect(state.savedCharacters).toHaveLength(1); + expect(result.rewards?.silver).toBe(25); + }); + + it('pays nothing on a step that is not the last one', async () => { + const { service, reputation, state } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 0, + currentIndex: 1, + pelts: 1, + }); + + const result = await service.advanceQuest( + CHARACTER_ID, + WARDEN_KEY, + QUEST_KEY, + ); + + expect(reputation.grantReputation).not.toHaveBeenCalled(); + expect(result.rewards).toBeNull(); + expect(result.consumedItems).toEqual([]); + expect(state.savedQuestRows[0].status).toBe(CharacterQuestStatus.ACTIVE); + }); +}); + +describe('QuestService.getQuestLog', () => { + it('renders collect progress against what the step needs', async () => { + const { service } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 0, + currentIndex: 0, + pelts: 1, + }); + + const [quest] = await service.getQuestLog(CHARACTER_ID); + + expect(quest).toMatchObject({ + key: QUEST_KEY, + title: 'Trouble Beyond the Gate', + status: 'ACTIVE', + currentObjectiveKey: 'collect-pelts-first', + }); + expect(quest.objectives[0]).toMatchObject({ + description: 'Collect Ashen Pelts', + current: 1, + required: 5, + completed: false, + }); + }); + + it('shows the hint while the active step is blocked', async () => { + const { service } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 0, + currentIndex: 0, + pelts: 1, + blockedIndexes: [0], + }); + + const [quest] = await service.getQuestLog(CHARACTER_ID); + + // Slice 0.9 §4/§12: the line that keeps "1 / 5" from reading as a wall. + expect(quest.hint).toBe( + 'You cannot carry enough pelts. Return to the South Gate Warden.', + ); + }); + + it('shows no hint for a step that is merely unfinished', async () => { + const { service } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 0, + currentIndex: 0, + pelts: 1, + }); + + const [quest] = await service.getQuestLog(CHARACTER_ID); + + expect(quest.hint).toBeNull(); + }); + + it('marks every step behind the current one as done', async () => { + const { service } = createWorld({ + status: CharacterQuestStatus.ACTIVE, + storedIndex: 3, + currentIndex: 3, + pelts: 1, + }); + + const [quest] = await service.getQuestLog(CHARACTER_ID); + + expect(quest.objectives.map((entry) => entry.completed)).toEqual([ + true, + true, + true, + false, + false, + ]); + }); + + it('marks a completed quest as done throughout and points at no step', async () => { + const { service } = createWorld({ + status: CharacterQuestStatus.COMPLETED, + storedIndex: 5, + }); + + const [quest] = await service.getQuestLog(CHARACTER_ID); + + expect(quest.status).toBe('COMPLETED'); + expect(quest.currentObjectiveKey).toBeNull(); + expect(quest.hint).toBeNull(); + expect(quest.objectives.every((entry) => entry.completed)).toBe(true); + }); + + it('reports an unstarted quest as available with nothing done', async () => { + const { service } = createWorld({ status: undefined }); + + const [quest] = await service.getQuestLog(CHARACTER_ID); + + expect(quest.status).toBe('AVAILABLE'); + expect(quest.currentObjectiveKey).toBeNull(); + expect(quest.objectives.every((entry) => !entry.completed)).toBe(true); + }); +}); diff --git a/apps/api/src/quests/quest.service.ts b/apps/api/src/quests/quest.service.ts new file mode 100644 index 0000000..fec2bf6 --- /dev/null +++ b/apps/api/src/quests/quest.service.ts @@ -0,0 +1,489 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity'; +import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity'; +import { NpcDefinition } from '../npcs/entities/npc-definition.entity'; +import { NpcService } from '../npcs/npc.service'; +import { ReputationService } from '../reputation/reputation.service'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { QuestProgressService, QuestState } from './quest-progress.service'; +import { + questAlreadyAccepted, + questNotActive, + questNotFound, + questNotOfferedHere, + questObjectiveIncomplete, + questStepNotHere, +} from './quest.errors'; +import { + CharacterQuestStatus, + GrantedLootBagDto, + NpcQuestRole, + QuestConsumedItemDto, + QuestDto, + QuestInteractionResultDto, + QuestObjectiveType, + QuestRewardDto, +} from './quest.types'; + +/** Postgres' unique-violation SQLSTATE. */ +const UNIQUE_VIOLATION = '23505'; + +/** + * Runs a quest chain (Playable Slice 0.9 §10). + * + * A step-based state machine and nothing more: §10 asks for exactly that and + * warns off a branching narrative engine. What a step *does* is content on the + * objective -- set a flag, hand a bag over, consume what it asked for -- so + * this service applies effects it finds rather than switching on quest keys + * (AGENTS.md §9). + * + * Steps are triggered by their own endpoints rather than by dialogue actions + * (decision D4). NPC spec §40 sketched `START_QUEST` / `COMPLETE_QUEST` as + * dialogue actions, but there is no dialogue-response endpoint to carry them, + * and building one would be the narrative engine §10 rules out. The beat's line + * travels back on the step result instead. + * + * Every softlock case in §11 is handled by *not* storing progress: the active + * step is derived from what the character owns right now + * (`QuestProgressService`), so selling the pelts reopens the objective, owning + * some before accepting already counts, and a bag obtained some other way just + * lets the first hunt finish. + */ +@Injectable() +export class QuestService { + constructor( + private readonly dataSource: DataSource, + private readonly progress: QuestProgressService, + private readonly npcs: NpcService, + private readonly reputation: ReputationService, + ) {} + + /** Every enabled quest and where this character stands with it (spec §12). */ + async getQuestLog(characterId: string): Promise { + const states = await this.progress.getQuestStates(characterId); + return states.map((state) => toQuestDto(state)); + } + + /** + * Takes a quest on, once (spec §13). + * + * The NPC has to actually offer it: an `OFFER` assignment is what makes a + * person a quest giver, not a flag on the NPC row (NPC spec §14). + */ + async acceptQuest( + characterId: string, + npcKey: string, + questKey: string, + ): Promise { + // The character's own location decides reachability, never the request. + const npc = await this.npcs.requireReachableNpc(characterId, npcKey); + + await this.dataSource.transaction(async (manager) => { + const quest = await this.requireQuest(manager, questKey); + + const assignment = await manager + .getRepository(NpcQuestAssignment) + .findOneBy({ + npcId: npc.id, + questId: quest.id, + role: NpcQuestRole.OFFER, + enabled: true, + }); + if (!assignment) { + throw questNotOfferedHere(); + } + + const rows = manager.getRepository(CharacterQuest); + const existing = await rows.findOne({ + where: { characterId, questId: quest.id }, + }); + if (existing) { + throw questAlreadyAccepted(); + } + + try { + await rows.save( + rows.create({ + characterId, + questId: quest.id, + status: CharacterQuestStatus.ACTIVE, + currentObjectiveIndex: 0, + acceptedAt: new Date(), + completedAt: null, + }), + ); + } catch (error) { + // Two clicks can both clear the check above. The unique index is the + // real guarantee; this is what stops the loser seeing a 500. + if (!isUniqueViolation(error)) { + throw error; + } + throw questAlreadyAccepted(); + } + }); + + const state = await this.progress.getQuestState(characterId, questKey); + if (!state) { + throw questNotFound(); + } + + // No line of its own: the warden's offer is a dialogue node, and the NPC + // screen re-reads dialogue after the quest state changes. + return { + quest: toQuestDto(state), + npcLine: null, + grantedBag: null, + consumedItems: [], + rewards: null, + }; + } + + /** + * Performs the talk step the character is currently on (spec §5, §6, §8). + * + * One endpoint for all three beats, because the difference between them is + * content: the referral sets a flag, Borin's step grants a bag, and the last + * step consumes and completes. Everything runs in one transaction under a + * write lock on the quest row, so two clicks cannot both consume the pelts + * (AGENTS.md §29, §30). + */ + async advanceQuest( + characterId: string, + npcKey: string, + questKey: string, + ): Promise { + const npc = await this.npcs.requireReachableNpc(characterId, npcKey); + + return this.dataSource.transaction(async (manager) => { + const quest = await this.requireQuest(manager, questKey); + + const rows = manager.getRepository(CharacterQuest); + const row = await rows.findOne({ + where: { characterId, questId: quest.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!row || row.status !== CharacterQuestStatus.ACTIVE) { + throw questNotActive(); + } + + const state = await this.progress.getQuestState( + characterId, + questKey, + manager, + ); + if (!state || state.currentIndex === null) { + throw questNotActive(); + } + + const currentIndex = state.currentIndex; + const step = state.objectives[currentIndex]?.objective; + // Also what refuses an early turn-in: with the pelts still short, the + // derived step is the hunt, not the hand-over. + if ( + !step || + step.type !== QuestObjectiveType.TALK_TO_NPC || + step.targetKey !== npc.key + ) { + throw questStepNotHere(); + } + + row.currentObjectiveIndex = currentIndex + 1; + + const grantedBag = await this.grantLootBag(manager, characterId, step); + await this.setStepFlag(manager, characterId, step); + + let consumedItems: QuestConsumedItemDto[] = []; + let rewards: QuestRewardDto | null = null; + + if (row.currentObjectiveIndex >= state.objectives.length) { + consumedItems = await this.consumeQuestItems( + manager, + characterId, + state, + ); + rewards = await this.grantRewards(manager, characterId, quest); + row.status = CharacterQuestStatus.COMPLETED; + row.completedAt = new Date(); + } + + await rows.save(row); + + const refreshed = await this.progress.getQuestState( + characterId, + questKey, + manager, + ); + + return { + quest: toQuestDto(refreshed ?? state), + npcLine: step.npcLine, + grantedBag, + consumedItems, + rewards, + }; + }); + } + + private async requireQuest( + manager: EntityManager, + questKey: string, + ): Promise { + const quest = await manager + .getRepository(QuestDefinition) + .findOneBy({ key: questKey, enabled: true }); + if (!quest) { + throw questNotFound(); + } + return quest; + } + + /** + * Hands the step's bag over, at most once (spec §11, decision D1). + * + * A bag the character already holds is not an error: development data, a + * repeated click and a re-run of the same step must all leave the chain + * moving. Only the roomiest active bag per category counts anyway + * (Slice 0.7.5 §6), so a second copy would grant nothing. + */ + private async grantLootBag( + manager: EntityManager, + characterId: string, + step: QuestObjective, + ): Promise { + if (!step.grantsLootBagKey) { + return null; + } + + const definition = await manager + .getRepository(LootBagDefinition) + .findOneBy({ key: step.grantsLootBagKey }); + if (!definition) { + // Content names a bag that does not exist. Refusing the whole step would + // strand the player on a content bug they cannot fix; the step still + // counts and the missing bag is visible in its absence. + return null; + } + + const bags = manager.getRepository(CharacterLootBag); + const existing = await bags.findOne({ + where: { characterId, lootBagDefinitionId: definition.id }, + }); + if (existing) { + return null; + } + + await bags.save( + bags.create({ + characterId, + lootBagDefinitionId: definition.id, + active: true, + }), + ); + + return { + key: definition.key, + name: definition.name, + lootCategory: definition.lootCategory, + capacity: definition.capacity, + }; + } + + /** + * Writes the step's dialogue flag onto whichever NPC content names (spec §5). + * + * The warden's referral belongs on Borin's row, because a flag is per-NPC + * player state (NPC spec §7) and Borin is the NPC in context when the Hide + * Bag offer's 0.8.5 bypass is evaluated (spec §6). Merged into the existing + * flags rather than replacing them -- `met` drives Borin's first-meeting + * line, and dropping it would make him greet the player as a stranger again. + */ + private async setStepFlag( + manager: EntityManager, + characterId: string, + step: QuestObjective, + ): Promise { + if (!step.setsFlagKey || !step.setsFlagNpcKey) { + return; + } + + const target = await manager + .getRepository(NpcDefinition) + .findOneBy({ key: step.setsFlagNpcKey }); + if (!target) { + return; + } + + const states = manager.getRepository(CharacterNpcState); + const existing = await states.findOneBy({ characterId, npcId: target.id }); + + if (existing) { + existing.flags = { ...existing.flags, [step.setsFlagKey]: true }; + await states.save(existing); + return; + } + + await states.save( + states.create({ + characterId, + npcId: target.id, + flags: { [step.setsFlagKey]: true }, + }), + ); + } + + /** + * Takes the goods the quest asked for (spec §8). + * + * Only steps marked `consumeOnComplete` are taken: this chain asks for five + * pelts twice, and consuming both would quietly demand ten. Quantities are + * re-checked here rather than trusted from the derivation, because the + * derivation ran before the lock and the player may have traded in between. + */ + private async consumeQuestItems( + manager: EntityManager, + characterId: string, + state: QuestState, + ): Promise { + const consumed: QuestConsumedItemDto[] = []; + + for (const entry of state.objectives) { + const step = entry.objective; + if ( + step.type !== QuestObjectiveType.COLLECT_ITEM || + !step.consumeOnComplete + ) { + continue; + } + + const definition = await manager + .getRepository(ItemDefinition) + .findOneBy({ key: step.targetKey }); + if (!definition) { + throw questObjectiveIncomplete(); + } + + const characterItems = manager.getRepository(CharacterItem); + const owned = await characterItems.findOne({ + where: { characterId, itemDefinitionId: definition.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!owned || owned.quantity < step.requiredQuantity) { + throw questObjectiveIncomplete(); + } + + if (owned.quantity === step.requiredQuantity) { + await characterItems.remove(owned); + } else { + owned.quantity -= step.requiredQuantity; + await characterItems.save(owned); + } + + consumed.push({ + itemKey: step.targetKey, + quantity: step.requiredQuantity, + }); + } + + return consumed; + } + + /** + * Pays the quest out, once (spec §9). + * + * Reputation and Silver only. There is no XP anywhere in the project any + * more (Slice 0.7 V2 §7), and no renown milestone on this quest by decision + * D2 -- one would reach World Renown 3 and open a Slice 0.8.5 offer that is + * meant to stay out of reach until 0.11. The seeded values pay 10 reputation + * and no Silver (D3); both branches exist so retuning stays a content change. + */ + private async grantRewards( + manager: EntityManager, + characterId: string, + quest: QuestDefinition, + ): Promise { + if (quest.rewardFactionKey && quest.rewardReputation > 0) { + await this.reputation.grantReputation( + characterId, + quest.rewardFactionKey, + quest.rewardReputation, + manager, + ); + } + + if (quest.rewardSilver > 0) { + const characters = manager.getRepository(Character); + const character = await characters.findOne({ + where: { id: characterId }, + lock: { mode: 'pessimistic_write' }, + }); + if (character) { + character.silver += quest.rewardSilver; + await characters.save(character); + } + } + + return { + factionKey: quest.rewardFactionKey, + reputation: quest.rewardReputation, + silver: quest.rewardSilver, + }; + } +} + +/** + * The quest as the player reads it (spec §12). + * + * `completed` is positional rather than stored: everything behind the derived + * step is done, and everything from it onward is not. A finished quest reads as + * done throughout, which is what keeps the journal honest after the goods were + * consumed. + */ +export function toQuestDto(state: QuestState): QuestDto { + const currentIndex = state.currentIndex; + const currentEntry = + currentIndex === null ? undefined : state.objectives[currentIndex]; + + return { + key: state.quest.key, + title: state.quest.title, + description: state.quest.description, + status: state.status, + objectives: state.objectives.map((entry, index) => ({ + key: entry.objective.key, + description: entry.objective.description, + type: entry.objective.type, + targetKey: entry.objective.targetKey, + required: entry.required, + current: entry.current, + completed: + state.status === 'COMPLETED' || + (currentIndex !== null && index < currentIndex), + })), + currentObjectiveKey: currentEntry?.objective.key ?? null, + // Only while the step genuinely cannot progress. A step that is merely + // unfinished needs no explanation, and saying one anyway would train the + // player to ignore the line that matters. + hint: + currentEntry && currentEntry.blocked && !currentEntry.satisfied + ? currentEntry.objective.hintText + : null, + }; +} + +function isUniqueViolation(error: unknown): boolean { + const code = ( + error as { driverError?: { code?: string }; code?: string } | null + )?.driverError?.code; + return ( + code === UNIQUE_VIOLATION || + (error as { code?: string } | null)?.code === UNIQUE_VIOLATION + ); +} 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; +} diff --git a/apps/api/src/quests/quests.module.ts b/apps/api/src/quests/quests.module.ts new file mode 100644 index 0000000..8eb516e --- /dev/null +++ b/apps/api/src/quests/quests.module.ts @@ -0,0 +1,50 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity'; +import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity'; +import { NpcDefinition } from '../npcs/entities/npc-definition.entity'; +import { NpcsModule } from '../npcs/npcs.module'; +import { ReputationModule } from '../reputation/reputation.module'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { QuestProgressModule } from './quest-progress.module'; +import { NpcQuestController, QuestController } from './quest.controller'; +import { QuestService } from './quest.service'; + +/** + * Running quest chains (Playable Slice 0.9 §10). + * + * Imports `NpcsModule` for reachability and `QuestProgressModule` for the + * derived step. `NpcsModule` imports only the latter, which is why that split + * exists at all -- see `QuestProgressModule`. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Character, + CharacterItem, + CharacterLootBag, + CharacterNpcState, + CharacterQuest, + ItemDefinition, + LootBagDefinition, + NpcDefinition, + NpcQuestAssignment, + QuestDefinition, + QuestObjective, + ]), + QuestProgressModule, + NpcsModule, + ReputationModule, + ], + controllers: [QuestController, NpcQuestController], + providers: [QuestService], + exports: [QuestService], +}) +export class QuestsModule {} diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index ff29e04..b4db3ef 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -42,6 +42,13 @@ export const routes: Routes = [ (module) => module.MerchantPageComponent, ), }, + { + path: 'quests', + loadComponent: () => + import('./features/quests/quest-page.component').then( + (module) => module.QuestPageComponent, + ), + }, { path: 'inventory', loadComponent: () => diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index dcd4ea0..6f85984 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -67,11 +67,17 @@ describe('App', () => { expect(inventoryButton?.disabled).toBe(false); expect(inventoryButton?.getAttribute('aria-label')).toBe('Inventory'); - for (const destination of ['quests', 'character']) { - expect( - element.querySelector(`[data-navigation="${destination}"]`)?.disabled, - ).toBe(true); - } + // Quests became reachable in Slice 0.9; Character is still unbuilt. + const questsButton = element.querySelector( + '[data-navigation="quests"]', + ); + expect(questsButton?.disabled).toBe(false); + expect(questsButton?.getAttribute('aria-label')).toBe('Quests'); + + expect( + element.querySelector('[data-navigation="character"]') + ?.disabled, + ).toBe(true); expect(element.textContent).not.toContain('Shop'); }); diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index 23a7f97..c951c3a 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -347,6 +347,7 @@ export type NpcMarker = | 'MERCHANT' | 'EXCHANGE' | 'QUEST_AVAILABLE' + | 'QUEST_IN_PROGRESS' | 'QUEST_TURN_IN'; export interface NpcSummary { @@ -469,3 +470,55 @@ export interface ShopPurchaseResult { silverSpent: number; silverBalance: number; } + +/** + * Quest transport types (Playable Slice 0.9). + * + * Mirrors the API's quest DTOs field for field. Nothing here is computed + * client-side: which step is current, how far along it is and whether it is + * blocked are all decided by the server (AGENTS.md §22). + */ +export type QuestStatus = 'AVAILABLE' | 'ACTIVE' | 'COMPLETED'; +export type QuestObjectiveType = 'COLLECT_ITEM' | 'TALK_TO_NPC'; + +export interface QuestObjectiveView { + key: string; + description: string; + type: QuestObjectiveType; + targetKey: string; + required: number; + current: number; + completed: boolean; +} + +export interface QuestView { + key: string; + title: string; + description: string; + status: QuestStatus; + objectives: QuestObjectiveView[]; + currentObjectiveKey: string | null; + /** Why the current step cannot progress, when it cannot (slice §4, §12). */ + hint: string | null; +} + +export interface GrantedLootBag { + key: string; + name: string; + lootCategory: string; + capacity: number; +} + +export interface QuestInteractionResult { + quest: QuestView; + /** What the NPC says for the step just performed (slice §5, §6, §8). */ + npcLine: string | null; + /** Non-null only on the step that hands a bag over (slice §12 Bag UI). */ + grantedBag: GrantedLootBag | null; + consumedItems: Array<{ itemKey: string; quantity: number }>; + rewards: { + factionKey: string | null; + reputation: number; + silver: number; + } | null; +} diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts index ea34b82..f82fafc 100644 --- a/apps/web/src/app/core/api/game-api.service.spec.ts +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -156,4 +156,47 @@ describe('GameApiService', () => { }); req.flush({}); }); + + it('reads the quest log', () => { + service.getQuests().subscribe(); + + const req = http.expectOne('/api/quests'); + expect(req.request.method).toBe('GET'); + req.flush([]); + }); + + it('accepts a quest without sending a body', () => { + service + .acceptQuest('south-gate-warden', 'trouble-beyond-the-gate') + .subscribe(); + + const req = http.expectOne( + '/api/npcs/south-gate-warden/quests/trouble-beyond-the-gate/accept', + ); + expect(req.request.method).toBe('POST'); + // The server decides where the quest starts; the client only names it. + expect(req.request.body).toEqual({}); + req.flush({}); + }); + + it('advances a quest step without naming the step', () => { + service + .advanceQuest('borin-quartermaster', 'trouble-beyond-the-gate') + .subscribe(); + + const req = http.expectOne( + '/api/npcs/borin-quartermaster/quests/trouble-beyond-the-gate/advance', + ); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({}); + req.flush({}); + }); + + it('encodes npc and quest keys into the path', () => { + service.advanceQuest('odd/key', 'quest key').subscribe(); + + const req = http.expectOne('/api/npcs/odd%2Fkey/quests/quest%20key/advance'); + expect(req.request.method).toBe('POST'); + req.flush({}); + }); }); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index 90e0b79..fc5c252 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -16,6 +16,8 @@ import { ExchangeView, NpcInteraction, NpcSummary, + QuestInteractionResult, + QuestView, ReputationEntry, ShopPurchaseResult, ShopView, @@ -128,6 +130,40 @@ export class GameApiService { ); } + getQuests(): Observable { + return this.http.get('/api/quests'); + } + + /** + * Takes a quest on. Only the two keys travel: the server decides whether this + * person offers it and where the quest starts (slice §10). + */ + acceptQuest( + npcKey: string, + questKey: string, + ): Observable { + return this.http.post( + `/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/accept`, + {}, + ); + } + + /** + * Performs whatever step this NPC is currently owed. + * + * The client never names the step. Which one is current, what it grants and + * what it consumes are the server's to decide (AGENTS.md §5). + */ + advanceQuest( + npcKey: string, + questKey: string, + ): Observable { + return this.http.post( + `/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/advance`, + {}, + ); + } + getShop(merchantKey: string): Observable { return this.http.get( `/api/merchants/${encodeURIComponent(merchantKey)}/shop`, diff --git a/apps/web/src/app/features/npc/merchant-page.component.html b/apps/web/src/app/features/npc/merchant-page.component.html index 3841a9c..51273db 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.html +++ b/apps/web/src/app/features/npc/merchant-page.component.html @@ -41,6 +41,7 @@ [class.merchant__button--active]=" (action.type === 'OPEN_EXCHANGE' && isPanel('EXCHANGE')) || (action.type === 'OPEN_SHOP' && isPanel('SHOP')) || + (action.type === 'VIEW_QUESTS' && isPanel('QUESTS')) || (action.type === 'TALK' && isPanel('DIALOGUE')) " [attr.data-action]="action.type" @@ -58,6 +59,71 @@ } + @if (store.questLine(); as line) { +
+ {{ line }} +
+ } + + @if (store.grantedBag(); as bag) { +
+

New Loot Bag

+

{{ bag.name }}

+

{{ capacityLabel(bag) }}

+ +
+ } + + @if (isPanel('QUESTS')) { +
+

Matters at Hand

+ + @for (quest of store.quests(); track quest.key) { +
+

{{ quest.title }}

+

{{ quest.description }}

+ + @if (currentObjective(quest); as objective) { + + } + + @if (quest.status === 'AVAILABLE') { + + } @else if (quest.status === 'ACTIVE' && isCurrentStepHere(quest)) { + + } +
+ } @empty { +

There is nothing to discuss.

+ } +
+ } + @if (isPanel('EXCHANGE') && store.exchange(); as exchange) {

{{ exchange.profileName }}

diff --git a/apps/web/src/app/features/npc/merchant-page.component.scss b/apps/web/src/app/features/npc/merchant-page.component.scss index 0188625..f67e4ca 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.scss +++ b/apps/web/src/app/features/npc/merchant-page.component.scss @@ -391,6 +391,66 @@ font-size: var(--ar-font-sm); } +/* ---------- quests ---------- */ + +.npc-quest + .npc-quest { + padding-block-start: var(--ar-space-4); + border-block-start: 1px solid rgb(85 74 57 / 0.5); +} + +.npc-quest__title { + margin: 0 0 var(--ar-space-1); + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.05rem; + font-weight: 400; + letter-spacing: 0.03em; +} + +.npc-quest__description { + margin: 0 0 var(--ar-space-3); + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + line-height: 1.6; +} + +.npc-quest .merchant__button { + margin-block-start: var(--ar-space-3); +} + +/* The one moment the slice is actually about (§12). Loud enough to notice, + quiet enough not to be a modal -- the same restraint the unlock line uses. */ +.bag-granted { + padding: var(--ar-space-3) var(--ar-space-4); + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-md); + background: var(--ar-panel-muted); +} + +.bag-granted__heading { + margin: 0; + color: var(--ar-text-muted); + font-family: Georgia, 'Times New Roman', serif; + font-size: 0.78rem; + font-weight: 400; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.bag-granted__name { + margin: var(--ar-space-1) 0 0; + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.15rem; +} + +.bag-granted__capacity { + margin: 0 0 var(--ar-space-2); + color: var(--ar-text); + font-size: var(--ar-font-sm); + font-variant-numeric: tabular-nums; +} + @media (max-width: 40rem) { .merchant__identity { grid-template-columns: 1fr; diff --git a/apps/web/src/app/features/npc/merchant-page.component.spec.ts b/apps/web/src/app/features/npc/merchant-page.component.spec.ts index a8cc064..308f5e5 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.spec.ts +++ b/apps/web/src/app/features/npc/merchant-page.component.spec.ts @@ -7,6 +7,8 @@ import type { ExchangeResult, ExchangeView, NpcInteraction, + QuestInteractionResult, + QuestView, ShopView, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; @@ -529,3 +531,211 @@ describe('MerchantPageComponent', () => { expect(button.textContent).toContain('Buy'); }); }); + +/** Borin, but with a quest to talk about (Playable Slice 0.9 §6). */ +function questInteraction(): NpcInteraction { + return { + ...INTERACTION, + availableActions: [ + ...INTERACTION.availableActions, + { type: 'VIEW_QUESTS', label: 'Quests', key: null }, + ], + }; +} + +function questView(overrides: Partial = {}): QuestView { + return { + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'The warden wants five Ashen Pelts.', + status: 'ACTIVE', + objectives: [ + { + key: 'collect-bag', + description: 'Speak with Borin in Graufurt', + type: 'TALK_TO_NPC', + targetKey: 'borin-quartermaster', + required: 1, + current: 0, + completed: false, + }, + ], + currentObjectiveKey: 'collect-bag', + hint: null, + ...overrides, + }; +} + +async function renderWithQuest( + quest: QuestView, + advanceResult: Partial = {}, +): Promise<{ + fixture: ComponentFixture; + element: HTMLElement; + api: Record>; +}> { + const api = { + getNpcInteraction: vi.fn(() => of(questInteraction())), + getTradeIn: vi.fn(() => of(EXCHANGE)), + getShop: vi.fn(() => of(SHOP)), + tradeIn: vi.fn(() => of(TRADE_RESULT)), + purchase: vi.fn(() => of({})), + getQuests: vi.fn(() => of([quest])), + getCharacter: vi.fn(() => of({})), + acceptQuest: vi.fn(() => + of({ + quest, + npcLine: null, + grantedBag: null, + consumedItems: [], + rewards: null, + }), + ), + advanceQuest: vi.fn(() => + of({ + quest, + npcLine: 'Take this.', + grantedBag: null, + consumedItems: [], + rewards: null, + ...advanceResult, + }), + ), + }; + + await TestBed.configureTestingModule({ + imports: [MerchantPageComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: GameApiService, useValue: api }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { paramMap: { get: () => 'borin-quartermaster' } }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(MerchantPageComponent); + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + element + .querySelector('[data-action="VIEW_QUESTS"]') + ?.click(); + fixture.detectChanges(); + + return { fixture, element, api }; +} + +describe('MerchantPageComponent quests panel', () => { + it('offers a quests panel when the server says so', async () => { + const { element } = await renderWithQuest(questView()); + + expect(element.querySelector('[data-action="VIEW_QUESTS"]')).not.toBeNull(); + expect(element.textContent).toContain('Trouble Beyond the Gate'); + expect(element.textContent).toContain('Speak with Borin in Graufurt'); + }); + + it('offers Accept for a quest that is not started', async () => { + const { element } = await renderWithQuest( + questView({ status: 'AVAILABLE', currentObjectiveKey: null }), + ); + + expect(element.querySelector('[data-quest-accept]')).not.toBeNull(); + expect(element.querySelector('[data-quest-advance]')).toBeNull(); + }); + + it('offers Continue when this NPC is the current step', async () => { + const { element } = await renderWithQuest(questView()); + + expect(element.querySelector('[data-quest-advance]')).not.toBeNull(); + expect(element.querySelector('[data-quest-accept]')).toBeNull(); + }); + + it('offers nothing when the step is somewhere else', async () => { + // The warden's step, seen from Borin's screen. + const { element } = await renderWithQuest( + questView({ + objectives: [ + { + key: 'turn-in', + description: 'Bring the pelts to the South Gate Warden', + type: 'TALK_TO_NPC', + targetKey: 'south-gate-warden', + required: 1, + current: 0, + completed: false, + }, + ], + currentObjectiveKey: 'turn-in', + }), + ); + + expect(element.querySelector('[data-quest-advance]')).toBeNull(); + expect(element.querySelector('[data-quest-accept]')).toBeNull(); + }); + + it('shows the line the step returned', async () => { + const { fixture, element } = await renderWithQuest(questView()); + + element.querySelector('[data-quest-advance]')?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + expect( + element.querySelector('[data-quest-line]')?.textContent, + ).toContain('Take this.'); + }); + + it('announces the new loot bag with its capacity', async () => { + const { fixture, element } = await renderWithQuest(questView(), { + grantedBag: { + key: 'basic-hide-bag', + name: 'Basic Hide Bag', + lootCategory: 'HIDE', + capacity: 5, + }, + }); + + element.querySelector('[data-quest-advance]')?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + // The exact block Slice 0.9 §12 prints. + const notice = element.querySelector('[data-granted-bag]'); + expect(notice?.textContent).toContain('New Loot Bag'); + expect(notice?.textContent).toContain('Basic Hide Bag'); + expect(notice?.textContent).toContain('Hide Capacity: 5'); + }); + + it('renders the blocked hint on the current objective', async () => { + const { element } = await renderWithQuest( + questView({ + objectives: [ + { + key: 'collect-pelts-first', + description: 'Collect Ashen Pelts', + type: 'COLLECT_ITEM', + targetKey: 'ash-pelt', + required: 5, + current: 1, + completed: false, + }, + ], + currentObjectiveKey: 'collect-pelts-first', + hint: 'You cannot carry enough pelts. Return to the South Gate Warden.', + }), + ); + + expect( + element.querySelector('[data-objective-hint]')?.textContent, + ).toContain('You cannot carry enough pelts.'); + expect( + element.querySelector('[data-objective-progress]')?.textContent?.trim(), + ).toBe('1 / 5'); + }); +}); diff --git a/apps/web/src/app/features/npc/merchant-page.component.ts b/apps/web/src/app/features/npc/merchant-page.component.ts index 2a23a51..04f7549 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.ts +++ b/apps/web/src/app/features/npc/merchant-page.component.ts @@ -1,6 +1,12 @@ import { Component, OnInit, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { + GrantedLootBag, + QuestObjectiveView, + QuestView, +} from '../../core/api/game-api.models'; import { LootCapacityStripComponent } from '../../shared/loot-capacity-strip/loot-capacity-strip.component'; +import { QuestObjectiveLineComponent } from '../quests/quest-objective-line.component'; import { WorldStore } from '../world/world.store'; import { MerchantPanel, MerchantStore } from './merchant.store'; @@ -14,7 +20,7 @@ import { MerchantPanel, MerchantStore } from './merchant.store'; */ @Component({ selector: 'app-merchant-page', - imports: [LootCapacityStripComponent], + imports: [LootCapacityStripComponent, QuestObjectiveLineComponent], templateUrl: './merchant-page.component.html', styleUrl: './merchant-page.component.scss', }) @@ -49,13 +55,47 @@ export class MerchantPageComponent implements OnInit { case 'TALK': this.store.showPanel('DIALOGUE'); return; + case 'VIEW_QUESTS': + this.store.showPanel('QUESTS'); + return; default: - // VIEW_QUESTS has no screen until Slice 0.9. Ignored rather than - // rendered as a button that does nothing. return; } } + /** The step the player is on, or null when the quest is not started. */ + protected currentObjective(quest: QuestView): QuestObjectiveView | null { + return ( + quest.objectives.find( + (objective) => objective.key === quest.currentObjectiveKey, + ) ?? null + ); + } + + /** + * Whether this NPC is the one the current step is waiting on. + * + * The server refuses a step at the wrong person anyway; this is what keeps + * the screen from offering a button that is going to be refused. + */ + protected isCurrentStepHere(quest: QuestView): boolean { + const objective = this.currentObjective(quest); + const npcKey = this.store.interaction()?.npc.key; + return ( + objective?.type === 'TALK_TO_NPC' && objective.targetKey === npcKey + ); + } + + /** "Hide Capacity: 5" from whatever category the bag covers (slice §12). */ + protected capacityLabel(bag: GrantedLootBag): string { + const category = bag.lootCategory + .toLowerCase() + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + return `${category} Capacity: ${bag.capacity}`; + } + protected isPanel(panel: MerchantPanel): boolean { return this.store.panel() === panel; } diff --git a/apps/web/src/app/features/npc/merchant.store.spec.ts b/apps/web/src/app/features/npc/merchant.store.spec.ts index 8e22aaf..20e5000 100644 --- a/apps/web/src/app/features/npc/merchant.store.spec.ts +++ b/apps/web/src/app/features/npc/merchant.store.spec.ts @@ -6,6 +6,8 @@ import type { ExchangeResult, ExchangeView, NpcInteraction, + QuestInteractionResult, + QuestView, ShopOfferView, ShopView, } from '../../core/api/game-api.models'; @@ -13,11 +15,9 @@ import { GameApiService } from '../../core/api/game-api.service'; import { MerchantStore } from './merchant.store'; function interaction( - actionTypes: Array<'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE'> = [ - 'TALK', - 'OPEN_SHOP', - 'OPEN_EXCHANGE', - ], + actionTypes: Array< + 'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE' | 'VIEW_QUESTS' + > = ['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE'], ): NpcInteraction { return { npc: { @@ -159,10 +159,49 @@ function createApi( silverBalance: 88, }), ), + getQuests: vi.fn(() => of([questView()])), + acceptQuest: vi.fn(() => of(questResult())), + advanceQuest: vi.fn(() => of(questResult())), ...rest, }; } +function questView(overrides: Partial = {}): QuestView { + return { + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'Five pelts.', + status: 'ACTIVE', + objectives: [ + { + key: 'collect-bag', + description: 'Speak with Borin in Graufurt', + type: 'TALK_TO_NPC', + targetKey: 'borin-quartermaster', + required: 1, + current: 0, + completed: false, + }, + ], + currentObjectiveKey: 'collect-bag', + hint: null, + ...overrides, + }; +} + +function questResult( + overrides: Partial = {}, +): QuestInteractionResult { + return { + quest: questView(), + npcLine: 'Take this.', + grantedBag: null, + consumedItems: [], + rewards: null, + ...overrides, + }; +} + function createStore(api: ReturnType): MerchantStore { TestBed.configureTestingModule({ providers: [{ provide: GameApiService, useValue: api }], @@ -454,4 +493,137 @@ describe('MerchantStore', () => { expect(store.newlyUnlocked()).toEqual([]); }); + + it('reads the quest log only when the NPC offers it', async () => { + const withoutQuests = createApi(); + await createStore(withoutQuests).load('borin-quartermaster'); + expect(withoutQuests.getQuests).not.toHaveBeenCalled(); + TestBed.resetTestingModule(); + + const withQuests = createApi({ + getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))), + }); + const store = createStore(withQuests); + await store.load('borin-quartermaster'); + + expect(withQuests.getQuests).toHaveBeenCalled(); + expect(store.quests()).toHaveLength(1); + }); + + it('accepts a quest and re-reads the screen', async () => { + const api = createApi({ + getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))), + }); + const store = createStore(api); + await store.load('borin-quartermaster'); + + await store.acceptQuest('trouble-beyond-the-gate'); + + expect(api.acceptQuest).toHaveBeenCalledWith( + 'borin-quartermaster', + 'trouble-beyond-the-gate', + ); + // The step can change what this person says, so the interaction is re-read. + expect(api.getNpcInteraction).toHaveBeenCalledTimes(2); + }); + + it('advances a step and re-reads the shop and capacities with it', async () => { + // One step can set the referral flag, unlock the Hide Bag offer and raise + // HIDE capacity from 1 to 5 at once. Patching locally would miss two of + // the three. + const api = createApi({ + getNpcInteraction: vi.fn(() => + of(interaction(['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE', 'VIEW_QUESTS'])), + ), + }); + const store = createStore(api); + await store.load('borin-quartermaster'); + + await store.advanceQuest('trouble-beyond-the-gate'); + + expect(api.advanceQuest).toHaveBeenCalledWith( + 'borin-quartermaster', + 'trouble-beyond-the-gate', + ); + expect(api.getShop).toHaveBeenCalledTimes(2); + expect(api.getTradeIn).toHaveBeenCalledTimes(2); + expect(api.getCharacter).toHaveBeenCalled(); + }); + + it('surfaces the line the step returned', async () => { + const api = createApi({ + getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))), + }); + const store = createStore(api); + await store.load('borin-quartermaster'); + + await store.advanceQuest('trouble-beyond-the-gate'); + + expect(store.questLine()).toBe('Take this.'); + }); + + it('holds the granted bag until it is dismissed', async () => { + const api = createApi({ + getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))), + advanceQuest: vi.fn(() => + of( + questResult({ + grantedBag: { + key: 'basic-hide-bag', + name: 'Basic Hide Bag', + lootCategory: 'HIDE', + capacity: 5, + }, + }), + ), + ), + }); + const store = createStore(api); + await store.load('borin-quartermaster'); + + await store.advanceQuest('trouble-beyond-the-gate'); + expect(store.grantedBag()?.name).toBe('Basic Hide Bag'); + + store.dismissGrantedBag(); + expect(store.grantedBag()).toBeNull(); + }); + + it('maps a quest error code to something the player can read', async () => { + const api = createApi({ + getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))), + advanceQuest: vi.fn(() => + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { code: 'QUEST_STEP_NOT_HERE' }, + }), + ), + ), + }); + const store = createStore(api); + await store.load('borin-quartermaster'); + + await store.advanceQuest('trouble-beyond-the-gate'); + + expect(store.actionError()).toBe( + 'This is not what the quest needs from you right now.', + ); + }); + + it('ignores a second click while a step is still running', async () => { + const api = createApi({ + getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))), + }); + const store = createStore(api); + await store.load('borin-quartermaster'); + + await Promise.all([ + store.advanceQuest('trouble-beyond-the-gate'), + store.advanceQuest('trouble-beyond-the-gate'), + ]); + + // Turning in twice would try to consume the pelts twice. + expect(api.advanceQuest).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/src/app/features/npc/merchant.store.ts b/apps/web/src/app/features/npc/merchant.store.ts index 77be705..1893f19 100644 --- a/apps/web/src/app/features/npc/merchant.store.ts +++ b/apps/web/src/app/features/npc/merchant.store.ts @@ -1,14 +1,18 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, computed, inject, signal } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; +import { Observable, firstValueFrom } from 'rxjs'; import { ExchangeResult, ExchangeView, + GrantedLootBag, NpcInteraction, + QuestInteractionResult, + QuestView, ShopPurchaseResult, ShopView, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; +import { QuestStore } from '../quests/quest.store'; import { WorldStore } from '../world/world.store'; const GENERIC_ERROR = "That isn't possible right now."; @@ -33,9 +37,15 @@ const ERROR_MESSAGES: Readonly> = { MERCHANT_REPUTATION_TOO_LOW: 'You have not earned enough standing for this yet.', SHOP_BAG_ALREADY_OWNED: 'You already carry that.', CHARACTER_NOT_FOUND: 'Your character could not be found.', + QUEST_NOT_FOUND: 'This quest could not be found.', + QUEST_NOT_OFFERED_HERE: 'This person has nothing to ask of you.', + QUEST_ALREADY_ACCEPTED: 'You have already taken this on.', + QUEST_NOT_ACTIVE: 'You are not on this quest.', + QUEST_STEP_NOT_HERE: 'This is not what the quest needs from you right now.', + QUEST_OBJECTIVE_INCOMPLETE: 'You do not have what this step needs yet.', }; -export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP'; +export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP' | 'QUESTS'; /** * State for one merchant screen (Playable Slice 0.8). @@ -49,6 +59,7 @@ export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP'; export class MerchantStore { private readonly api = inject(GameApiService); private readonly worldStore = inject(WorldStore); + private readonly questStore = inject(QuestStore); private readonly interactionState = signal(null); private readonly exchangeState = signal(null); @@ -62,6 +73,9 @@ export class MerchantStore { private readonly lastPurchaseState = signal(null); private readonly selectionState = signal>({}); private readonly newlyUnlockedState = signal([]); + private readonly questsState = signal([]); + private readonly questLineState = signal(null); + private readonly grantedBagState = signal(null); readonly interaction = this.interactionState.asReadonly(); readonly exchange = this.exchangeState.asReadonly(); @@ -75,6 +89,12 @@ export class MerchantStore { readonly lastPurchase = this.lastPurchaseState.asReadonly(); readonly selection = this.selectionState.asReadonly(); readonly newlyUnlocked = this.newlyUnlockedState.asReadonly(); + /** The quests this NPC is involved in, as the server reported them. */ + readonly quests = this.questsState.asReadonly(); + /** What the NPC said for the step just performed (slice §5, §6, §8). */ + readonly questLine = this.questLineState.asReadonly(); + /** The bag a step just handed over, until it is dismissed (slice §12). */ + readonly grantedBag = this.grantedBagState.asReadonly(); /** True once anything is selected, so the trade button can enable. */ readonly hasSelection = computed(() => @@ -115,6 +135,9 @@ export class MerchantStore { this.lastPurchaseState.set(null); this.selectionState.set({}); this.newlyUnlockedState.set([]); + this.questsState.set([]); + this.questLineState.set(null); + this.grantedBagState.set(null); this.panelState.set('DIALOGUE'); try { @@ -137,16 +160,102 @@ export class MerchantStore { ? await firstValueFrom(this.api.getShop(npcKey)) : null, ); + this.questsState.set( + actions.includes('VIEW_QUESTS') + ? await firstValueFrom(this.api.getQuests()) + : [], + ); } catch (error) { this.interactionState.set(null); this.exchangeState.set(null); this.shopState.set(null); + this.questsState.set([]); this.errorState.set(this.toMessage(error)); } finally { this.loadingState.set(false); } } + /** Takes a quest on and shows what changed (slice §3). */ + async acceptQuest(questKey: string): Promise { + await this.runQuestStep(questKey, (npcKey) => + this.api.acceptQuest(npcKey, questKey), + ); + } + + /** Performs whatever step this NPC is owed (slice §5, §6, §8). */ + async advanceQuest(questKey: string): Promise { + await this.runQuestStep(questKey, (npcKey) => + this.api.advanceQuest(npcKey, questKey), + ); + } + + /** + * One shape for both quest calls, because both change the same things. + * + * A single step can rewrite the NPC's dialogue, unlock a shop offer through + * the referral flag and raise HIDE capacity from 1 to 5 all at once, so the + * screen is re-read rather than patched locally -- the server is the only + * place that knows all of it. + */ + private async runQuestStep( + questKey: string, + call: (npcKey: string) => Observable, + ): Promise { + const npcKey = this.interactionState()?.npc.key; + if (!npcKey || this.pendingState() !== null) { + return; + } + + this.pendingState.set(questKey); + this.actionErrorState.set(null); + this.newlyUnlockedState.set([]); + + try { + const result = await firstValueFrom(call(npcKey)); + + this.questLineState.set(result.npcLine); + if (result.grantedBag) { + this.grantedBagState.set(result.grantedBag); + } + this.questStore.setQuest(result.quest); + + const interaction = await firstValueFrom( + this.api.getNpcInteraction(npcKey), + ); + this.interactionState.set(interaction); + + const actions = interaction.availableActions.map((action) => action.type); + this.questsState.set( + actions.includes('VIEW_QUESTS') + ? await firstValueFrom(this.api.getQuests()) + : [], + ); + this.exchangeState.set( + actions.includes('OPEN_EXCHANGE') + ? await firstValueFrom(this.api.getTradeIn(npcKey)) + : null, + ); + this.shopState.set( + actions.includes('OPEN_SHOP') + ? await firstValueFrom(this.api.getShop(npcKey)) + : null, + ); + + // Reputation and Silver both change on turn-in, and the HUD reads them + // from the shared character state. + await this.worldStore.refreshCharacter(); + } catch (error) { + this.actionErrorState.set(this.toMessage(error)); + } finally { + this.pendingState.set(null); + } + } + + dismissGrantedBag(): void { + this.grantedBagState.set(null); + } + showPanel(panel: MerchantPanel): void { this.panelState.set(panel); this.actionErrorState.set(null); diff --git a/apps/web/src/app/features/quests/quest-objective-line.component.html b/apps/web/src/app/features/quests/quest-objective-line.component.html new file mode 100644 index 0000000..9fd7d73 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-objective-line.component.html @@ -0,0 +1,14 @@ +

+ {{ objective.description }} + @if (showsProgress) { + {{ objective.current }} / {{ objective.required }} + } +

+@if (hint) { +

{{ hint }}

+} diff --git a/apps/web/src/app/features/quests/quest-objective-line.component.scss b/apps/web/src/app/features/quests/quest-objective-line.component.scss new file mode 100644 index 0000000..966ff99 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-objective-line.component.scss @@ -0,0 +1,43 @@ +:host { + display: block; +} + +/* Description left, tally right, so a column of objectives reads as a list of + counts rather than a paragraph. */ +.quest-objective { + display: flex; + gap: var(--ar-space-3); + align-items: baseline; + justify-content: space-between; + margin: 0; + color: var(--ar-text); + font-size: 0.95rem; +} + +/* Done steps stay visible but stop competing for attention. */ +.quest-objective--done { + color: var(--ar-text-muted); + text-decoration: line-through; + text-decoration-color: rgb(155 122 66 / 0.5); +} + +.quest-objective__progress { + color: var(--ar-gold); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.quest-objective--done .quest-objective__progress { + color: var(--ar-text-muted); +} + +/* The line that keeps "1 / 5" from reading as a dead end (slice §4). Warning + amber rather than danger red: the player is not in trouble, just stuck. */ +.quest-objective__hint { + margin: var(--ar-space-2) 0 0; + padding-inline-start: var(--ar-space-3); + border-inline-start: 2px solid var(--ar-warning); + color: var(--ar-warning); + font-size: var(--ar-font-sm); + line-height: 1.5; +} diff --git a/apps/web/src/app/features/quests/quest-objective-line.component.ts b/apps/web/src/app/features/quests/quest-objective-line.component.ts new file mode 100644 index 0000000..7a529b5 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-objective-line.component.ts @@ -0,0 +1,31 @@ +import { Component, Input } from '@angular/core'; +import { QuestObjectiveView } from '../../core/api/game-api.models'; + +/** + * One objective, exactly as Slice 0.9 §12 prints it: + * + * ```text + * Collect Ashen Pelts 1 / 5 + * + * You cannot carry enough pelts. Return to the South Gate Warden. + * ``` + * + * Shared by the journal and the NPC screen so the two can never word the same + * step differently (AGENTS.md §20). Purely presentational -- it renders what it + * is handed and decides nothing. + */ +@Component({ + selector: 'app-quest-objective-line', + templateUrl: './quest-objective-line.component.html', + styleUrl: './quest-objective-line.component.scss', +}) +export class QuestObjectiveLineComponent { + @Input({ required: true }) objective!: QuestObjectiveView; + /** Shown only when the server says this step cannot progress right now. */ + @Input() hint: string | null = null; + + /** Talk steps have nothing to count, so they show no tally. */ + protected get showsProgress(): boolean { + return this.objective.type === 'COLLECT_ITEM'; + } +} diff --git a/apps/web/src/app/features/quests/quest-page.component.html b/apps/web/src/app/features/quests/quest-page.component.html new file mode 100644 index 0000000..a7a1cc0 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-page.component.html @@ -0,0 +1,52 @@ +
+

Quests

+ + @if (store.error(); as error) { + + } + +
+

On the Road

+ + @for (quest of store.activeQuests(); track quest.key) { +
+

{{ quest.title }}

+

{{ quest.description }}

+ @if (currentObjective(quest); as objective) { + + } +
+ } @empty { +

You have taken nothing on.

+ } +
+ + @if (store.availableQuests().length > 0) { +
+

Waiting to be Asked

+ + @for (quest of store.availableQuests(); track quest.key) { +
+

{{ quest.title }}

+

{{ quest.description }}

+
+ } +
+ } + + @if (store.completedQuests().length > 0) { +
+

Behind You

+ + @for (quest of store.completedQuests(); track quest.key) { +
+

{{ quest.title }}

+

Completed.

+
+ } +
+ } +
diff --git a/apps/web/src/app/features/quests/quest-page.component.scss b/apps/web/src/app/features/quests/quest-page.component.scss new file mode 100644 index 0000000..5dee1fa --- /dev/null +++ b/apps/web/src/app/features/quests/quest-page.component.scss @@ -0,0 +1,71 @@ +:host { + display: block; +} + +/* A reading column, not a dashboard grid: the journal is prose with counts + (AGENTS.md §19). */ +.quest-page { + display: flex; + flex-direction: column; + gap: var(--ar-space-5); + max-inline-size: 48rem; +} + +.quest-page__title { + margin: 0; + color: var(--ar-text); + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(1.5rem, 2.6vw, 2rem); + font-weight: 400; + letter-spacing: 0.04em; + text-shadow: 0 0.1rem 0.6rem rgb(0 0 0 / 0.8); +} + +.quest-page__panel { + display: flex; + flex-direction: column; + gap: var(--ar-space-4); +} + +.quest-page__notice { + display: flex; + gap: var(--ar-space-3); + align-items: center; + margin: 0; + color: var(--ar-danger); + font-size: var(--ar-font-sm); +} + +.quest-page__empty { + margin: 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + font-style: italic; +} + +/* Separated by a hairline rather than a card each: several quests are one + list, not a stack of tiles. */ +.quest + .quest { + padding-block-start: var(--ar-space-4); + border-block-start: 1px solid rgb(85 74 57 / 0.5); +} + +.quest__title { + margin: 0 0 var(--ar-space-1); + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.1rem; + font-weight: 400; + letter-spacing: 0.03em; +} + +.quest__description { + margin: 0 0 var(--ar-space-3); + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + line-height: 1.6; +} + +.quest--muted .quest__title { + color: var(--ar-text-muted); +} diff --git a/apps/web/src/app/features/quests/quest-page.component.spec.ts b/apps/web/src/app/features/quests/quest-page.component.spec.ts new file mode 100644 index 0000000..11cd47e --- /dev/null +++ b/apps/web/src/app/features/quests/quest-page.component.spec.ts @@ -0,0 +1,139 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import type { QuestView } from '../../core/api/game-api.models'; +import { QuestPageComponent } from './quest-page.component'; +import { QuestStore } from './quest.store'; + +function quest(over: Partial = {}): QuestView { + return { + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'The warden wants five Ashen Pelts.', + status: 'ACTIVE', + objectives: [ + { + key: 'collect-pelts-first', + description: 'Collect Ashen Pelts', + type: 'COLLECT_ITEM', + targetKey: 'ash-pelt', + required: 5, + current: 1, + completed: false, + }, + ], + currentObjectiveKey: 'collect-pelts-first', + hint: null, + ...over, + }; +} + +async function setup(quests: QuestView[], error: string | null = null) { + const questsSignal = signal(quests); + const store = { + quests: questsSignal.asReadonly(), + activeQuests: signal(quests.filter((entry) => entry.status === 'ACTIVE')), + completedQuests: signal( + quests.filter((entry) => entry.status === 'COMPLETED'), + ), + availableQuests: signal( + quests.filter((entry) => entry.status === 'AVAILABLE'), + ), + loading: signal(false), + error: signal(error), + load: vi.fn().mockResolvedValue(undefined), + setQuest: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [QuestPageComponent], + providers: [{ provide: QuestStore, useValue: store }], + }).compileComponents(); + + const fixture = TestBed.createComponent(QuestPageComponent); + fixture.detectChanges(); + return { fixture, store, text: () => fixture.nativeElement.textContent ?? '' }; +} + +describe('QuestPageComponent', () => { + it('loads the quest log on init', async () => { + const { store } = await setup([quest()]); + + expect(store.load).toHaveBeenCalledOnce(); + }); + + it('renders the active quest with its current objective', async () => { + const { fixture, text } = await setup([quest()]); + + expect(text()).toContain('Trouble Beyond the Gate'); + expect(text()).toContain('The warden wants five Ashen Pelts.'); + expect( + fixture.nativeElement.querySelector('[data-objective-description]') + .textContent, + ).toContain('Collect Ashen Pelts'); + }); + + it('renders collect progress as current over required', async () => { + const { fixture } = await setup([quest()]); + + // The block slice §12 prints verbatim. + expect( + fixture.nativeElement + .querySelector('[data-objective-progress]') + .textContent.replace(/\s+/g, ' ') + .trim(), + ).toBe('1 / 5'); + }); + + it('renders the blocked hint under the objective', async () => { + const { fixture } = await setup([ + quest({ + hint: 'You cannot carry enough pelts. Return to the South Gate Warden.', + }), + ]); + + expect( + fixture.nativeElement.querySelector('[data-objective-hint]').textContent, + ).toContain('You cannot carry enough pelts.'); + }); + + it('shows no hint when the step is merely unfinished', async () => { + const { fixture } = await setup([quest()]); + + expect( + fixture.nativeElement.querySelector('[data-objective-hint]'), + ).toBeNull(); + }); + + it('shows an empty state when nothing is taken on', async () => { + const { text } = await setup([]); + + expect(text()).toContain('You have taken nothing on.'); + }); + + it('lists completed quests separately from active ones', async () => { + const { fixture, text } = await setup([ + quest({ + key: 'done-quest', + title: 'An Older Errand', + status: 'COMPLETED', + currentObjectiveKey: null, + }), + ]); + + expect(text()).toContain('An Older Errand'); + expect( + fixture.nativeElement.querySelector('[data-quest-completed]'), + ).not.toBeNull(); + // The active panel is still empty, so the two do not blur together. + expect(text()).toContain('You have taken nothing on.'); + }); + + it('offers a retry when the log could not be read', async () => { + const { fixture, store } = await setup([], 'Could not load your quests.'); + + fixture.nativeElement.querySelector('[data-quest-retry]').click(); + + expect(store.load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/features/quests/quest-page.component.ts b/apps/web/src/app/features/quests/quest-page.component.ts new file mode 100644 index 0000000..5828b61 --- /dev/null +++ b/apps/web/src/app/features/quests/quest-page.component.ts @@ -0,0 +1,38 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { QuestView } from '../../core/api/game-api.models'; +import { QuestObjectiveLineComponent } from './quest-objective-line.component'; +import { QuestStore } from './quest.store'; + +/** + * The quest journal (Playable Slice 0.9 §12). + * + * Deliberately small: §15 rules out a large journal taxonomy, so this is one + * list in three states -- what you are doing, what is on offer, what is behind + * you -- and nothing else. + */ +@Component({ + selector: 'app-quest-page', + imports: [QuestObjectiveLineComponent], + templateUrl: './quest-page.component.html', + styleUrl: './quest-page.component.scss', +}) +export class QuestPageComponent implements OnInit { + protected readonly store = inject(QuestStore); + + ngOnInit(): void { + void this.store.load(); + } + + /** The step the player is on, or null for a quest that is not started. */ + protected currentObjective(quest: QuestView) { + return ( + quest.objectives.find( + (objective) => objective.key === quest.currentObjectiveKey, + ) ?? null + ); + } + + protected retry(): void { + void this.store.load(); + } +} diff --git a/apps/web/src/app/features/quests/quest.store.spec.ts b/apps/web/src/app/features/quests/quest.store.spec.ts new file mode 100644 index 0000000..c7b7f36 --- /dev/null +++ b/apps/web/src/app/features/quests/quest.store.spec.ts @@ -0,0 +1,108 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import type { QuestView } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { QuestStore } from './quest.store'; + +function quest(over: Partial = {}): QuestView { + return { + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'Five pelts.', + status: 'ACTIVE', + objectives: [ + { + key: 'collect-pelts-first', + description: 'Collect Ashen Pelts', + type: 'COLLECT_ITEM', + targetKey: 'ash-pelt', + required: 5, + current: 1, + completed: false, + }, + ], + currentObjectiveKey: 'collect-pelts-first', + hint: null, + ...over, + }; +} + +function createStore(getQuests: ReturnType): QuestStore { + TestBed.configureTestingModule({ + providers: [{ provide: GameApiService, useValue: { getQuests } }], + }); + return TestBed.inject(QuestStore); +} + +describe('QuestStore', () => { + it('loads the quest log', async () => { + const store = createStore(vi.fn().mockReturnValue(of([quest()]))); + + await store.load(); + + expect(store.quests()).toHaveLength(1); + expect(store.error()).toBeNull(); + expect(store.loading()).toBe(false); + }); + + it('splits the log by status', async () => { + const store = createStore( + vi.fn().mockReturnValue( + of([ + quest({ key: 'active-quest', status: 'ACTIVE' }), + quest({ key: 'done-quest', status: 'COMPLETED' }), + quest({ key: 'offered-quest', status: 'AVAILABLE' }), + ]), + ), + ); + + await store.load(); + + expect(store.activeQuests().map((entry) => entry.key)).toEqual([ + 'active-quest', + ]); + expect(store.completedQuests().map((entry) => entry.key)).toEqual([ + 'done-quest', + ]); + expect(store.availableQuests().map((entry) => entry.key)).toEqual([ + 'offered-quest', + ]); + }); + + it('keeps the previous log when a reload fails', async () => { + const getQuests = vi.fn().mockReturnValue(of([quest()])); + const store = createStore(getQuests); + await store.load(); + + getQuests.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500 })), + ); + await store.load(); + + // A stale journal beats a blank one; the next load resyncs. + expect(store.quests()).toHaveLength(1); + expect(store.error()).toBe('Could not load your quests.'); + }); + + it('replaces a quest in place when the NPC screen pushes an update', async () => { + const store = createStore(vi.fn().mockReturnValue(of([quest()]))); + await store.load(); + + store.setQuest(quest({ status: 'COMPLETED', currentObjectiveKey: null })); + + expect(store.quests()).toHaveLength(1); + expect(store.completedQuests()).toHaveLength(1); + }); + + it('adds a quest the log had never seen', async () => { + // Accepting a quest at an NPC before ever opening the journal. + const store = createStore(vi.fn().mockReturnValue(of([]))); + await store.load(); + + store.setQuest(quest()); + + expect(store.activeQuests()).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/features/quests/quest.store.ts b/apps/web/src/app/features/quests/quest.store.ts new file mode 100644 index 0000000..1eba554 --- /dev/null +++ b/apps/web/src/app/features/quests/quest.store.ts @@ -0,0 +1,83 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { QuestView } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; + +const GENERIC_ERROR = 'Could not load your quests.'; + +/** + * The quest log (Playable Slice 0.9 §12). + * + * Holds no derived progress of its own: which step is current, how far along it + * is and whether it is blocked all arrive decided from the server + * (AGENTS.md §22). The store's only job is to keep the newest answer. + */ +@Injectable({ providedIn: 'root' }) +export class QuestStore { + private readonly api = inject(GameApiService); + + private readonly questsState = signal([]); + private readonly loadingState = signal(false); + private readonly errorState = signal(null); + + readonly quests = this.questsState.asReadonly(); + readonly loading = this.loadingState.asReadonly(); + readonly error = this.errorState.asReadonly(); + + readonly activeQuests = computed(() => + this.questsState().filter((quest) => quest.status === 'ACTIVE'), + ); + readonly completedQuests = computed(() => + this.questsState().filter((quest) => quest.status === 'COMPLETED'), + ); + readonly availableQuests = computed(() => + this.questsState().filter((quest) => quest.status === 'AVAILABLE'), + ); + + async load(): Promise { + this.loadingState.set(true); + this.errorState.set(null); + + try { + this.questsState.set(await firstValueFrom(this.api.getQuests())); + } catch (error) { + // The previous log stays on screen. A failed refresh is a worse reason to + // blank the journal than it is to show a slightly stale one. + this.errorState.set(this.toMessage(error)); + } finally { + this.loadingState.set(false); + } + } + + /** + * Folds a quest the NPC screen just changed back into the log. + * + * The step endpoint already returns the updated quest, so re-reading the + * whole log for it would be a round trip that answers a question the client + * already has. + */ + setQuest(quest: QuestView): void { + this.questsState.update((current) => { + const index = current.findIndex( + (candidate) => candidate.key === quest.key, + ); + if (index === -1) { + return [...current, quest]; + } + const next = [...current]; + next[index] = quest; + return next; + }); + } + + private toMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + const code = (error.error as { code?: string } | null)?.code; + if (code === 'CHARACTER_NOT_FOUND') { + return 'Your character could not be found.'; + } + } + return GENERIC_ERROR; + } +} diff --git a/apps/web/src/app/features/world/local-location.store.ts b/apps/web/src/app/features/world/local-location.store.ts index a47e999..7aea055 100644 --- a/apps/web/src/app/features/world/local-location.store.ts +++ b/apps/web/src/app/features/world/local-location.store.ts @@ -38,6 +38,16 @@ export class LocalLocationStore { readonly loading = this.worldStore.loading; readonly error = this.worldStore.error; + /** + * Quest and merchant markers for the NPC behind a hotspot (Slice 0.9 §12). + * + * Passed straight through from `WorldStore`, which reads them alongside the + * location, for the same reason the location itself is: two fetch paths for + * one screen can disagree about where the character is standing. + */ + readonly markersFor = (npcKey: string | undefined) => + this.worldStore.markersFor(npcKey); + readonly interactionResult = this.interactionResultState.asReadonly(); readonly interactionError = this.interactionErrorState.asReadonly(); /** Key of the interaction currently in flight, so only that control busies. */ diff --git a/apps/web/src/app/features/world/location-page/location-page.component.html b/apps/web/src/app/features/world/location-page/location-page.component.html index 0a0b9e4..b85fd8d 100644 --- a/apps/web/src/app/features/world/location-page/location-page.component.html +++ b/apps/web/src/app/features/world/location-page/location-page.component.html @@ -24,6 +24,7 @@ } diff --git a/apps/web/src/app/features/world/location-page/location-page.component.spec.ts b/apps/web/src/app/features/world/location-page/location-page.component.spec.ts index 102e0be..a3f6169 100644 --- a/apps/web/src/app/features/world/location-page/location-page.component.spec.ts +++ b/apps/web/src/app/features/world/location-page/location-page.component.spec.ts @@ -5,6 +5,7 @@ import { vi } from 'vitest'; import type { CurrentLocationResponse, LocationInteractionResult, + NpcMarker, } from '../../../core/api/game-api.models'; import { burnedRoadFixture, southGateFixture } from '../current-location.fixture'; import { LocalLocationStore } from '../local-location.store'; @@ -26,9 +27,16 @@ describe('LocationPageComponent', () => { load: ReturnType; runInteraction: ReturnType; closeInteraction: ReturnType; + markersFor: ReturnType; }; + /** Markers by NPC key, as the server would have decided them. */ + let markers: Record; - async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) { + async function setup( + current: CurrentLocationResponse | null = burnedRoadFixture(), + npcMarkers: Record = {}, + ) { + markers = npcMarkers; location = signal(current); error = signal(null); interactionResult = signal(null); @@ -44,6 +52,7 @@ describe('LocationPageComponent', () => { load: vi.fn().mockResolvedValue(undefined), runInteraction: vi.fn().mockResolvedValue(undefined), closeInteraction: vi.fn(), + markersFor: vi.fn((npcKey?: string) => markers[npcKey ?? ''] ?? []), }; await TestBed.configureTestingModule({ @@ -238,20 +247,43 @@ describe('LocationPageComponent', () => { expect(element.querySelectorAll('app-location-poi')).toHaveLength(1); expect(element.querySelectorAll('[data-action]')).toHaveLength(1); }); + + it('badges the hotspot of an NPC with something to ask', async () => { + const { element } = await setup(southGateFixture(southGateContent()), { + 'south-gate-warden': ['QUEST_AVAILABLE'], + }); + + expect( + element.querySelector('[data-poi-badge]')?.textContent?.trim(), + ).toBe('!'); + }); + + it('leaves a hotspot that names no NPC unbadged', async () => { + const { element } = await setup( + southGateFixture(southGateContent({ withNpcKey: false })), + { 'south-gate-warden': ['QUEST_AVAILABLE'] }, + ); + + expect(element.querySelector('[data-poi-badge]')).toBeNull(); + expect(store.markersFor).toHaveBeenCalledWith(undefined); + }); }); -function southGateContent(): Partial { +function southGateContent({ + withNpcKey = true, +}: { withNpcKey?: boolean } = {}): Partial { return { pointsOfInterest: [ { key: 'gate-watch', - title: 'Gate Watch', + title: 'Halvik, Warden of the South Gate', actionLabel: 'Talk', type: 'NPC', iconKey: 'speak', xPercent: 45, yPercent: 52, enabled: true, + ...(withNpcKey ? { npcKey: 'south-gate-warden' } : {}), }, ], primaryActions: [ diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.html b/apps/web/src/app/features/world/location-poi/location-poi.component.html index 721530d..76387c3 100644 --- a/apps/web/src/app/features/world/location-poi/location-poi.component.html +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.html @@ -4,11 +4,16 @@ [class.location-poi--busy]="busy" [disabled]="!poi.enabled || busy" [attr.data-poi]="poi.key" - [attr.aria-label]="poi.actionLabel ? poi.title + ': ' + poi.actionLabel : poi.title" + [attr.aria-label]="accessibleLabel" (click)="onActivate()" > + @if (badge; as questBadge) { + + } {{ poi.title }} @if (poi.actionLabel) { diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.scss b/apps/web/src/app/features/world/location-poi/location-poi.component.scss index b19d4cf..ab539d2 100644 --- a/apps/web/src/app/features/world/location-poi/location-poi.component.scss +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.scss @@ -23,6 +23,7 @@ } .location-poi__medallion { + position: relative; display: grid; place-items: center; inline-size: 2.6rem; @@ -39,6 +40,26 @@ 0 0 0.85rem rgb(201 164 95 / 0.28); } +/* A small forged disc on the medallion's shoulder. Same gold as the frame, so + it reads as part of the marker rather than a notification dot. */ +.location-poi__badge { + position: absolute; + inset-block-start: -0.3rem; + inset-inline-end: -0.3rem; + display: grid; + place-items: center; + inline-size: 1.15rem; + block-size: 1.15rem; + border: 0.1rem solid var(--ar-gold); + border-radius: 50%; + color: #14171a; + background: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 0.72rem; + line-height: 1; + box-shadow: 0 0 0.5rem rgb(201 164 95 / 0.5); +} + .location-poi__title { max-inline-size: 11rem; font-family: Georgia, 'Times New Roman', serif; diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts b/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts index ac059c2..32c144b 100644 --- a/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import type { LocationPointOfInterest } from '../../../core/api/game-api.models'; +import type { + LocationPointOfInterest, + NpcMarker, +} from '../../../core/api/game-api.models'; import { LocationPoiComponent } from './location-poi.component'; const wagon: LocationPointOfInterest = { @@ -16,6 +19,7 @@ const wagon: LocationPointOfInterest = { async function setup( poi: LocationPointOfInterest = wagon, busy = false, + markers: NpcMarker[] = [], ): Promise<{ fixture: ComponentFixture; activated: LocationPointOfInterest[]; @@ -28,6 +32,7 @@ async function setup( const fixture = TestBed.createComponent(LocationPoiComponent); fixture.componentRef.setInput('poi', poi); fixture.componentRef.setInput('busy', busy); + fixture.componentRef.setInput('markers', markers); const activated: LocationPointOfInterest[] = []; fixture.componentInstance.activate.subscribe((value) => activated.push(value)); @@ -113,4 +118,48 @@ describe('LocationPoiComponent', () => { expect(button.getAttribute('aria-label')).toBe('Abandoned Wagon'); }); + + it('renders no badge without markers', async () => { + const { fixture } = await setup(); + + expect(fixture.nativeElement.querySelector('[data-poi-badge]')).toBeNull(); + }); + + it('renders a badge for a quest that can be started here', async () => { + const { fixture, button } = await setup(wagon, false, ['QUEST_AVAILABLE']); + + expect( + fixture.nativeElement.querySelector('[data-poi-badge]').textContent.trim(), + ).toBe('!'); + expect(button.getAttribute('aria-label')).toBe( + 'Abandoned Wagon: Search, quest available', + ); + }); + + it('renders a badge when the current step waits here', async () => { + const { fixture, button } = await setup(wagon, false, ['QUEST_TURN_IN']); + + expect( + fixture.nativeElement.querySelector('[data-poi-badge]').textContent.trim(), + ).toBe('?'); + expect(button.getAttribute('aria-label')).toContain('quest step ready'); + }); + + it('ignores markers that are not quest markers', async () => { + const { fixture } = await setup(wagon, false, ['MERCHANT', 'EXCHANGE']); + + expect(fixture.nativeElement.querySelector('[data-poi-badge]')).toBeNull(); + }); + + it('renders one badge at most, preferring the waiting step', async () => { + const { fixture } = await setup(wagon, false, [ + 'MERCHANT', + 'QUEST_AVAILABLE', + 'QUEST_TURN_IN', + ]); + + const badges = fixture.nativeElement.querySelectorAll('[data-poi-badge]'); + expect(badges).toHaveLength(1); + expect(badges[0].textContent.trim()).toBe('?'); + }); }); diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.ts b/apps/web/src/app/features/world/location-poi/location-poi.component.ts index 1cecbf8..368189d 100644 --- a/apps/web/src/app/features/world/location-poi/location-poi.component.ts +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.ts @@ -1,7 +1,31 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { LocationPointOfInterest } from '../../../core/api/game-api.models'; +import { + LocationPointOfInterest, + NpcMarker, +} from '../../../core/api/game-api.models'; import { LocationIconComponent } from '../location-icon/location-icon.component'; +/** + * What each quest marker looks like on a hotspot (Slice 0.9 §12). + * + * Glyph plus a spoken suffix, because a badge that only exists as a colour is + * not a marker for anyone using a screen reader. + */ +const QUEST_BADGES: Readonly< + Partial> +> = { + QUEST_AVAILABLE: { glyph: '!', label: 'quest available' }, + QUEST_TURN_IN: { glyph: '?', label: 'quest step ready' }, + QUEST_IN_PROGRESS: { glyph: '·', label: 'quest in progress' }, +}; + +/** Most useful first, matching the precedence the server already applies. */ +const BADGE_ORDER: readonly NpcMarker[] = [ + 'QUEST_TURN_IN', + 'QUEST_AVAILABLE', + 'QUEST_IN_PROGRESS', +]; + /** * One hotspot pinned to the location artwork. * @@ -24,8 +48,30 @@ import { LocationIconComponent } from '../location-icon/location-icon.component' export class LocationPoiComponent { @Input({ required: true }) poi!: LocationPointOfInterest; @Input() busy = false; + /** Markers for the NPC behind this hotspot, if it has one. */ + @Input() markers: NpcMarker[] = []; @Output() readonly activate = new EventEmitter(); + /** + * The single badge this hotspot shows, or null. + * + * One at most: a portrait wearing three symbols tells the player nothing. + */ + protected get badge(): { glyph: string; label: string } | null { + const marker = BADGE_ORDER.find((candidate) => + this.markers.includes(candidate), + ); + return marker ? (QUEST_BADGES[marker] ?? null) : null; + } + + protected get accessibleLabel(): string { + const base = this.poi.actionLabel + ? `${this.poi.title}: ${this.poi.actionLabel}` + : this.poi.title; + const badge = this.badge; + return badge ? `${base}, ${badge.label}` : base; + } + protected onActivate(): void { if (this.poi.enabled && !this.busy) { this.activate.emit(this.poi); diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 87d111d..4547797 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -62,6 +62,7 @@ describe('WorldStore', () => { getCurrentLocation: ReturnType; getCurrentTravel: ReturnType; startTravel: ReturnType; + getLocationNpcs: ReturnType; }; let store: WorldStore; @@ -73,6 +74,18 @@ describe('WorldStore', () => { getCurrentLocation: vi.fn(() => of(currentLocation)), getCurrentTravel: vi.fn(() => of({ status: 'IDLE' } satisfies CurrentTravel)), startTravel: vi.fn(() => of(travelling)), + getLocationNpcs: vi.fn(() => + of([ + { + id: 'npc-warden', + key: 'south-gate-warden', + name: 'Halvik', + title: 'Warden of the South Gate', + portraitPath: '/images/npcs/south-gate-warden.png', + markers: ['QUEST_AVAILABLE'], + }, + ]), + ), }; TestBed.configureTestingModule({ @@ -441,4 +454,35 @@ describe('WorldStore', () => { expect(store.displayedCharacter()?.currentHp).toBe(14); }); }); + + describe('location NPC markers', () => { + it('reads who is standing at the current location', async () => { + await store.load(); + + expect(api.getLocationNpcs).toHaveBeenCalledWith(currentLocation.id); + expect(store.markersFor('south-gate-warden')).toEqual([ + 'QUEST_AVAILABLE', + ]); + }); + + it('reports no markers for an NPC who is not here', async () => { + await store.load(); + + expect(store.markersFor('borin-quartermaster')).toEqual([]); + expect(store.markersFor(undefined)).toEqual([]); + }); + + it('still renders the location when the marker read fails', async () => { + // Markers are decoration; the screen has to come up regardless. + api.getLocationNpcs.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500 })), + ); + + await store.load(); + + expect(store.currentLocation()).toEqual(currentLocation); + expect(store.error()).toBeNull(); + expect(store.markersFor('south-gate-warden')).toEqual([]); + }); + }); }); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index 7ef224a..1f14963 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -7,6 +7,8 @@ import { CurrentLocationResponse, CurrentTravel, LocationSummary, + NpcMarker, + NpcSummary, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; @@ -30,6 +32,7 @@ export class WorldStore implements OnDestroy { private readonly currentTravelState = signal(null); private readonly remainingSecondsState = signal(null); private readonly arrivedState = signal(null); + private readonly locationNpcsState = signal([]); private readonly loadingState = signal(false); private readonly errorState = signal(null); private countdownTimer: ReturnType | undefined; @@ -48,9 +51,21 @@ export class WorldStore implements OnDestroy { readonly arrived = this.arrivedState.asReadonly(); readonly loading = this.loadingState.asReadonly(); readonly error = this.errorState.asReadonly(); + /** The people standing here, with the markers the server decided on. */ + readonly locationNpcs = this.locationNpcsState.asReadonly(); constructor(private readonly api: GameApiService) {} + /** The markers for one hotspot's NPC, or none when nobody matches. */ + markersFor(npcKey: string | undefined): NpcMarker[] { + if (!npcKey) { + return []; + } + return ( + this.locationNpcsState().find((npc) => npc.key === npcKey)?.markers ?? [] + ); + } + async load(): Promise { if (this.destroyed) { return; @@ -68,6 +83,7 @@ export class WorldStore implements OnDestroy { this.applyCharacter(character); this.currentLocationState.set(location); this.selectedConnectionState.set(null); + await this.loadLocationNpcs(location); await this.setCurrentTravel(travel); } catch (error) { if (!this.destroyed) { @@ -266,6 +282,29 @@ export class WorldStore implements OnDestroy { this.applyCharacter(character); this.currentLocationState.set(location); this.selectedConnectionState.set(null); + await this.loadLocationNpcs(location); + } + + /** + * Reads who is standing here and what they currently want (Slice 0.9 §12). + * + * A failure is swallowed on purpose: markers are decoration on a screen that + * still has to render. Blanking the location because a badge could not be + * fetched would trade a small loss for a total one. + */ + private async loadLocationNpcs( + location: CurrentLocationResponse, + ): Promise { + try { + const npcs = await firstValueFrom(this.api.getLocationNpcs(location.id)); + if (!this.destroyed) { + this.locationNpcsState.set(npcs); + } + } catch { + if (!this.destroyed) { + this.locationNpcsState.set([]); + } + } } private applyCharacter(character: CharacterResponse): void { diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.html b/apps/web/src/app/layout/side-navigation/side-navigation.component.html index a40face..ab721d1 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.html +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.html @@ -45,9 +45,12 @@