diff --git a/apps/api/src/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts index 3275c4e..6388fcc 100644 --- a/apps/api/src/database/seeds/local-location.content.ts +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -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', diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index 46324c9..6f17405 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -10,13 +10,16 @@ import type { } from '../../npcs/npc.types'; import { ITEM_IDS } from './item.constants'; import { BASIC_HIDE_BAG_ID, BASIC_TROPHY_POUCH_ID } from './loot-bag-content'; +import { TROUBLE_BEYOND_THE_GATE_KEY } from './quest.constants'; import { BORDER_GUARD_FACTION_ID } from './reputation-content'; export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001'; +export const SOUTH_GATE_WARDEN_NPC_ID = 'b0000000-0000-4000-8000-000000000002'; export const BORIN_SHOP_ID = 'b1000000-0000-4000-8000-000000000001'; export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001'; export const BORIN_KEY = 'borin-quartermaster'; +export const SOUTH_GATE_WARDEN_KEY = 'south-gate-warden'; export const BORIN_SHOP_KEY = 'borin-supplies'; export const BORIN_EXCHANGE_KEY = 'borin-trade-in'; @@ -114,6 +117,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 { diff --git a/apps/api/src/database/seeds/quest-content.ts b/apps/api/src/database/seeds/quest-content.ts new file mode 100644 index 0000000..8d55401 --- /dev/null +++ b/apps/api/src/database/seeds/quest-content.ts @@ -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, + }, +]; diff --git a/apps/api/src/database/seeds/quest.constants.ts b/apps/api/src/database/seeds/quest.constants.ts new file mode 100644 index 0000000..f73be03 --- /dev/null +++ b/apps/api/src/database/seeds/quest.constants.ts @@ -0,0 +1,29 @@ +// Stable quest content ids and keys (AGENTS.md §8). +// +// Their own file because both `npc-content.ts` and `quest-content.ts` need +// them: the warden's dialogue gates on the quest key, and the quest content +// needs the warden's NPC id. Sharing a constants module is how the seeds +// already break that kind of knot (see `item.constants.ts`). + +export const TROUBLE_BEYOND_THE_GATE_KEY = 'trouble-beyond-the-gate'; +export const TROUBLE_BEYOND_THE_GATE_QUEST_ID = + 'c0000000-0000-4000-8000-000000000001'; + +/** + * An objective's `key` is only unique within its quest, so it cannot serve as + * an upsert conflict target. These ids are what make a re-seed re-tune a step + * instead of inserting a second one. + */ +export const QUEST_OBJECTIVE_IDS = { + collectPeltsFirst: 'c1000000-0000-4000-8000-000000000001', + reportCapacity: 'c1000000-0000-4000-8000-000000000002', + collectBag: 'c1000000-0000-4000-8000-000000000003', + collectPelts: 'c1000000-0000-4000-8000-000000000004', + turnIn: 'c1000000-0000-4000-8000-000000000005', +} as const; + +export const QUEST_ASSIGNMENT_IDS = { + wardenOffer: 'c2000000-0000-4000-8000-000000000001', + wardenTurnIn: 'c2000000-0000-4000-8000-000000000002', + borinProgress: 'c2000000-0000-4000-8000-000000000003', +} as const; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index f3bf441..4582f0b 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -31,8 +31,21 @@ import { ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; +import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity'; +import { QuestDefinition } from '../../quests/entities/quest-definition.entity'; +import { QuestObjective } from '../../quests/entities/quest-objective.entity'; +import { NpcQuestRole, QuestObjectiveType } from '../../quests/quest.types'; import { BASIC_HIDE_BAG_ID } from './loot-bag-content'; -import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content'; +import { + BORIN_KEY, + BORIN_NPC_ID, + BORIN_OFFER_IDS, + SHOP_OFFERS, + SOUTH_GATE_REFERRAL_FLAG, + SOUTH_GATE_WARDEN_KEY, + SOUTH_GATE_WARDEN_NPC_ID, +} from './npc-content'; +import { TROUBLE_BEYOND_THE_GATE_KEY } from './quest.constants'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; @@ -92,53 +105,108 @@ class InMemoryRepository { }); } +/** + * Every table the seed touches, addressed by name. + * + * Named rather than positional: the seed writes more than twenty tables now, + * and a test that only cares about quest objectives should not have to count + * out placeholder repositories to reach them. + */ +interface SeedRepositories { + location: InMemoryRepository; + connection: InMemoryRepository; + character: InMemoryRepository; + monster: InMemoryRepository; + locationMonster: InMemoryRepository; + item: InMemoryRepository; + lootTable: InMemoryRepository; + lootEntry: InMemoryRepository; + characterItem: InMemoryRepository; + characterEquipment: InMemoryRepository; + reputationFaction: InMemoryRepository; + renownMilestone: InMemoryRepository; + lootBagDefinition: InMemoryRepository; + characterLootBag: InMemoryRepository; + npcDefinition: InMemoryRepository; + dialogueNode: InMemoryRepository; + npcShop: InMemoryRepository; + shopOffer: InMemoryRepository; + npcExchangeProfile: InMemoryRepository; + exchangeRule: InMemoryRepository; + questDefinition: InMemoryRepository; + questObjective: InMemoryRepository; + npcQuestAssignment: InMemoryRepository; +} + +const REPOSITORY_KEYS: ReadonlyArray = [ + 'location', + 'connection', + 'character', + 'monster', + 'locationMonster', + 'item', + 'lootTable', + 'lootEntry', + 'characterItem', + 'characterEquipment', + 'reputationFaction', + 'renownMilestone', + 'lootBagDefinition', + 'characterLootBag', + 'npcDefinition', + 'dialogueNode', + 'npcShop', + 'shopOffer', + 'npcExchangeProfile', + 'exchangeRule', + 'questDefinition', + 'questObjective', + 'npcQuestAssignment', +]; + function createDataSource( - locationRepository: InMemoryRepository, - connectionRepository: InMemoryRepository, - characterRepository: InMemoryRepository, - monsterRepository: InMemoryRepository, - locationMonsterRepository: InMemoryRepository, - itemRepository: InMemoryRepository = new InMemoryRepository(), - lootTableRepository: InMemoryRepository = new InMemoryRepository(), - lootEntryRepository: InMemoryRepository = new InMemoryRepository(), - characterItemRepository: InMemoryRepository = new InMemoryRepository(), - characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(), - reputationFactionRepository: InMemoryRepository = new InMemoryRepository(), - renownMilestoneRepository: InMemoryRepository = new InMemoryRepository(), - lootBagDefinitionRepository: InMemoryRepository = new InMemoryRepository(), - characterLootBagRepository: InMemoryRepository = new InMemoryRepository(), - npcDefinitionRepository: InMemoryRepository = new InMemoryRepository(), - dialogueNodeRepository: InMemoryRepository = new InMemoryRepository(), - npcShopRepository: InMemoryRepository = new InMemoryRepository(), - shopOfferRepository: InMemoryRepository = new InMemoryRepository(), - npcExchangeProfileRepository: InMemoryRepository = new InMemoryRepository(), - exchangeRuleRepository: InMemoryRepository = new InMemoryRepository(), + overrides: Partial = {}, ): DataSource { + const repositories = Object.fromEntries( + REPOSITORY_KEYS.map((key) => [ + key, + overrides[key] ?? new InMemoryRepository(), + ]), + ) as SeedRepositories; + + const byEntity = new Map([ + [LocationDefinition, repositories.location], + [LocationConnection, repositories.connection], + [Character, repositories.character], + [MonsterDefinition, repositories.monster], + [LocationMonster, repositories.locationMonster], + [ItemDefinition, repositories.item], + [LootTable, repositories.lootTable], + [LootTableEntry, repositories.lootEntry], + [CharacterItem, repositories.characterItem], + [CharacterEquipment, repositories.characterEquipment], + [ReputationFaction, repositories.reputationFaction], + [RenownMilestoneDefinition, repositories.renownMilestone], + [NpcDefinition, repositories.npcDefinition], + [DialogueNode, repositories.dialogueNode], + [NpcShop, repositories.npcShop], + [ShopOffer, repositories.shopOffer], + [NpcExchangeProfile, repositories.npcExchangeProfile], + [ExchangeRule, repositories.exchangeRule], + [LootBagDefinition, repositories.lootBagDefinition], + [CharacterLootBag, repositories.characterLootBag], + [QuestDefinition, repositories.questDefinition], + [QuestObjective, repositories.questObjective], + [NpcQuestAssignment, repositories.npcQuestAssignment], + ]); + return { getRepository: jest.fn((entity: unknown) => { - if (entity === LocationDefinition) return locationRepository; - if (entity === LocationConnection) return connectionRepository; - if (entity === Character) return characterRepository; - if (entity === MonsterDefinition) return monsterRepository; - if (entity === LocationMonster) return locationMonsterRepository; - if (entity === ItemDefinition) return itemRepository; - if (entity === LootTable) return lootTableRepository; - if (entity === LootTableEntry) return lootEntryRepository; - if (entity === CharacterItem) return characterItemRepository; - if (entity === CharacterEquipment) return characterEquipmentRepository; - if (entity === ReputationFaction) return reputationFactionRepository; - if (entity === RenownMilestoneDefinition) - return renownMilestoneRepository; - if (entity === NpcDefinition) return npcDefinitionRepository; - if (entity === DialogueNode) return dialogueNodeRepository; - if (entity === NpcShop) return npcShopRepository; - if (entity === ShopOffer) return shopOfferRepository; - if (entity === NpcExchangeProfile) return npcExchangeProfileRepository; - if (entity === ExchangeRule) return exchangeRuleRepository; - if (entity === LootBagDefinition) return lootBagDefinitionRepository; - if (entity === CharacterLootBag) return characterLootBagRepository; - - throw new Error('Unexpected repository'); + const repository = byEntity.get(entity); + if (!repository) { + throw new Error('Unexpected repository'); + } + return repository; }), } as unknown as DataSource; } @@ -150,13 +218,13 @@ describe('seedVisibleVerticalSlice', () => { const characterRepository = new InMemoryRepository(); const monsterRepository = new InMemoryRepository(); const locationMonsterRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + }); await seedVisibleVerticalSlice(dataSource); Object.assign(characterRepository.rows[0], { @@ -308,13 +376,9 @@ describe('seedVisibleVerticalSlice', () => { it('seeds the local view content of the Burned Road with four points of interest', async () => { const locationRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - ); + const dataSource = createDataSource({ + location: locationRepository, + }); await seedVisibleVerticalSlice(dataSource); @@ -364,13 +428,9 @@ describe('seedVisibleVerticalSlice', () => { it('gives the South Gate its own local content so a second location needs no new component', async () => { const locationRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - new InMemoryRepository(), - ); + const dataSource = createDataSource({ + location: locationRepository, + }); await seedVisibleVerticalSlice(dataSource); @@ -406,13 +466,13 @@ describe('seedVisibleVerticalSlice', () => { key: 'south-gate', name: 'Outdated South Gate', }); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + }); await seedVisibleVerticalSlice(dataSource); @@ -458,16 +518,16 @@ describe('seedVisibleVerticalSlice', () => { const itemRepository = new InMemoryRepository(); const lootTableRepository = new InMemoryRepository(); const lootEntryRepository = new InMemoryRepository(); - const dataSource = createDataSource( - locationRepository, - connectionRepository, - characterRepository, - monsterRepository, - locationMonsterRepository, - itemRepository, - lootTableRepository, - lootEntryRepository, - ); + const dataSource = createDataSource({ + location: locationRepository, + connection: connectionRepository, + character: characterRepository, + monster: monsterRepository, + locationMonster: locationMonsterRepository, + item: itemRepository, + lootTable: lootTableRepository, + lootEntry: lootEntryRepository, + }); await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); @@ -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> + ).find((poi) => poi.key === 'gate-watch') as Record; + + // A hotspot carries authored result text or an npcKey, never both -- the + // warden is a person now, not a plaque. + expect(watch.npcKey).toBe(SOUTH_GATE_WARDEN_KEY); + expect(watch.resultText).toBeUndefined(); + + const action = ( + southGate.localPrimaryActions as Array> + ).find((entry) => entry.poiKey === 'gate-watch') as Record; + expect(action.npcKey).toBe(SOUTH_GATE_WARDEN_KEY); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index f5be0f9..7a65c84 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -11,8 +11,10 @@ import { EquipmentSlot } from '../../items/equipment-slot.enum'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTable } from '../../loot/entities/loot-table.entity'; -import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity'; import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity'; +import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity'; +import { QuestDefinition } from '../../quests/entities/quest-definition.entity'; +import { QuestObjective } from '../../quests/entities/quest-objective.entity'; import { EncounterType } from '../../monsters/entities/encounter-type.enum'; import { MonsterCategory } from '../../monsters/monster-category.enum'; import { LocationMonster } from '../../monsters/entities/location-monster.entity'; @@ -43,7 +45,12 @@ import { BURNED_ROAD_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT, } from './local-location.content'; -import { BASIC_HIDE_BAG_ID, LOOT_BAG_DEFINITIONS } from './loot-bag-content'; +import { LOOT_BAG_DEFINITIONS } from './loot-bag-content'; +import { + NPC_QUEST_ASSIGNMENTS, + QUEST_DEFINITIONS, + QUEST_OBJECTIVES, +} from './quest-content'; import { REPUTATION_FACTIONS } from './reputation-content'; import { DIALOGUE_NODES, @@ -88,6 +95,10 @@ export async function seedVisibleVerticalSlice( const npcExchangeProfileRepository = dataSource.getRepository(NpcExchangeProfile); const exchangeRuleRepository = dataSource.getRepository(ExchangeRule); + const questDefinitionRepository = dataSource.getRepository(QuestDefinition); + const questObjectiveRepository = dataSource.getRepository(QuestObjective); + const npcQuestAssignmentRepository = + dataSource.getRepository(NpcQuestAssignment); const locations = [ { @@ -358,28 +369,14 @@ export async function seedVisibleVerticalSlice( }); } - // ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag - // and no longer starts with the Trophy Pouch. + // The demo character starts with no loot bag at all (Slice 0.9 decision D5). // - // The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over - // for free would make the slice's own showcase pointless. The Hide Bag stays - // until Slice 0.9 grants it through the warden's referral -- removing both - // now would drop HIDE capacity to the bagless default of 1 with no way to - // raise it, and the hunting loop would be unplayable in between. - // - // Delete this block once 0.9 hands the Hide Bag over in the quest. - const characterLootBagRepository = dataSource.getRepository(CharacterLootBag); - const existingBag = await characterLootBagRepository.findOneBy({ - characterId: DEMO_CHARACTER_ID, - lootBagDefinitionId: BASIC_HIDE_BAG_ID, - }); - if (!existingBag) { - await characterLootBagRepository.insert({ - characterId: DEMO_CHARACTER_ID, - lootBagDefinitionId: BASIC_HIDE_BAG_ID, - active: true, - }); - } + // Slice 0.8.5 left the Hide Bag here as a stopgap, with a note to remove it + // "once 0.9 hands the Hide Bag over in the quest". This is that moment: the + // bagless HIDE capacity of 1 is what the first quest is built to teach + // (0.9 §4), and a free bag would hide the lesson entirely. The quest's step + // at Borin grants the bag instead, and the migration clears the row from + // databases that were seeded before this change. // NPC content (NPC Specification V1 §32; Playable Slice 0.8). // @@ -401,6 +398,16 @@ export async function seedVisibleVerticalSlice( ]); } + // Quest content (Playable Slice 0.9 §10; NPC spec §14). + // + // After the NPC upsert, because an assignment points at an NPC id. Objectives + // and assignments conflict on `id` rather than on a natural key: an + // objective's key is unique only within its quest, and an assignment has no + // natural key at all beyond the triple its unique index already covers. + await questDefinitionRepository.upsert(QUEST_DEFINITIONS, ['key']); + await questObjectiveRepository.upsert(QUEST_OBJECTIVES, ['id']); + await npcQuestAssignmentRepository.upsert(NPC_QUEST_ASSIGNMENTS, ['id']); + await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']); await npcShopRepository.upsert(NPC_SHOPS, ['key']); // By id, not by (shop, item): an offer may now sell a bag instead of an diff --git a/apps/web/public/images/npcs/borin.png b/apps/web/public/images/npcs/borin.png new file mode 100644 index 0000000..443d80d Binary files /dev/null and b/apps/web/public/images/npcs/borin.png differ diff --git a/apps/web/public/images/npcs/south-gate-warden.png b/apps/web/public/images/npcs/south-gate-warden.png new file mode 100644 index 0000000..46792e4 Binary files /dev/null and b/apps/web/public/images/npcs/south-gate-warden.png differ