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:
|
resultText:
|
||||||
'Weathered notices flutter in the wind. A fresh one warns of raiders on the Burned Road and promises silver for every bandit killed.',
|
'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',
|
key: 'gate-watch',
|
||||||
title: 'Gate Watch',
|
title: 'Halvik, Warden of the South Gate',
|
||||||
actionLabel: 'Talk',
|
actionLabel: 'Talk',
|
||||||
type: 'NPC',
|
type: 'NPC',
|
||||||
iconKey: 'speak',
|
iconKey: 'speak',
|
||||||
xPercent: 45,
|
xPercent: 45,
|
||||||
yPercent: 52,
|
yPercent: 52,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
resultTitle: 'Gate Watch',
|
npcKey: 'south-gate-warden',
|
||||||
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."',
|
|
||||||
},
|
},
|
||||||
// Borin stands at the gate rather than deeper in a town that does not
|
// 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
|
// exist yet as a location. Carries `npcKey` instead of result text, so
|
||||||
@@ -200,12 +201,13 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
|
|||||||
localPrimaryActions: [
|
localPrimaryActions: [
|
||||||
{
|
{
|
||||||
key: 'talk-to-watch',
|
key: 'talk-to-watch',
|
||||||
label: 'Talk to the watch',
|
label: 'Talk to the warden',
|
||||||
description: 'Ask about the situation',
|
description: 'Ask about the road south',
|
||||||
type: 'NPC',
|
type: 'NPC',
|
||||||
iconKey: 'speak',
|
iconKey: 'speak',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
poiKey: 'gate-watch',
|
poiKey: 'gate-watch',
|
||||||
|
npcKey: 'south-gate-warden',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'trade-with-borin',
|
key: 'trade-with-borin',
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import type {
|
|||||||
} from '../../npcs/npc.types';
|
} from '../../npcs/npc.types';
|
||||||
import { ITEM_IDS } from './item.constants';
|
import { ITEM_IDS } from './item.constants';
|
||||||
import { BASIC_HIDE_BAG_ID, BASIC_TROPHY_POUCH_ID } from './loot-bag-content';
|
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';
|
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
|
||||||
|
|
||||||
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001';
|
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_SHOP_ID = 'b1000000-0000-4000-8000-000000000001';
|
||||||
export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001';
|
export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
export const BORIN_KEY = 'borin-quartermaster';
|
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_SHOP_KEY = 'borin-supplies';
|
||||||
export const BORIN_EXCHANGE_KEY = 'borin-trade-in';
|
export const BORIN_EXCHANGE_KEY = 'borin-trade-in';
|
||||||
|
|
||||||
@@ -114,6 +117,42 @@ export const NPC_DEFINITIONS: SeedNpcDefinition[] = [
|
|||||||
],
|
],
|
||||||
enabled: true,
|
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 {
|
export interface SeedDialogueNode {
|
||||||
@@ -182,6 +221,78 @@ export const DIALOGUE_NODES: SeedDialogueNode[] = [
|
|||||||
responses: [],
|
responses: [],
|
||||||
enabled: true,
|
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 {
|
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,
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||||
} from './item.constants';
|
} 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 { 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';
|
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
|
||||||
|
|
||||||
type Row = Record<string, unknown>;
|
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(
|
function createDataSource(
|
||||||
locationRepository: InMemoryRepository,
|
overrides: Partial<SeedRepositories> = {},
|
||||||
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(),
|
|
||||||
): DataSource {
|
): 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 {
|
return {
|
||||||
getRepository: jest.fn((entity: unknown) => {
|
getRepository: jest.fn((entity: unknown) => {
|
||||||
if (entity === LocationDefinition) return locationRepository;
|
const repository = byEntity.get(entity);
|
||||||
if (entity === LocationConnection) return connectionRepository;
|
if (!repository) {
|
||||||
if (entity === Character) return characterRepository;
|
throw new Error('Unexpected repository');
|
||||||
if (entity === MonsterDefinition) return monsterRepository;
|
}
|
||||||
if (entity === LocationMonster) return locationMonsterRepository;
|
return repository;
|
||||||
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');
|
|
||||||
}),
|
}),
|
||||||
} as unknown as DataSource;
|
} as unknown as DataSource;
|
||||||
}
|
}
|
||||||
@@ -150,13 +218,13 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
const characterRepository = new InMemoryRepository();
|
const characterRepository = new InMemoryRepository();
|
||||||
const monsterRepository = new InMemoryRepository();
|
const monsterRepository = new InMemoryRepository();
|
||||||
const locationMonsterRepository = new InMemoryRepository();
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
locationRepository,
|
location: locationRepository,
|
||||||
connectionRepository,
|
connection: connectionRepository,
|
||||||
characterRepository,
|
character: characterRepository,
|
||||||
monsterRepository,
|
monster: monsterRepository,
|
||||||
locationMonsterRepository,
|
locationMonster: locationMonsterRepository,
|
||||||
);
|
});
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
Object.assign(characterRepository.rows[0], {
|
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 () => {
|
it('seeds the local view content of the Burned Road with four points of interest', async () => {
|
||||||
const locationRepository = new InMemoryRepository();
|
const locationRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
locationRepository,
|
location: locationRepository,
|
||||||
new InMemoryRepository(),
|
});
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
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 () => {
|
it('gives the South Gate its own local content so a second location needs no new component', async () => {
|
||||||
const locationRepository = new InMemoryRepository();
|
const locationRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
locationRepository,
|
location: locationRepository,
|
||||||
new InMemoryRepository(),
|
});
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
@@ -406,13 +466,13 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
key: 'south-gate',
|
key: 'south-gate',
|
||||||
name: 'Outdated South Gate',
|
name: 'Outdated South Gate',
|
||||||
});
|
});
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
locationRepository,
|
location: locationRepository,
|
||||||
connectionRepository,
|
connection: connectionRepository,
|
||||||
characterRepository,
|
character: characterRepository,
|
||||||
monsterRepository,
|
monster: monsterRepository,
|
||||||
locationMonsterRepository,
|
locationMonster: locationMonsterRepository,
|
||||||
);
|
});
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
@@ -458,16 +518,16 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
const itemRepository = new InMemoryRepository();
|
const itemRepository = new InMemoryRepository();
|
||||||
const lootTableRepository = new InMemoryRepository();
|
const lootTableRepository = new InMemoryRepository();
|
||||||
const lootEntryRepository = new InMemoryRepository();
|
const lootEntryRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
locationRepository,
|
location: locationRepository,
|
||||||
connectionRepository,
|
connection: connectionRepository,
|
||||||
characterRepository,
|
character: characterRepository,
|
||||||
monsterRepository,
|
monster: monsterRepository,
|
||||||
locationMonsterRepository,
|
locationMonster: locationMonsterRepository,
|
||||||
itemRepository,
|
item: itemRepository,
|
||||||
lootTableRepository,
|
lootTable: lootTableRepository,
|
||||||
lootEntryRepository,
|
lootEntry: lootEntryRepository,
|
||||||
);
|
});
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
@@ -605,21 +665,9 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
|
|
||||||
it('seeds both starter loot bags idempotently', async () => {
|
it('seeds both starter loot bags idempotently', async () => {
|
||||||
const lootBagDefinitionRepository = new InMemoryRepository();
|
const lootBagDefinitionRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
new InMemoryRepository(),
|
lootBagDefinition: lootBagDefinitionRepository,
|
||||||
new InMemoryRepository(),
|
});
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
lootBagDefinitionRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
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 characterLootBagRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
new InMemoryRepository(),
|
characterLootBag: characterLootBagRepository,
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
// Slice 0.8.5 decision: the Trophy Pouch is now a reputation-gated offer,
|
// Slice 0.9 decision D5. The Hide Bag was a stopgap: Slice 0.8.5 turned the
|
||||||
// so handing it to the demo character for free would undercut the
|
// Trophy Pouch into a reputation-gated offer and left the Hide Bag in the
|
||||||
// showcase. The Hide Bag stays -- without it, HIDE capacity would drop to
|
// seed so the hunting loop stayed playable until a quest could hand one
|
||||||
// the bagless default of 1 with no way to raise it before Slice 0.9 grants
|
// over. That quest exists now, and the default HIDE capacity of 1 is the
|
||||||
// it through the warden's referral. See the ASSUMPTION note in the seed.
|
// whole lesson the chain teaches (slice §4) -- a free bag would hide it.
|
||||||
expect(characterLootBagRepository.rows).toHaveLength(1);
|
expect(characterLootBagRepository.rows).toHaveLength(0);
|
||||||
expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([
|
|
||||||
true,
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId),
|
|
||||||
).toEqual(['a0000000-0000-4000-8000-000000000001']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
|
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
|
||||||
@@ -687,18 +716,15 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
const locationMonsterRepository = new InMemoryRepository();
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
const characterItemRepository = new InMemoryRepository();
|
const characterItemRepository = new InMemoryRepository();
|
||||||
const characterEquipmentRepository = new InMemoryRepository();
|
const characterEquipmentRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
locationRepository,
|
location: locationRepository,
|
||||||
connectionRepository,
|
connection: connectionRepository,
|
||||||
characterRepository,
|
character: characterRepository,
|
||||||
monsterRepository,
|
monster: monsterRepository,
|
||||||
locationMonsterRepository,
|
locationMonster: locationMonsterRepository,
|
||||||
new InMemoryRepository(),
|
characterItem: characterItemRepository,
|
||||||
new InMemoryRepository(),
|
characterEquipment: characterEquipmentRepository,
|
||||||
new InMemoryRepository(),
|
});
|
||||||
characterItemRepository,
|
|
||||||
characterEquipmentRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
@@ -729,18 +755,15 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
const locationMonsterRepository = new InMemoryRepository();
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
const characterItemRepository = new InMemoryRepository();
|
const characterItemRepository = new InMemoryRepository();
|
||||||
const characterEquipmentRepository = new InMemoryRepository();
|
const characterEquipmentRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
locationRepository,
|
location: locationRepository,
|
||||||
connectionRepository,
|
connection: connectionRepository,
|
||||||
characterRepository,
|
character: characterRepository,
|
||||||
monsterRepository,
|
monster: monsterRepository,
|
||||||
locationMonsterRepository,
|
locationMonster: locationMonsterRepository,
|
||||||
new InMemoryRepository(),
|
characterItem: characterItemRepository,
|
||||||
new InMemoryRepository(),
|
characterEquipment: characterEquipmentRepository,
|
||||||
new InMemoryRepository(),
|
});
|
||||||
characterItemRepository,
|
|
||||||
characterEquipmentRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
// Simulate the player having equipped earned loot instead.
|
// Simulate the player having equipped earned loot instead.
|
||||||
@@ -764,18 +787,15 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
const locationMonsterRepository = new InMemoryRepository();
|
const locationMonsterRepository = new InMemoryRepository();
|
||||||
const characterItemRepository = new InMemoryRepository();
|
const characterItemRepository = new InMemoryRepository();
|
||||||
const characterEquipmentRepository = new InMemoryRepository();
|
const characterEquipmentRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
locationRepository,
|
location: locationRepository,
|
||||||
connectionRepository,
|
connection: connectionRepository,
|
||||||
characterRepository,
|
character: characterRepository,
|
||||||
monsterRepository,
|
monster: monsterRepository,
|
||||||
locationMonsterRepository,
|
locationMonster: locationMonsterRepository,
|
||||||
new InMemoryRepository(),
|
characterItem: characterItemRepository,
|
||||||
new InMemoryRepository(),
|
characterEquipment: characterEquipmentRepository,
|
||||||
new InMemoryRepository(),
|
});
|
||||||
characterItemRepository,
|
|
||||||
characterEquipmentRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate the demo character having already looted a worn-short-sword
|
// Simulate the demo character having already looted a worn-short-sword
|
||||||
// naturally, under a DB-generated id that differs from the seed's
|
// 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 () => {
|
it('seeds the Border Watch faction', async () => {
|
||||||
const reputationFactionRepository = new InMemoryRepository();
|
const reputationFactionRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
new InMemoryRepository(),
|
reputationFaction: reputationFactionRepository,
|
||||||
new InMemoryRepository(),
|
});
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
reputationFactionRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
@@ -830,40 +840,24 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
const npcDefinitionRepository = new InMemoryRepository();
|
const npcDefinitionRepository = new InMemoryRepository();
|
||||||
const npcShopRepository = new InMemoryRepository();
|
const npcShopRepository = new InMemoryRepository();
|
||||||
const exchangeRuleRepository = new InMemoryRepository();
|
const exchangeRuleRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
new InMemoryRepository(),
|
npcDefinition: npcDefinitionRepository,
|
||||||
new InMemoryRepository(),
|
npcShop: npcShopRepository,
|
||||||
new InMemoryRepository(),
|
exchangeRule: exchangeRuleRepository,
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Twice: seeds must be idempotent (NPC spec §32).
|
// Twice: seeds must be idempotent (NPC spec §32).
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
expect(npcDefinitionRepository.rows).toHaveLength(1);
|
// Looked up by key rather than by position: Slice 0.9 put the warden at the
|
||||||
expect(npcDefinitionRepository.rows[0]).toMatchObject({
|
// same gate, so "the first row" stopped meaning "Borin".
|
||||||
key: 'borin-quartermaster',
|
const borin = npcDefinitionRepository.rows.find(
|
||||||
enabled: true,
|
(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.
|
// 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);
|
expect(npcShopRepository.rows).toHaveLength(1);
|
||||||
|
|
||||||
// All four Burned Road trade goods are accepted (slice 0.8 §5), and the
|
// 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 () => {
|
it('seeds the renown milestone the first trade-in completes', async () => {
|
||||||
const renownMilestoneRepository = new InMemoryRepository();
|
const renownMilestoneRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
new InMemoryRepository(),
|
renownMilestone: renownMilestoneRepository,
|
||||||
new InMemoryRepository(),
|
});
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
new InMemoryRepository(),
|
|
||||||
renownMilestoneRepository,
|
|
||||||
);
|
|
||||||
|
|
||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
|
|
||||||
@@ -962,26 +945,9 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
|
|
||||||
it('still holds exactly five offers after a re-seed, at the tuned numbers', async () => {
|
it('still holds exactly five offers after a re-seed, at the tuned numbers', async () => {
|
||||||
const shopOfferRepository = new InMemoryRepository();
|
const shopOfferRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource(
|
const dataSource = createDataSource({
|
||||||
new InMemoryRepository(),
|
shopOffer: shopOfferRepository,
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Twice, because that is the whole point of the stable ids (NPC spec §32).
|
// 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
|
// 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 { ItemDefinition } from '../../items/entities/item-definition.entity';
|
||||||
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
|
||||||
import { LootTable } from '../../loot/entities/loot-table.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 { 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 { EncounterType } from '../../monsters/entities/encounter-type.enum';
|
||||||
import { MonsterCategory } from '../../monsters/monster-category.enum';
|
import { MonsterCategory } from '../../monsters/monster-category.enum';
|
||||||
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
|
||||||
@@ -43,7 +45,12 @@ import {
|
|||||||
BURNED_ROAD_LOCAL_CONTENT,
|
BURNED_ROAD_LOCAL_CONTENT,
|
||||||
SOUTH_GATE_LOCAL_CONTENT,
|
SOUTH_GATE_LOCAL_CONTENT,
|
||||||
} from './local-location.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 { REPUTATION_FACTIONS } from './reputation-content';
|
||||||
import {
|
import {
|
||||||
DIALOGUE_NODES,
|
DIALOGUE_NODES,
|
||||||
@@ -88,6 +95,10 @@ export async function seedVisibleVerticalSlice(
|
|||||||
const npcExchangeProfileRepository =
|
const npcExchangeProfileRepository =
|
||||||
dataSource.getRepository(NpcExchangeProfile);
|
dataSource.getRepository(NpcExchangeProfile);
|
||||||
const exchangeRuleRepository = dataSource.getRepository(ExchangeRule);
|
const exchangeRuleRepository = dataSource.getRepository(ExchangeRule);
|
||||||
|
const questDefinitionRepository = dataSource.getRepository(QuestDefinition);
|
||||||
|
const questObjectiveRepository = dataSource.getRepository(QuestObjective);
|
||||||
|
const npcQuestAssignmentRepository =
|
||||||
|
dataSource.getRepository(NpcQuestAssignment);
|
||||||
|
|
||||||
const locations = [
|
const locations = [
|
||||||
{
|
{
|
||||||
@@ -358,28 +369,14 @@ export async function seedVisibleVerticalSlice(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ASSUMPTION (Slice 0.8.5 decision): the demo character keeps the Hide Bag
|
// The demo character starts with no loot bag at all (Slice 0.9 decision D5).
|
||||||
// and no longer starts with the Trophy Pouch.
|
|
||||||
//
|
//
|
||||||
// The pouch is now a reputation-gated offer (0.8.5 §4) and handing it over
|
// Slice 0.8.5 left the Hide Bag here as a stopgap, with a note to remove it
|
||||||
// for free would make the slice's own showcase pointless. The Hide Bag stays
|
// "once 0.9 hands the Hide Bag over in the quest". This is that moment: the
|
||||||
// until Slice 0.9 grants it through the warden's referral -- removing both
|
// bagless HIDE capacity of 1 is what the first quest is built to teach
|
||||||
// now would drop HIDE capacity to the bagless default of 1 with no way to
|
// (0.9 §4), and a free bag would hide the lesson entirely. The quest's step
|
||||||
// raise it, and the hunting loop would be unplayable in between.
|
// at Borin grants the bag instead, and the migration clears the row from
|
||||||
//
|
// databases that were seeded before this change.
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// NPC content (NPC Specification V1 §32; Playable Slice 0.8).
|
// 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 dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
|
||||||
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
|
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
|
||||||
// By id, not by (shop, item): an offer may now sell a bag instead of an
|
// 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