Merge branch 'slice/0.9-first-quest-and-bag-tutorial'

This commit is contained in:
Bastian Wagner
2026-08-23 09:08:43 +02:00
68 changed files with 8001 additions and 329 deletions

View File

@@ -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 {}

View File

@@ -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,
]),
],

View File

@@ -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<string, boolean | string | number> | 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 });
});
});

View File

@@ -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<ConditionEvaluation> {
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,

View File

@@ -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<GameConditionType> =
new Set([
@@ -52,6 +54,8 @@ export const SUPPORTED_CONDITION_TYPES: ReadonlySet<GameConditionType> =
GameConditionType.WORLD_RENOWN,
GameConditionType.FLAG_SET,
GameConditionType.HAS_ITEM,
GameConditionType.QUEST_ACTIVE,
GameConditionType.QUEST_COMPLETED,
]);
export function compare(

View File

@@ -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<void> {
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<void> {
// 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"`);
}
}

View File

@@ -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<string> {
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<string> {
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"');
});
});

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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,
},
];

View File

@@ -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;

View File

@@ -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<string, unknown>;
@@ -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<keyof SeedRepositories> = [
'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<SeedRepositories> = {},
): DataSource {
const repositories = Object.fromEntries(
REPOSITORY_KEYS.map((key) => [
key,
overrides[key] ?? new InMemoryRepository(),
]),
) as SeedRepositories;
const byEntity = new Map<unknown, InMemoryRepository>([
[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<Record<string, unknown>>
).find((poi) => poi.key === 'gate-watch') as Record<string, unknown>;
// 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<Record<string, unknown>>
).find((entry) => entry.poiKey === 'gate-watch') as Record<string, unknown>;
expect(action.npcKey).toBe(SOUTH_GATE_WARDEN_KEY);
});
});

View File

@@ -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

View File

