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:
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
229
apps/api/src/database/seeds/quest-content.ts
Normal file
229
apps/api/src/database/seeds/quest-content.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
29
apps/api/src/database/seeds/quest.constants.ts
Normal file
29
apps/api/src/database/seeds/quest.constants.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Stable quest content ids and keys (AGENTS.md §8).
|
||||
//
|
||||
// Their own file because both `npc-content.ts` and `quest-content.ts` need
|
||||
// them: the warden's dialogue gates on the quest key, and the quest content
|
||||
// needs the warden's NPC id. Sharing a constants module is how the seeds
|
||||
// already break that kind of knot (see `item.constants.ts`).
|
||||
|
||||
export const TROUBLE_BEYOND_THE_GATE_KEY = 'trouble-beyond-the-gate';
|
||||
export const TROUBLE_BEYOND_THE_GATE_QUEST_ID =
|
||||
'c0000000-0000-4000-8000-000000000001';
|
||||
|
||||
/**
|
||||
* An objective's `key` is only unique within its quest, so it cannot serve as
|
||||
* an upsert conflict target. These ids are what make a re-seed re-tune a step
|
||||
* instead of inserting a second one.
|
||||
*/
|
||||
export const QUEST_OBJECTIVE_IDS = {
|
||||
collectPeltsFirst: 'c1000000-0000-4000-8000-000000000001',
|
||||
reportCapacity: 'c1000000-0000-4000-8000-000000000002',
|
||||
collectBag: 'c1000000-0000-4000-8000-000000000003',
|
||||
collectPelts: 'c1000000-0000-4000-8000-000000000004',
|
||||
turnIn: 'c1000000-0000-4000-8000-000000000005',
|
||||
} as const;
|
||||
|
||||
export const QUEST_ASSIGNMENT_IDS = {
|
||||
wardenOffer: 'c2000000-0000-4000-8000-000000000001',
|
||||
wardenTurnIn: 'c2000000-0000-4000-8000-000000000002',
|
||||
borinProgress: 'c2000000-0000-4000-8000-000000000003',
|
||||
} as const;
|
||||
@@ -31,8 +31,21 @@ import {
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} from './item.constants';
|
||||
import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity';
|
||||
import { QuestDefinition } from '../../quests/entities/quest-definition.entity';
|
||||
import { QuestObjective } from '../../quests/entities/quest-objective.entity';
|
||||
import { NpcQuestRole, QuestObjectiveType } from '../../quests/quest.types';
|
||||
import { BASIC_HIDE_BAG_ID } from './loot-bag-content';
|
||||
import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content';
|
||||
import {
|
||||
BORIN_KEY,
|
||||
BORIN_NPC_ID,
|
||||
BORIN_OFFER_IDS,
|
||||
SHOP_OFFERS,
|
||||
SOUTH_GATE_REFERRAL_FLAG,
|
||||
SOUTH_GATE_WARDEN_KEY,
|
||||
SOUTH_GATE_WARDEN_NPC_ID,
|
||||
} from './npc-content';
|
||||
import { TROUBLE_BEYOND_THE_GATE_KEY } from './quest.constants';
|
||||
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
@@ -92,53 +105,108 @@ class InMemoryRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every table the seed touches, addressed by name.
|
||||
*
|
||||
* Named rather than positional: the seed writes more than twenty tables now,
|
||||
* and a test that only cares about quest objectives should not have to count
|
||||
* out placeholder repositories to reach them.
|
||||
*/
|
||||
interface SeedRepositories {
|
||||
location: InMemoryRepository;
|
||||
connection: InMemoryRepository;
|
||||
character: InMemoryRepository;
|
||||
monster: InMemoryRepository;
|
||||
locationMonster: InMemoryRepository;
|
||||
item: InMemoryRepository;
|
||||
lootTable: InMemoryRepository;
|
||||
lootEntry: InMemoryRepository;
|
||||
characterItem: InMemoryRepository;
|
||||
characterEquipment: InMemoryRepository;
|
||||
reputationFaction: InMemoryRepository;
|
||||
renownMilestone: InMemoryRepository;
|
||||
lootBagDefinition: InMemoryRepository;
|
||||
characterLootBag: InMemoryRepository;
|
||||
npcDefinition: InMemoryRepository;
|
||||
dialogueNode: InMemoryRepository;
|
||||
npcShop: InMemoryRepository;
|
||||
shopOffer: InMemoryRepository;
|
||||
npcExchangeProfile: InMemoryRepository;
|
||||
exchangeRule: InMemoryRepository;
|
||||
questDefinition: InMemoryRepository;
|
||||
questObjective: InMemoryRepository;
|
||||
npcQuestAssignment: InMemoryRepository;
|
||||
}
|
||||
|
||||
const REPOSITORY_KEYS: ReadonlyArray<keyof SeedRepositories> = [
|
||||
'location',
|
||||
'connection',
|
||||
'character',
|
||||
'monster',
|
||||
'locationMonster',
|
||||
'item',
|
||||
'lootTable',
|
||||
'lootEntry',
|
||||
'characterItem',
|
||||
'characterEquipment',
|
||||
'reputationFaction',
|
||||
'renownMilestone',
|
||||
'lootBagDefinition',
|
||||
'characterLootBag',
|
||||
'npcDefinition',
|
||||
'dialogueNode',
|
||||
'npcShop',
|
||||
'shopOffer',
|
||||
'npcExchangeProfile',
|
||||
'exchangeRule',
|
||||
'questDefinition',
|
||||
'questObjective',
|
||||
'npcQuestAssignment',
|
||||
];
|
||||
|
||||
function createDataSource(
|
||||
locationRepository: InMemoryRepository,
|
||||
connectionRepository: InMemoryRepository,
|
||||
characterRepository: InMemoryRepository,
|
||||
monsterRepository: InMemoryRepository,
|
||||
locationMonsterRepository: InMemoryRepository,
|
||||
itemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
reputationFactionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
renownMilestoneRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
lootBagDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
characterLootBagRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
dialogueNodeRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcShopRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
shopOfferRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
npcExchangeProfileRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
exchangeRuleRepository: InMemoryRepository = new InMemoryRepository(),
|
||||
overrides: Partial<SeedRepositories> = {},
|
||||
): DataSource {
|
||||
const repositories = Object.fromEntries(
|
||||
REPOSITORY_KEYS.map((key) => [
|
||||
key,
|
||||
overrides[key] ?? new InMemoryRepository(),
|
||||
]),
|
||||
) as SeedRepositories;
|
||||
|
||||
const byEntity = new Map<unknown, InMemoryRepository>([
|
||||
[LocationDefinition, repositories.location],
|
||||
[LocationConnection, repositories.connection],
|
||||
[Character, repositories.character],
|
||||
[MonsterDefinition, repositories.monster],
|
||||
[LocationMonster, repositories.locationMonster],
|
||||
[ItemDefinition, repositories.item],
|
||||
[LootTable, repositories.lootTable],
|
||||
[LootTableEntry, repositories.lootEntry],
|
||||
[CharacterItem, repositories.characterItem],
|
||||
[CharacterEquipment, repositories.characterEquipment],
|
||||
[ReputationFaction, repositories.reputationFaction],
|
||||
[RenownMilestoneDefinition, repositories.renownMilestone],
|
||||
[NpcDefinition, repositories.npcDefinition],
|
||||
[DialogueNode, repositories.dialogueNode],
|
||||
[NpcShop, repositories.npcShop],
|
||||
[ShopOffer, repositories.shopOffer],
|
||||
[NpcExchangeProfile, repositories.npcExchangeProfile],
|
||||
[ExchangeRule, repositories.exchangeRule],
|
||||
[LootBagDefinition, repositories.lootBagDefinition],
|
||||
[CharacterLootBag, repositories.characterLootBag],
|
||||
[QuestDefinition, repositories.questDefinition],
|
||||
[QuestObjective, repositories.questObjective],
|
||||
[NpcQuestAssignment, repositories.npcQuestAssignment],
|
||||
]);
|
||||
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === LocationDefinition) return locationRepository;
|
||||
if (entity === LocationConnection) return connectionRepository;
|
||||
if (entity === Character) return characterRepository;
|
||||
if (entity === MonsterDefinition) return monsterRepository;
|
||||
if (entity === LocationMonster) return locationMonsterRepository;
|
||||
if (entity === ItemDefinition) return itemRepository;
|
||||
if (entity === LootTable) return lootTableRepository;
|
||||
if (entity === LootTableEntry) return lootEntryRepository;
|
||||
if (entity === CharacterItem) return characterItemRepository;
|
||||
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
||||
if (entity === ReputationFaction) return reputationFactionRepository;
|
||||
if (entity === RenownMilestoneDefinition)
|
||||
return renownMilestoneRepository;
|
||||
if (entity === NpcDefinition) return npcDefinitionRepository;
|
||||
if (entity === DialogueNode) return dialogueNodeRepository;
|
||||
if (entity === NpcShop) return npcShopRepository;
|
||||
if (entity === ShopOffer) return shopOfferRepository;
|
||||
if (entity === NpcExchangeProfile) return npcExchangeProfileRepository;
|
||||
if (entity === ExchangeRule) return exchangeRuleRepository;
|
||||
if (entity === LootBagDefinition) return lootBagDefinitionRepository;
|
||||
if (entity === CharacterLootBag) return characterLootBagRepository;
|
||||
|
||||
throw new Error('Unexpected repository');
|
||||
const repository = byEntity.get(entity);
|
||||
if (!repository) {
|
||||
throw new Error('Unexpected repository');
|
||||
}
|
||||
return repository;
|
||||
}),
|
||||
} as unknown as DataSource;
|
||||
}
|
||||
@@ -150,13 +218,13 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
const characterRepository = new InMemoryRepository();
|
||||
const monsterRepository = new InMemoryRepository();
|
||||
const locationMonsterRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
);
|
||||
const dataSource = createDataSource({
|
||||
location: locationRepository,
|
||||
connection: connectionRepository,
|
||||
character: characterRepository,
|
||||
monster: monsterRepository,
|
||||
locationMonster: locationMonsterRepository,
|
||||
});
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
Object.assign(characterRepository.rows[0], {
|
||||
@@ -308,13 +376,9 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
|
||||
it('seeds the local view content of the Burned Road with four points of interest', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
);
|
||||
const dataSource = createDataSource({
|
||||
location: locationRepository,
|
||||
});
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
@@ -364,13 +428,9 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
|
||||
it('gives the South Gate its own local content so a second location needs no new component', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
);
|
||||
const dataSource = createDataSource({
|
||||
location: locationRepository,
|
||||
});
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
@@ -406,13 +466,13 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
key: 'south-gate',
|
||||
name: 'Outdated South Gate',
|
||||
});
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
);
|
||||
const dataSource = createDataSource({
|
||||
location: locationRepository,
|
||||
connection: connectionRepository,
|
||||
character: characterRepository,
|
||||
monster: monsterRepository,
|
||||
locationMonster: locationMonsterRepository,
|
||||
});
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
@@ -458,16 +518,16 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
const itemRepository = new InMemoryRepository();
|
||||
const lootTableRepository = new InMemoryRepository();
|
||||
const lootEntryRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
connectionRepository,
|
||||
characterRepository,
|
||||
monsterRepository,
|
||||
locationMonsterRepository,
|
||||
itemRepository,
|
||||
lootTableRepository,
|
||||
lootEntryRepository,
|
||||
);
|
||||
const dataSource = createDataSource({
|
||||
location: locationRepository,
|
||||
connection: connectionRepository,
|
||||
character: characterRepository,
|
||||
monster: monsterRepository,
|
||||
locationMonster: locationMonsterRepository,
|
||||
item: itemRepository,
|
||||
lootTable: lootTableRepository,
|
||||
lootEntry: lootEntryRepository,
|
||||
});
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
apps/web/public/images/npcs/borin.png
Normal file
BIN
apps/web/public/images/npcs/borin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/web/public/images/npcs/south-gate-warden.png
Normal file
BIN
apps/web/public/images/npcs/south-gate-warden.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Reference in New Issue
Block a user