feat(quests): seed Trouble Beyond the Gate and the South Gate Warden

The demo character no longer starts with a free Basic Hide Bag: the
bagless HIDE capacity of 1 is what the quest chain exists to teach.

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

View File

@@ -159,18 +159,19 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
resultText:
'Weathered notices flutter in the wind. A fresh one warns of raiders on the Burned Road and promises silver for every bandit killed.',
},
// 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."',
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
@@ -200,12 +201,13 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
localPrimaryActions: [
{
key: 'talk-to-watch',
label: 'Talk to the watch',
description: 'Ask about the situation',
label: 'Talk to the warden',
description: 'Ask about the road south',
type: 'NPC',
iconKey: 'speak',
enabled: true,
poiKey: 'gate-watch',
npcKey: 'south-gate-warden',
},
{
key: 'trade-with-borin',

View File

@@ -10,13 +10,16 @@ import type {
} from '../../npcs/npc.types';
import { ITEM_IDS } from './item.constants';
import { BASIC_HIDE_BAG_ID, BASIC_TROPHY_POUCH_ID } from './loot-bag-content';
import { TROUBLE_BEYOND_THE_GATE_KEY } from './quest.constants';
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001';
export const SOUTH_GATE_WARDEN_NPC_ID = 'b0000000-0000-4000-8000-000000000002';
export const BORIN_SHOP_ID = 'b1000000-0000-4000-8000-000000000001';
export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001';
export const BORIN_KEY = 'borin-quartermaster';
export const SOUTH_GATE_WARDEN_KEY = 'south-gate-warden';
export const BORIN_SHOP_KEY = 'borin-supplies';
export const BORIN_EXCHANGE_KEY = 'borin-trade-in';
@@ -114,6 +117,42 @@ 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',
portraitPath: '/images/npcs/south-gate-warden.png',
artworkPath: null,
capabilities: [
NpcCapability.DIALOGUE,
NpcCapability.QUEST_GIVER,
NpcCapability.QUEST_TURN_IN,
],
enabled: true,
},
];
export interface SeedDialogueNode {
@@ -182,6 +221,78 @@ export const DIALOGUE_NODES: SeedDialogueNode[] = [
responses: [],
enabled: true,
},
/**
* The warden's four lines (Slice 0.9 §3, §8).
*
* Gated on quest state rather than on which step is current: the beat-specific
* lines -- the referral, the thanks -- are content on the objectives and come
* back from the step endpoint that performs them (decision D4). These four
* cover what the warden says when you simply walk up, which is all a priority
* list needs to answer.
*/
{
npcId: SOUTH_GATE_WARDEN_NPC_ID,
key: 'warden-quest-offer',
text: 'The road has gone bad. Start small. Bring me five Ashen Pelts from the rats beyond the gate. I want to know what the ash is doing to them.',
priority: 900,
conditions: [
{
type: GameConditionType.QUEST_ACTIVE,
key: TROUBLE_BEYOND_THE_GATE_KEY,
value: false,
},
{
type: GameConditionType.QUEST_COMPLETED,
key: TROUBLE_BEYOND_THE_GATE_KEY,
value: false,
},
],
actions: [],
responses: [],
enabled: true,
},
{
npcId: SOUTH_GATE_WARDEN_NPC_ID,
key: 'warden-quest-done',
text: 'The road is no safer, but at least someone is walking it. Whatever you drag back from now on, take it to Borin.',
priority: 700,
conditions: [
{
type: GameConditionType.QUEST_COMPLETED,
key: TROUBLE_BEYOND_THE_GATE_KEY,
value: true,
},
],
actions: [],
responses: [],
enabled: true,
},
{
npcId: SOUTH_GATE_WARDEN_NPC_ID,
key: 'warden-quest-active',
text: 'Still out there, then. Five pelts, and no fewer. I am not paying for a guess.',
priority: 500,
conditions: [
{
type: GameConditionType.QUEST_ACTIVE,
key: TROUBLE_BEYOND_THE_GATE_KEY,
value: true,
},
],
actions: [],
responses: [],
enabled: true,
},
{
npcId: SOUTH_GATE_WARDEN_NPC_ID,
key: 'warden-default',
text: "Beyond the gate, Graufurt's protection ends. Whoever heads south does so at their own risk.",
priority: 100,
conditions: [],
actions: [],
responses: [],
enabled: true,
},
];
export interface SeedNpcShop {

View File

@@ -0,0 +1,229 @@
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,
hintText: null,
advanceWhenBlocked: false,
consumeOnComplete: true,
grantsLootBagKey: null,
setsFlagKey: null,
setsFlagNpcKey: null,
enabled: true,
},
{
id: QUEST_OBJECTIVE_IDS.turnIn,
questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID,
key: 'turn-in',
orderIndex: 4,
type: QuestObjectiveType.TALK_TO_NPC,
targetKey: SOUTH_GATE_WARDEN_KEY,
requiredQuantity: 1,
description: 'Bring the pelts to the South Gate Warden',
npcLine:
"Good. That's enough for me. From now on, take hides and trophies to Borin. He'll pay for useful spoils, and word gets around when you keep the roads clear.",
hintText: null,
advanceWhenBlocked: false,
consumeOnComplete: false,
grantsLootBagKey: null,
setsFlagKey: null,
setsFlagNpcKey: null,
enabled: true,
},
];
export interface SeedNpcQuestAssignment {
id: string;
npcId: string;
questId: string;
role: NpcQuestRole;
enabled: boolean;
}
/**
* Who does what (NPC spec §14).
*
* §14's own example is a quest offered by one person, moved forward by a second
* and handed back to the first. That is exactly this chain, and it is why the
* assignment is a row rather than an `isQuestGiver` flag on the NPC.
*/
export const NPC_QUEST_ASSIGNMENTS: SeedNpcQuestAssignment[] = [
{
id: QUEST_ASSIGNMENT_IDS.wardenOffer,
npcId: SOUTH_GATE_WARDEN_NPC_ID,
questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID,
role: NpcQuestRole.OFFER,
enabled: true,
},
{
id: QUEST_ASSIGNMENT_IDS.wardenTurnIn,
npcId: SOUTH_GATE_WARDEN_NPC_ID,
questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID,
role: NpcQuestRole.TURN_IN,
enabled: true,
},
{
id: QUEST_ASSIGNMENT_IDS.borinProgress,
npcId: BORIN_NPC_ID,
questId: TROUBLE_BEYOND_THE_GATE_QUEST_ID,
role: NpcQuestRole.PROGRESS,
enabled: true,
},
];