@@ -12,7 +12,7 @@ export class NpcController {
getNpcsAtLocation(
@Param('locationId') locationId: string,
): Promise<NpcSummaryDto[]> {
return this.npcService.getNpcsAtLocation(locationId);
return this.npcService.getNpcsAtLocation(DEMO_CHARACTER_ID, locationId);
}
/**

View File

@@ -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<string, unknown> | 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<DialogueNode>): Partial<DialogueNode> {
@@ -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' });

View File

@@ -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<NpcSummaryDto[]> {
/**
* 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<NpcSummaryDto[]> {
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<NpcActionDto[]> {
private async resolveActions(
characterId: string,
npc: NpcDefinition,
): Promise<NpcActionDto[]> {
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<NpcMarker[]> {
private async resolveMarkers(
characterId: string,
npc: NpcDefinition,
): Promise<NpcMarker[]> {
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<NpcMarker | null> {
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.
*

View File

@@ -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;

View File

@@ -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],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 {}

View File

@@ -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<CharacterQuest> | null;
objectiveOverrides?: Array<Partial<QuestObjective>>;
disabledObjectiveKeys?: string[];
assignments?: Array<{ npcId: string; role: NpcQuestRole; enabled?: boolean }>;
}
function objective(over: Partial<QuestObjective>): 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([]);
});
});

View File

@@ -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<DataSource, 'getRepository'>;
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<QuestState[]> {
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<QuestState | null> {
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<NpcQuestState[]> {
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<string, NpcQuestRole[]>();
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<string, { quantity: number; lootCategory: LootCategory | null }>,
capacityFull: Map<LootCategory, boolean>,
): 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<string, { quantity: number; lootCategory: LootCategory | null }>,
capacityFull: Map<LootCategory, boolean>,
): 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<string, { quantity: number; lootCategory: LootCategory | null }>
> {
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,
},
]),
);
}
}

View File

@@ -0,0 +1,194 @@
import { QuestObjectiveType } from './quest.types';
import {
isObjectiveSatisfied,
ObjectiveSnapshot,
resolveCurrentObjectiveIndex,
} from './quest-state';
function collect(over: Partial<ObjectiveSnapshot> = {}): ObjectiveSnapshot {
return {
type: QuestObjectiveType.COLLECT_ITEM,
requiredQuantity: 5,
advanceWhenBlocked: false,
current: 0,
blocked: false,
...over,
};
}
function talk(over: Partial<ObjectiveSnapshot> = {}): 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,
);
});
});

View File

@@ -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;
}

View File

@@ -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<App>;
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<App>();
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',
);
});
});

View File

@@ -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<QuestDto[]> {
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<QuestInteractionResultDto> {
return this.questService.acceptQuest(DEMO_CHARACTER_ID, npcKey, questKey);
}
@Post('advance')
advanceQuest(
@Param('npcKey') npcKey: string,
@Param('questKey') questKey: string,
): Promise<QuestInteractionResultDto> {
return this.questService.advanceQuest(DEMO_CHARACTER_ID, npcKey, questKey);
}
}

View File

@@ -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.',
);
}

View File

@@ -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>): 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<string, boolean | string | number>;
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<string, NpcDefinition> = {
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<Record<string, unknown>>),
npcStates: fixture.borinFlags
? [
{
characterId: CHARACTER_ID,
npcId: BORIN_ID,
flags: { ...fixture.borinFlags },
},
]
: ([] as Array<Record<string, unknown>>),
questRows: questRow ? [questRow] : ([] as CharacterQuest[]),
savedQuestRows: [] as CharacterQuest[],
savedNpcStates: [] as Array<Record<string, unknown>>,
savedLootBags: [] as Array<Record<string, unknown>>,
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<string, unknown>) => 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<string, unknown>) => value,
save: async (value: Record<string, unknown>) => {
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<string, unknown>) => value,
save: async (value: Record<string, unknown>) => {
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 <T>(run: (m: EntityManager) => Promise<T>) => 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);
});
});

View File

@@ -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<QuestDto[]> {
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<QuestInteractionResultDto> {
// 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<QuestInteractionResultDto> {
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<QuestDefinition> {
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<GrantedLootBagDto | null> {
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<void> {
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<QuestConsumedItemDto[]> {
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<QuestRewardDto> {
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
);
}

View File

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

View File

@@ -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 {}

View File

@@ -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: () =>

View File

@@ -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<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
).toBe(true);
}
// Quests became reachable in Slice 0.9; Character is still unbuilt.
const questsButton = element.querySelector<HTMLButtonElement>(
'[data-navigation="quests"]',
);
expect(questsButton?.disabled).toBe(false);
expect(questsButton?.getAttribute('aria-label')).toBe('Quests');
expect(
element.querySelector<HTMLButtonElement>('[data-navigation="character"]')
?.disabled,
).toBe(true);
expect(element.textContent).not.toContain('Shop');
});

View File

@@ -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;
}

View File

@@ -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({});
});
});

View File

@@ -16,6 +16,8 @@ import {
ExchangeView,
NpcInteraction,
NpcSummary,
QuestInteractionResult,
QuestView,
ReputationEntry,
ShopPurchaseResult,
ShopView,
@@ -128,6 +130,40 @@ export class GameApiService {
);
}
getQuests(): Observable<QuestView[]> {
return this.http.get<QuestView[]>('/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<QuestInteractionResult> {
return this.http.post<QuestInteractionResult>(
`/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<QuestInteractionResult> {
return this.http.post<QuestInteractionResult>(
`/api/npcs/${encodeURIComponent(npcKey)}/quests/${encodeURIComponent(questKey)}/advance`,
{},
);
}
getShop(merchantKey: string): Observable<ShopView> {
return this.http.get<ShopView>(
`/api/merchants/${encodeURIComponent(merchantKey)}/shop`,

View File

@@ -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 @@
<p class="merchant__error" role="alert">{{ store.actionError() }}</p>
}
@if (store.questLine(); as line) {
<blockquote class="merchant__dialogue" data-quest-line aria-live="polite">
{{ line }}
</blockquote>
}
@if (store.grantedBag(); as bag) {
<section class="bag-granted" data-granted-bag aria-live="polite">
<h2 class="bag-granted__heading">New Loot Bag</h2>
<p class="bag-granted__name">{{ bag.name }}</p>
<p class="bag-granted__capacity">{{ capacityLabel(bag) }}</p>
<button
type="button"
class="merchant__link"
(click)="store.dismissGrantedBag()"
>
Dismiss
</button>
</section>
}
@if (isPanel('QUESTS')) {
<section class="merchant__panel" aria-label="Quests">
<h2 class="merchant__panel-title">Matters at Hand</h2>
@for (quest of store.quests(); track quest.key) {
<article class="npc-quest" [attr.data-quest]="quest.key">
<h3 class="npc-quest__title">{{ quest.title }}</h3>
<p class="npc-quest__description">{{ quest.description }}</p>
@if (currentObjective(quest); as objective) {
<app-quest-objective-line
[objective]="objective"
[hint]="quest.hint"
/>
}
@if (quest.status === 'AVAILABLE') {
<button
type="button"
class="merchant__button"
data-quest-accept
[disabled]="store.pending() !== null"
(click)="store.acceptQuest(quest.key)"
>
Accept
</button>
} @else if (quest.status === 'ACTIVE' && isCurrentStepHere(quest)) {
<button
type="button"
class="merchant__button"
data-quest-advance
[disabled]="store.pending() !== null"
(click)="store.advanceQuest(quest.key)"
>
Continue
</button>
}
</article>
} @empty {
<p class="merchant__empty">There is nothing to discuss.</p>
}
</section>
}
@if (isPanel('EXCHANGE') && store.exchange(); as exchange) {
<section class="merchant__panel" aria-label="Trade in goods">
<h2 class="merchant__panel-title">{{ exchange.profileName }}</h2>

View File

@@ -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;

View File

@@ -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> = {}): 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<QuestInteractionResult> = {},
): Promise<{
fixture: ComponentFixture<MerchantPageComponent>;
element: HTMLElement;
api: Record<string, ReturnType<typeof vi.fn>>;
}> {
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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');
});
});

View File

@@ -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;
}

View File

@@ -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> = {}): 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> = {},
): QuestInteractionResult {
return {
quest: questView(),
npcLine: 'Take this.',
grantedBag: null,
consumedItems: [],
rewards: null,
...overrides,
};
}
function createStore(api: ReturnType<typeof createApi>): 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);
});
});

View File

@@ -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<Record<string, string>> = {
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<NpcInteraction | null>(null);
private readonly exchangeState = signal<ExchangeView | null>(null);
@@ -62,6 +73,9 @@ export class MerchantStore {
private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null);
private readonly selectionState = signal<Record<string, number>>({});
private readonly newlyUnlockedState = signal<string[]>([]);
private readonly questsState = signal<QuestView[]>([]);
private readonly questLineState = signal<string | null>(null);
private readonly grantedBagState = signal<GrantedLootBag | null>(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<void> {
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<void> {
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<QuestInteractionResult>,
): Promise<void> {
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);

View File

@@ -0,0 +1,14 @@
<p class="quest-objective" [class.quest-objective--done]="objective.completed">
<span class="quest-objective__text" data-objective-description>{{ objective.description }}</span>
@if (showsProgress) {
<span
class="quest-objective__progress"
data-objective-progress
[attr.aria-label]="objective.current + ' of ' + objective.required"
>{{ objective.current }} / {{ objective.required }}</span
>
}
</p>
@if (hint) {
<p class="quest-objective__hint" data-objective-hint>{{ hint }}</p>
}

View File

@@ -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;
}

View File

@@ -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';
}
}

View File

@@ -0,0 +1,52 @@
<section class="quest-page" aria-label="Quest Journal">
<h1 class="quest-page__title">Quests</h1>
@if (store.error(); as error) {
<p class="quest-page__notice" role="alert">
{{ error }}
<button type="button" data-quest-retry (click)="retry()">Retry</button>
</p>
}
<section class="ar-panel quest-page__panel" aria-label="Active Quests">
<h2 class="ar-panel__title">On the Road</h2>
@for (quest of store.activeQuests(); track quest.key) {
<article class="quest" [attr.data-quest]="quest.key">
<h3 class="quest__title">{{ quest.title }}</h3>
<p class="quest__description">{{ quest.description }}</p>
@if (currentObjective(quest); as objective) {
<app-quest-objective-line [objective]="objective" [hint]="quest.hint" />
}
</article>
} @empty {
<p class="quest-page__empty" role="status">You have taken nothing on.</p>
}
</section>
@if (store.availableQuests().length > 0) {
<section class="ar-panel quest-page__panel" aria-label="Available Quests">
<h2 class="ar-panel__title">Waiting to be Asked</h2>
@for (quest of store.availableQuests(); track quest.key) {
<article class="quest quest--muted" [attr.data-quest]="quest.key">
<h3 class="quest__title">{{ quest.title }}</h3>
<p class="quest__description">{{ quest.description }}</p>
</article>
}
</section>
}
@if (store.completedQuests().length > 0) {
<section class="ar-panel quest-page__panel" aria-label="Completed Quests">
<h2 class="ar-panel__title">Behind You</h2>
@for (quest of store.completedQuests(); track quest.key) {
<article class="quest quest--muted" [attr.data-quest]="quest.key">
<h3 class="quest__title">{{ quest.title }}</h3>
<p class="quest__description" data-quest-completed>Completed.</p>
</article>
}
</section>
}
</section>

View File

@@ -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);
}

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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();
}
}

View File

@@ -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> = {}): 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<typeof vi.fn>): 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);
});
});

View File

@@ -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<QuestView[]>([]);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(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<void> {
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;
}
}

View File

@@ -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. */

View File

@@ -24,6 +24,7 @@
<app-location-poi
[poi]="poi"
[busy]="busy"
[markers]="store.markersFor(poi.npcKey)"
(activate)="activatePoi($event)"
/>
}

