Merge branch 'slice/0.9-first-quest-and-bag-tutorial'
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
235
apps/api/src/database/seeds/quest-content.ts
Normal file
235
apps/api/src/database/seeds/quest-content.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
29
apps/api/src/database/seeds/quest.constants.ts
Normal file
29
apps/api/src/database/seeds/quest.constants.ts
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
77
apps/api/src/quests/entities/character-quest.entity.ts
Normal file
77
apps/api/src/quests/entities/character-quest.entity.ts
Normal 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;
|
||||
}
|
||||
68
apps/api/src/quests/entities/npc-quest-assignment.entity.ts
Normal file
68
apps/api/src/quests/entities/npc-quest-assignment.entity.ts
Normal 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;
|
||||
}
|
||||
63
apps/api/src/quests/entities/quest-definition.entity.ts
Normal file
63
apps/api/src/quests/entities/quest-definition.entity.ts
Normal 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;
|
||||
}
|
||||
129
apps/api/src/quests/entities/quest-entities.metadata.spec.ts
Normal file
129
apps/api/src/quests/entities/quest-entities.metadata.spec.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
135
apps/api/src/quests/entities/quest-objective.entity.ts
Normal file
135
apps/api/src/quests/entities/quest-objective.entity.ts
Normal 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;
|
||||
}
|
||||
40
apps/api/src/quests/quest-progress.module.ts
Normal file
40
apps/api/src/quests/quest-progress.module.ts
Normal 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 {}
|
||||
386
apps/api/src/quests/quest-progress.service.spec.ts
Normal file
386
apps/api/src/quests/quest-progress.service.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
267
apps/api/src/quests/quest-progress.service.ts
Normal file
267
apps/api/src/quests/quest-progress.service.ts
Normal 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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
194
apps/api/src/quests/quest-state.spec.ts
Normal file
194
apps/api/src/quests/quest-state.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
72
apps/api/src/quests/quest-state.ts
Normal file
72
apps/api/src/quests/quest-state.ts
Normal 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;
|
||||
}
|
||||
135
apps/api/src/quests/quest.controller.spec.ts
Normal file
135
apps/api/src/quests/quest.controller.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
52
apps/api/src/quests/quest.controller.ts
Normal file
52
apps/api/src/quests/quest.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
81
apps/api/src/quests/quest.errors.ts
Normal file
81
apps/api/src/quests/quest.errors.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
867
apps/api/src/quests/quest.service.spec.ts
Normal file
867
apps/api/src/quests/quest.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
489
apps/api/src/quests/quest.service.ts
Normal file
489
apps/api/src/quests/quest.service.ts
Normal 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
|
||||
);
|
||||
}
|
||||
90
apps/api/src/quests/quest.types.ts
Normal file
90
apps/api/src/quests/quest.types.ts
Normal 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;
|
||||
}
|
||||
50
apps/api/src/quests/quests.module.ts
Normal file
50
apps/api/src/quests/quests.module.ts
Normal 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 {}
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
52
apps/web/src/app/features/quests/quest-page.component.html
Normal file
52
apps/web/src/app/features/quests/quest-page.component.html
Normal 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>
|
||||
71
apps/web/src/app/features/quests/quest-page.component.scss
Normal file
71
apps/web/src/app/features/quests/quest-page.component.scss
Normal 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);
|
||||
}
|
||||
139
apps/web/src/app/features/quests/quest-page.component.spec.ts
Normal file
139
apps/web/src/app/features/quests/quest-page.component.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
apps/web/src/app/features/quests/quest-page.component.ts
Normal file
38
apps/web/src/app/features/quests/quest-page.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
108
apps/web/src/app/features/quests/quest.store.spec.ts
Normal file
108
apps/web/src/app/features/quests/quest.store.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
apps/web/src/app/features/quests/quest.store.ts
Normal file
83
apps/web/src/app/features/quests/quest.store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<app-location-poi
|
||||
[poi]="poi"
|
||||
[busy]="busy"
|
||||
[markers]="store.markersFor(poi.npcKey)"
|
||||
(activate)="activatePoi($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
1288
docs/superpowers/plans/2026-08-22-first-quest-and-bag-tutorial.md
Normal file
1288
docs/superpowers/plans/2026-08-22-first-quest-and-bag-tutorial.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user