View File

@@ -0,0 +1,29 @@
// Stable quest content ids and keys (AGENTS.md §8).
//
// Their own file because both `npc-content.ts` and `quest-content.ts` need
// them: the warden's dialogue gates on the quest key, and the quest content
// needs the warden's NPC id. Sharing a constants module is how the seeds
// already break that kind of knot (see `item.constants.ts`).
export const TROUBLE_BEYOND_THE_GATE_KEY = 'trouble-beyond-the-gate';
export const TROUBLE_BEYOND_THE_GATE_QUEST_ID =
'c0000000-0000-4000-8000-000000000001';
/**
* An objective's `key` is only unique within its quest, so it cannot serve as
* an upsert conflict target. These ids are what make a re-seed re-tune a step
* instead of inserting a second one.
*/
export const QUEST_OBJECTIVE_IDS = {
collectPeltsFirst: 'c1000000-0000-4000-8000-000000000001',
reportCapacity: 'c1000000-0000-4000-8000-000000000002',
collectBag: 'c1000000-0000-4000-8000-000000000003',
collectPelts: 'c1000000-0000-4000-8000-000000000004',
turnIn: 'c1000000-0000-4000-8000-000000000005',
} as const;
export const QUEST_ASSIGNMENT_IDS = {
wardenOffer: 'c2000000-0000-4000-8000-000000000001',
wardenTurnIn: 'c2000000-0000-4000-8000-000000000002',
borinProgress: 'c2000000-0000-4000-8000-000000000003',
} as const;

View File