View File

@@ -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<typeof vi.fn>;
runInteraction: ReturnType<typeof vi.fn>;
closeInteraction: ReturnType<typeof vi.fn>;
markersFor: ReturnType<typeof vi.fn>;
};
/** Markers by NPC key, as the server would have decided them. */
let markers: Record<string, NpcMarker[]>;
async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) {
async function setup(
current: CurrentLocationResponse | null = burnedRoadFixture(),
npcMarkers: Record<string, NpcMarker[]> = {},
) {
markers = npcMarkers;
location = signal(current);
error = signal<string | null>(null);
interactionResult = signal<LocationInteractionResult | null>(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<CurrentLocationResponse> {
function southGateContent({
withNpcKey = true,
}: { withNpcKey?: boolean } = {}): Partial<CurrentLocationResponse> {
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: [

View File

@@ -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()"
>
<span class="location-poi__medallion">
<app-location-icon [iconKey]="poi.iconKey" />
@if (badge; as questBadge) {
<span class="location-poi__badge" data-poi-badge aria-hidden="true">{{
questBadge.glyph
}}</span>
}
</span>
<span class="location-poi__title">{{ poi.title }}</span>
@if (poi.actionLabel) {

View File

@@ -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;

View File

@@ -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<LocationPoiComponent>;
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('?');
});
});

View File

@@ -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<Record<NpcMarker, { glyph: string; label: string }>>
> = {
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<LocationPointOfInterest>();
/**
* 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);

View File

@@ -62,6 +62,7 @@ describe('WorldStore', () => {
getCurrentLocation: ReturnType<typeof vi.fn>;
getCurrentTravel: ReturnType<typeof vi.fn>;
startTravel: ReturnType<typeof vi.fn>;
getLocationNpcs: ReturnType<typeof vi.fn>;
};
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([]);
});
});
});

View File

@@ -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<CurrentTravel | null>(null);
private readonly remainingSecondsState = signal<number | null>(null);
private readonly arrivedState = signal<LocationSummary | null>(null);
private readonly locationNpcsState = signal<NpcSummary[]>([]);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
private countdownTimer: ReturnType<typeof setInterval> | 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<void> {
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<void> {
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 {

View File

@@ -45,9 +45,12 @@
<button
class="side-navigation__item"
type="button"
routerLink="/quests"
routerLinkActive="side-navigation__item--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
data-navigation="quests"
disabled
aria-label="Quests are not yet available"
aria-label="Quests"
>
<img src="/images/hud/runtime/QuestsIcon-128.png" alt="" />
<span>Quests</span>

View File

@@ -1354,13 +1354,22 @@ Erster NPC: **Borin, Quartermaster** an `south-gate` (Graufurt) mit
DIALOGUE + MERCHANT + RESOURCE_EXCHANGE gleichzeitig — der Kompositionsfall
aus §2/§26 in echt.
## Abweichung 1 — kein `NpcQuestAssignment`
## Abweichung 1 — kein `NpcQuestAssignment` — **aufgelöst in Slice 0.9**
§34 listet es im V1-Scope, aber es gibt noch kein Questsystem (Slice 0.9).
§34 listet es im V1-Scope, aber es gab in Slice 0.8 noch kein Questsystem.
Eine Tabelle mit Fremdschlüssel auf eine nicht existierende `quests`-Tabelle
ist nicht baubar, und ein `questKey`-String ohne Validierung wäre spekulative
Architektur (AGENTS §1.7). Nachzuholen mit Slice 0.9, zusammen mit den
Dialog-Actions `START_QUEST` / `COMPLETE_QUEST`.
Architektur (AGENTS §1.7).
Slice 0.9 hat das nachgeholt: `NpcQuestAssignment` liegt unter
`apps/api/src/quests/entities/npc-quest-assignment.entity.ts` und trägt die
Rollen `OFFER` / `TURN_IN` / `PROGRESS` aus §14. Der Unique-Index umfasst die
Rolle mit, damit derselbe NPC eine Quest sowohl vergeben als auch annehmen
kann — genau der Fall aus §14, den die erste Questkette benutzt: der South
Gate Warden vergibt und nimmt an, Borin steht als `PROGRESS` dazwischen.
Die Dialog-Actions `START_QUEST` / `COMPLETE_QUEST` bleiben offen, siehe
Abweichung 4.
## Abweichung 2 — kein `NpcDialogueProfile`
@@ -1372,14 +1381,52 @@ neu zu schreiben.
## Abweichung 3 — Conditions, die (noch) nichts beantworten kann
`GameConditionType` enthält die vollständige V1-Liste aus §19, aber nur
`REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET` und `HAS_ITEM` sind
auswertbar. `QUEST_ACTIVE`, `QUEST_COMPLETED`, `BOSS_DEFEATED` und
`LOCATION_DISCOVERED` haben noch kein System dahinter.
`GameConditionType` enthält die vollständige V1-Liste aus §19, aber nicht jeder
Typ hat ein System dahinter.
Sie werten **fail-closed** aus, also immer "nicht erfüllt". Für ein Gate ist
die sichere Richtung eines Fehlers zu, nicht offen — ein Quest-Gate darf
niemals aufgehen, nur weil es keine Quests gibt.
Seit Slice 0.9 auswertbar: `REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET`,
`HAS_ITEM`, `QUEST_ACTIVE`, `QUEST_COMPLETED`. Offen bleiben `BOSS_DEFEATED`
(Slice 0.11) und `LOCATION_DISCOVERED` (wird pro Charakter nicht getrackt).
Die beiden Quest-Typen werten boolesch aus wie `FLAG_SET`, nicht numerisch:
eine Quest läuft oder nicht, `actual` bleibt `null`. `value: false` ist die Art,
wie Content die Verneinung ausdrückt — die Angebotszeile des Wardens braucht
"noch nicht begonnen und noch nicht abgeschlossen", ohne dass die
Condition-Sprache ein NOT bekommt.
Die verbleibenden Typen werten **fail-closed** aus, also immer "nicht erfüllt".
Für ein Gate ist die sichere Richtung eines Fehlers zu, nicht offen.
## Abweichung 4 — Questschritte laufen nicht über Dialog-Actions
`DialogueActionType` enthält `START_QUEST` und `COMPLETE_QUEST`, aber beide
bleiben auch mit dem Questsystem aus Slice 0.9 wirkungslos.
Sie auszuführen bräuchte zwei Dinge, die es nicht gibt: einen Endpoint, über
den der Client eine Dialog-Response *auswählt*, und einen Action-Executor
dahinter. Playable Slice 0.9 §10 verlangt ausdrücklich einen
schrittbasierten Questautomaten und verbietet eine verzweigende
Narrative-Engine — und ein Response-Endpoint plus Executor wäre genau deren
Anfang, für eine Kette mit fünf Schritten.
Stattdessen:
```text
POST /api/npcs/:npcKey/quests/:questKey/accept
POST /api/npcs/:npcKey/quests/:questKey/advance
```
Keine der beiden Routen nimmt einen Body. Welcher Schritt gerade dran ist, was
er vergibt und was er verbraucht, entscheidet der Server (AGENTS §5). Der Satz,
den der NPC zu einem Schritt sagt, ist Content auf dem `QuestObjective`
(`npc_line`) und kommt mit der Antwort zurück.
Nachzuholen wäre das, sobald ein NPC wirklich verzweigende Antworten braucht:
dann ein Response-Auswahl-Endpoint, ein Executor über
`SUPPORTED_DIALOGUE_ACTIONS`, und die Questschritte können auf Dialog-Actions
umziehen, ohne dass die Questtabellen sich ändern.
---
## Offene Designfrage — World Renown im Tausch
@@ -1398,3 +1445,40 @@ anzufassen.
Falls Renown später doch als Punktwährung gedacht ist, muss das zuerst in
0.6.5 geändert werden — nicht hier.
---
# 41. Implementierungsstand (Slice 0.9)
```text
QuestDefinition / QuestObjective apps/api/src/quests/entities/
NpcQuestAssignment Rollen aus §14
CharacterQuest Player-State, ein Row pro Charakter/Quest
QuestProgressService leitet den aktiven Schritt ab (lesend)
QuestService accept / advance, Effekte, Belohnungen
QuestController / NpcQuestController apps/api/src/quests/quest.controller.ts
```
Erste Questkette: **Trouble Beyond the Gate**, vergeben und angenommen von
Halvik am Südtor, mit Borin als Zwischenschritt — der Mehr-NPC-Fall aus §14
in echt.
Zwei Module statt einem: `QuestProgressModule` enthält nur den lesenden Teil,
weil `NpcService` Questmarker braucht und `QuestService` umgekehrt `NpcService`
für die Erreichbarkeitsprüfung. Der Schnitt hält den Modulgraphen zyklenfrei,
ohne `forwardRef`.
Es gibt bewusst **keine** Tabelle für Objective-Fortschritt. Slice 0.9 §11
verlangt, dass Sammelschritte ihren Stand aus dem aktuellen Besitz ableiten;
genau das macht `QuestProgressService`, und dadurch fallen die Softlock-Fälle
aus §11 von selbst weg, statt einzeln behandelt zu werden.
Was ein Schritt *tut*, steht als Content auf dem `QuestObjective`
(`sets_flag_key` / `sets_flag_npc_key`, `grants_loot_bag_key`,
`consume_on_complete`, `advance_when_blocked`). `QuestService` wendet an, was
es vorfindet, und verzweigt nirgends auf einen Questkey (AGENTS §9).
Markerregeln für die Ortsansicht (§24, Slice 0.9 §12): höchstens ein
Questmarker pro NPC, Reihenfolge `QUEST_TURN_IN` > `QUEST_AVAILABLE` >
`QUEST_IN_PROGRESS`. Halvik hält für dieselbe Quest zwei Rollen; ohne
Vorrangregel trüge er zwei Symbole und keines würde etwas aussagen.

View File

@@ -314,9 +314,85 @@ Hide Capacity: 5
- [ ] Quest grants no XP.
- [ ] All dialogue/UI text is English.
### Verification status
Every criterion above is covered by automated tests: the quest chain in
`quest-state.spec.ts` and `quest.service.spec.ts`, the content in
`vertical-slice.seed.spec.ts`, the markers in `npc.service.spec.ts`, and the
three UI requirements of §12 in the web specs for the journal, the NPC screen
and the location hotspots.
The boxes stay unticked because two things have not been run: the migration and
seed against a real PostgreSQL database, and a hand-played pass through the
chain in the running app. Neither was possible in the environment this slice was
built in — no database was reachable. Do both before ticking, following the
walkthrough in the implementation plan
(`docs/superpowers/plans/2026-08-22-first-quest-and-bag-tutorial.md`, Task 13).
---
## 15. Out of Scope
## 15. Implementation Decisions
Recorded here rather than only in the implementation plan, so the next slice
reads them from the specification.
### D1 — The Basic Hide Bag is granted, not sold
§6 has Borin say "Take this" while also describing the Slice 0.8.5 offer, which
costs 35 Silver. A player standing at that step has close to none, so the
purchase path alone would stall the chain.
Borin's quest step therefore hands the bag over directly and idempotently. The
referral flag is still written to the character's Borin state, so the 0.8.5
`bypassConditions` gate opens the offer as well — a visible consequence, and a
way back to a bag that is somehow lost. Nothing about reputation changes.
### D2 — No World Renown milestone
§9 lists one as optional. The demo character sits at Renown 1 and the first
trade-in takes them to 2; a quest milestone would reach 3 and unlock Borin's
Bandit Blade, which Slice 0.8.5 deliberately placed out of reach until Slice
0.11. The quest pays regional reputation instead: **+10 Border Watch**.
### D3 — No Silver reward
§9 warns against a Silver reward that undermines the merchant trade loop, and
names the bag and system knowledge as the point of the chain. `rewardSilver` is
a content column set to 0, so retuning it later is a seed change.
### D4 — Quest steps use their own endpoints
NPC spec §40 asked Slice 0.9 to also wire the `START_QUEST` / `COMPLETE_QUEST`
dialogue actions. There is no dialogue-response endpoint at all, and building
one plus an action executor is the branching narrative engine §10 rules out.
Steps run through `POST /api/npcs/:npcKey/quests/:questKey/{accept,advance}`.
Neither takes a body — which step is current, what it grants and what it
consumes are the server's to decide. The line an NPC speaks for a step is
content on the objective. Recorded as Abweichung 4 in the NPC specification.
### D5 — The demo character starts with no loot bag
The vertical-slice seed handed over a free Basic Hide Bag as a stopgap between
Slices 0.8.5 and 0.9, with a note to remove it once a quest could grant one.
That is done: the bagless HIDE capacity of 1 is the whole premise of §4. The
migration also clears that one row for the demo character on databases seeded
before this change.
### Addition — a hint for the second pelt hunt
Not in the specification, but the same courtesy §4 asks for. The Hide Bag holds
five of *any* hide, so Tough Hides can crowd out the fifth Ashen Pelt. That step
must not give up the way the first one does — it is meant to be finished (§7) —
so it carries a hint instead:
```text
Your hide bag is full of other goods. Trade some to Borin to make room for pelts.
```
---
## 16. Out of Scope
Do not implement:

File diff suppressed because it is too large Load Diff