@@ -31,8 +31,21 @@ import {
ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants';
import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity';
import { QuestDefinition } from '../../quests/entities/quest-definition.entity';
import { QuestObjective } from '../../quests/entities/quest-objective.entity';
import { NpcQuestRole, QuestObjectiveType } from '../../quests/quest.types';
import { BASIC_HIDE_BAG_ID } from './loot-bag-content';
import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content';
import {
BORIN_KEY,
BORIN_NPC_ID,
BORIN_OFFER_IDS,
SHOP_OFFERS,
SOUTH_GATE_REFERRAL_FLAG,
SOUTH_GATE_WARDEN_KEY,
SOUTH_GATE_WARDEN_NPC_ID,
} from './npc-content';
import { TROUBLE_BEYOND_THE_GATE_KEY } from './quest.constants';
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
type Row = Record<string, unknown>;
@@ -92,53 +105,108 @@ class InMemoryRepository {
});
}
/**
* Every table the seed touches, addressed by name.
*
* Named rather than positional: the seed writes more than twenty tables now,
* and a test that only cares about quest objectives should not have to count
* out placeholder repositories to reach them.
*/
interface SeedRepositories {
location: InMemoryRepository;
connection: InMemoryRepository;
character: InMemoryRepository;
monster: InMemoryRepository;
locationMonster: InMemoryRepository;
item: InMemoryRepository;
lootTable: InMemoryRepository;
lootEntry: InMemoryRepository;
characterItem: InMemoryRepository;
characterEquipment: InMemoryRepository;
reputationFaction: InMemoryRepository;
renownMilestone: InMemoryRepository;
lootBagDefinition: InMemoryRepository;
characterLootBag: InMemoryRepository;
npcDefinition: InMemoryRepository;
dialogueNode: InMemoryRepository;
npcShop: InMemoryRepository;
shopOffer: InMemoryRepository;
npcExchangeProfile: InMemoryRepository;
exchangeRule: InMemoryRepository;
questDefinition: InMemoryRepository;
questObjective: InMemoryRepository;
npcQuestAssignment: InMemoryRepository;
}
const REPOSITORY_KEYS: ReadonlyArray<keyof SeedRepositories> = [
'location',
'connection',
'character',
'monster',
'locationMonster',
'item',
'lootTable',
'lootEntry',
'characterItem',
'characterEquipment',
'reputationFaction',
'renownMilestone',
'lootBagDefinition',
'characterLootBag',
'npcDefinition',
'dialogueNode',
'npcShop',
'shopOffer',
'npcExchangeProfile',
'exchangeRule',
'questDefinition',
'questObjective',
'npcQuestAssignment',
];
function createDataSource(
locationRepository: InMemoryRepository,
connectionRepository: InMemoryRepository,
characterRepository: InMemoryRepository,
monsterRepository: InMemoryRepository,
locationMonsterRepository: InMemoryRepository,
itemRepository: InMemoryRepository = new InMemoryRepository(),
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
reputationFactionRepository: InMemoryRepository = new InMemoryRepository(),
renownMilestoneRepository: InMemoryRepository = new InMemoryRepository(),
lootBagDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
characterLootBagRepository: InMemoryRepository = new InMemoryRepository(),
npcDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
dialogueNodeRepository: InMemoryRepository = new InMemoryRepository(),
npcShopRepository: InMemoryRepository = new InMemoryRepository(),
shopOfferRepository: InMemoryRepository = new InMemoryRepository(),
npcExchangeProfileRepository: InMemoryRepository = new InMemoryRepository(),
exchangeRuleRepository: InMemoryRepository = new InMemoryRepository(),
overrides: Partial<SeedRepositories> = {},
): DataSource {
const repositories = Object.fromEntries(
REPOSITORY_KEYS.map((key) => [
key,
overrides[key] ?? new InMemoryRepository(),
]),
) as SeedRepositories;
const byEntity = new Map<unknown, InMemoryRepository>([
[LocationDefinition, repositories.location],
[LocationConnection, repositories.connection],
[Character, repositories.character],
[MonsterDefinition, repositories.monster],
[LocationMonster, repositories.locationMonster],
[ItemDefinition, repositories.item],
[LootTable, repositories.lootTable],
[LootTableEntry, repositories.lootEntry],
[CharacterItem, repositories.characterItem],
[CharacterEquipment, repositories.characterEquipment],
[ReputationFaction, repositories.reputationFaction],
[RenownMilestoneDefinition, repositories.renownMilestone],
[NpcDefinition, repositories.npcDefinition],
[DialogueNode, repositories.dialogueNode],
[NpcShop, repositories.npcShop],
[ShopOffer, repositories.shopOffer],
[NpcExchangeProfile, repositories.npcExchangeProfile],
[ExchangeRule, repositories.exchangeRule],
[LootBagDefinition, repositories.lootBagDefinition],
[CharacterLootBag, repositories.characterLootBag],
[QuestDefinition, repositories.questDefinition],
[QuestObjective, repositories.questObjective],
[NpcQuestAssignment, repositories.npcQuestAssignment],
]);
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === LocationDefinition) return locationRepository;
if (entity === LocationConnection) return connectionRepository;
if (entity === Character) return characterRepository;
if (entity === MonsterDefinition) return monsterRepository;
if (entity === LocationMonster) return locationMonsterRepository;
if (entity === ItemDefinition) return itemRepository;
if (entity === LootTable) return lootTableRepository;
if (entity === LootTableEntry) return lootEntryRepository;
if (entity === CharacterItem) return characterItemRepository;
if (entity === CharacterEquipment) return characterEquipmentRepository;
if (entity === ReputationFaction) return reputationFactionRepository;
if (entity === RenownMilestoneDefinition)
return renownMilestoneRepository;
if (entity === NpcDefinition) return npcDefinitionRepository;
if (entity === DialogueNode) return dialogueNodeRepository;
if (entity === NpcShop) return npcShopRepository;
if (entity === ShopOffer) return shopOfferRepository;
if (entity === NpcExchangeProfile) return npcExchangeProfileRepository;
if (entity === ExchangeRule) return exchangeRuleRepository;
if (entity === LootBagDefinition) return lootBagDefinitionRepository;
if (entity === CharacterLootBag) return characterLootBagRepository;
throw new Error('Unexpected repository');
const repository = byEntity.get(entity);
if (!repository) {
throw new Error('Unexpected repository');
}
return repository;
}),
} as unknown as DataSource;
}
@@ -150,13 +218,13 @@ describe('seedVisibleVerticalSlice', () => {
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
);
const dataSource = createDataSource({
location: locationRepository,
connection: connectionRepository,
character: characterRepository,
monster: monsterRepository,
locationMonster: locationMonsterRepository,
});
await seedVisibleVerticalSlice(dataSource);
Object.assign(characterRepository.rows[0], {
@@ -308,13 +376,9 @@ describe('seedVisibleVerticalSlice', () => {
it('seeds the local view content of the Burned Road with four points of interest', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
);
const dataSource = createDataSource({
location: locationRepository,
});
await seedVisibleVerticalSlice(dataSource);
@@ -364,13 +428,9 @@ describe('seedVisibleVerticalSlice', () => {
it('gives the South Gate its own local content so a second location needs no new component', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
);
const dataSource = createDataSource({
location: locationRepository,
});
await seedVisibleVerticalSlice(dataSource);
@@ -406,13 +466,13 @@ describe('seedVisibleVerticalSlice', () => {
key: 'south-gate',
name: 'Outdated South Gate',
});
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
);
const dataSource = createDataSource({
location: locationRepository,
connection: connectionRepository,
character: characterRepository,
monster: monsterRepository,
locationMonster: locationMonsterRepository,
});
await seedVisibleVerticalSlice(dataSource);
@@ -458,16 +518,16 @@ describe('seedVisibleVerticalSlice', () => {
const itemRepository = new InMemoryRepository();
const lootTableRepository = new InMemoryRepository();
const lootEntryRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
itemRepository,
lootTableRepository,
lootEntryRepository,
);
const dataSource = createDataSource({
location: locationRepository,
connection: connectionRepository,
character: characterRepository,
monster: monsterRepository,
locationMonster: locationMonsterRepository,
item: itemRepository,
lootTable: lootTableRepository,
lootEntry: lootEntryRepository,
});
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
@@ -605,21 +665,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 +691,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 +716,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 +755,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 +787,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 +823,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 +840,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 +876,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 +945,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 +1019,214 @@ 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('writes the referral flag onto Borin, not onto the warden', async () => {
const questObjectiveRepository = new InMemoryRepository();
const dataSource = createDataSource({
questObjective: questObjectiveRepository,
});
await seedVisibleVerticalSlice(dataSource);
const referralStep = questObjectiveRepository.rows.find(
(row) => row.setsFlagKey === SOUTH_GATE_REFERRAL_FLAG,
) as Row;
// Dialogue flags are per-NPC state (NPC spec §7), and the 0.8.5 bypass on
// the Hide Bag offer is evaluated with Borin in context (slice §6).
expect(referralStep.setsFlagNpcKey).toBe(BORIN_KEY);
expect(referralStep.targetKey).toBe(SOUTH_GATE_WARDEN_KEY);
});
it('hands the Basic Hide Bag over on the step at Borin', async () => {
const questObjectiveRepository = new InMemoryRepository();
const dataSource = createDataSource({
questObjective: questObjectiveRepository,
});
await seedVisibleVerticalSlice(dataSource);
const bagSteps = questObjectiveRepository.rows.filter(
(row) => row.grantsLootBagKey !== null,
);
expect(bagSteps).toHaveLength(1);
expect(bagSteps[0]).toMatchObject({
targetKey: BORIN_KEY,
grantsLootBagKey: 'basic-hide-bag',
});
});
it('consumes pelts only on the second collect step', async () => {
const questObjectiveRepository = new InMemoryRepository();
const dataSource = createDataSource({
questObjective: questObjectiveRepository,
});
await seedVisibleVerticalSlice(dataSource);
// Both steps ask for five pelts; consuming both would demand ten (§8).
const consuming = questObjectiveRepository.rows.filter(
(row) => row.consumeOnComplete === true,
);
expect(consuming).toHaveLength(1);
expect(consuming[0].orderIndex).toBe(3);
});
it('pays reputation only, with no silver and no renown milestone', async () => {
const questDefinitionRepository = new InMemoryRepository();
const renownMilestoneRepository = new InMemoryRepository();
const dataSource = createDataSource({
questDefinition: questDefinitionRepository,
renownMilestone: renownMilestoneRepository,
});
await seedVisibleVerticalSlice(dataSource);
// Slice 0.9 decisions D2/D3: a Silver payout would undercut the trade loop
// the quest exists to teach, and a renown milestone would reach World
// Renown 3 and open the Bandit Blade that Slice 0.8.5 parked until 0.11.
expect(questDefinitionRepository.rows[0]).toMatchObject({
rewardFactionKey: 'border-guard',
rewardReputation: 10,
rewardSilver: 0,
});
expect(renownMilestoneRepository.rows).toHaveLength(1);
expect(renownMilestoneRepository.rows[0].key).toBe('first-goods-returned');
});
it('splits the quest across two NPCs by role', async () => {
const npcQuestAssignmentRepository = new InMemoryRepository();
const dataSource = createDataSource({
npcQuestAssignment: npcQuestAssignmentRepository,
});
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
// NPC spec §14's worked example, in content: one person offers and
// receives, another moves it forward.
expect(npcQuestAssignmentRepository.rows).toHaveLength(3);
const roleByNpc = npcQuestAssignmentRepository.rows.map((row) => [
row.npcId,
row.role,
]);
expect(roleByNpc).toEqual(
expect.arrayContaining([
[SOUTH_GATE_WARDEN_NPC_ID, NpcQuestRole.OFFER],
[SOUTH_GATE_WARDEN_NPC_ID, NpcQuestRole.TURN_IN],
[BORIN_NPC_ID, NpcQuestRole.PROGRESS],
]),
);
});
it('turns the gate watch hotspot into the warden doorway', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource({ location: locationRepository });
await seedVisibleVerticalSlice(dataSource);
const southGate = locationRepository.rows.find(
(row) => row.key === 'south-gate',
) as Row;
const watch = (
southGate.localPointsOfInterest as Array<Record<string, unknown>>
).find((poi) => poi.key === 'gate-watch') as Record<string, unknown>;
// A hotspot carries authored result text or an npcKey, never both -- the
// warden is a person now, not a plaque.
expect(watch.npcKey).toBe(SOUTH_GATE_WARDEN_KEY);
expect(watch.resultText).toBeUndefined();
const action = (
southGate.localPrimaryActions as Array<Record<string, unknown>>
).find((entry) => entry.poiKey === 'gate-watch') as Record<string, unknown>;
expect(action.npcKey).toBe(SOUTH_GATE_WARDEN_KEY);
});
});

View File

@@ -11,8 +11,10 @@ import { EquipmentSlot } from '../../items/equipment-slot.enum';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity';
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity';
import { QuestDefinition } from '../../quests/entities/quest-definition.entity';
import { QuestObjective } from '../../quests/entities/quest-objective.entity';
import { EncounterType } from '../../monsters/entities/encounter-type.enum';
import { MonsterCategory } from '../../monsters/monster-category.enum';
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
@@ -43,7 +45,12 @@ import {
BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content';
import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content';
import { LOOT_BAG_DEFINITIONS } from './loot-bag-content';
import {
NPC_QUEST_ASSIGNMENTS,
QUEST_DEFINITIONS,
QUEST_OBJECTIVES,
} from './quest-content';
import { REPUTATION_FACTIONS } from './reputation-content';
import {
DIALOGUE_NODES,
@@ -88,6 +95,10 @@ export async function seedVisibleVerticalSlice(
const npcExchangeProfileRepository =
dataSource.getRepository(NpcExchangeProfile);
const exchangeRuleRepository = dataSource.getRepository(ExchangeRule);
const questDefinitionRepository = dataSource.getRepository(QuestDefinition);
const questObjectiveRepository = dataSource.getRepository(QuestObjective);
const npcQuestAssignmentRepository =
dataSource.getRepository(NpcQuestAssignment);
const locations = [
{
@@ -358,28 +369,14 @@ export async function seedVisibleVerticalSlice(
});
}
// ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag
// and no longer starts with the Trophy Pouch.
// The demo character starts with no loot bag at all (Slice 0.9 decision D5).
//
// The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over
// for free would make the slice's own showcase pointless. The Hide Bag stays
// until Slice 0.9 grants it through the warden's referral -- removing both
// now would drop HIDE capacity to the bagless default of 1 with no way to
// raise it, and the hunting loop would be unplayable in between.
//
// Delete this block once 0.9 hands the Hide Bag over in the quest.
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
const existingBag = await characterLootBagRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
});
if (!existingBag) {
await characterLootBagRepository.insert({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId: BASIC_HIDE_BAG_ID,
active: true,
});
}
// Slice 0.8.5 left the Hide Bag here as a stopgap, with a note to remove it
// "once 0.9 hands the Hide Bag over in the quest". This is that moment: the
// bagless HIDE capacity of 1 is what the first quest is built to teach
// (0.9 §4), and a free bag would hide the lesson entirely. The quest's step
// at Borin grants the bag instead, and the migration clears the row from
// databases that were seeded before this change.
// NPC content (NPC Specification V1 §32; Playable Slice 0.8).
//
@@ -401,6 +398,16 @@ export async function seedVisibleVerticalSlice(
]);
}
// Quest content (Playable Slice 0.9 §10; NPC spec §14).
//
// After the NPC upsert, because an assignment points at an NPC id. Objectives
// and assignments conflict on `id` rather than on a natural key: an
// objective's key is unique only within its quest, and an assignment has no
// natural key at all beyond the triple its unique index already covers.
await questDefinitionRepository.upsert(QUEST_DEFINITIONS, ['key']);
await questObjectiveRepository.upsert(QUEST_OBJECTIVES, ['id']);
await npcQuestAssignmentRepository.upsert(NPC_QUEST_ASSIGNMENTS, ['id']);
await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
// By id, not by (shop, item): an offer may now sell a bag instead of an

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB