614 lines
23 KiB
TypeScript
614 lines
23 KiB
TypeScript
import { DataSource } from 'typeorm';
|
|
import {
|
|
DEMO_CHARACTER_ID,
|
|
DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
|
|
DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
|
} from '../../demo/demo-character.constants';
|
|
import { Character } from '../../characters/entities/character.entity';
|
|
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
|
import { CharacterItem } from '../../items/entities/character-item.entity';
|
|
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 { 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';
|
|
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
|
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
|
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
|
|
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
|
|
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
|
|
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
|
|
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
|
|
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
|
|
import { NpcShop } from '../../shops/entities/npc-shop.entity';
|
|
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
|
|
import {
|
|
ITEM_DEFINITIONS,
|
|
LOOT_TABLES,
|
|
LOOT_TABLE_ENTRIES,
|
|
} from './item-content';
|
|
import {
|
|
ASH_RAT_LOOT_TABLE_ID,
|
|
BURNED_HOUND_LOOT_TABLE_ID,
|
|
CHARRED_LOOTER_LOOT_TABLE_ID,
|
|
ITEM_IDS,
|
|
RAIDER_CAPTAIN_LOOT_TABLE_ID,
|
|
RAIDER_SCOUT_LOOT_TABLE_ID,
|
|
RAIDER_VETERAN_LOOT_TABLE_ID,
|
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
|
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
|
} from './item.constants';
|
|
import {
|
|
ABANDONED_WATCHPOST_LOCAL_CONTENT,
|
|
ASH_PIT_LOCAL_CONTENT,
|
|
BURNED_ROAD_LOCAL_CONTENT,
|
|
SOUTH_GATE_LOCAL_CONTENT,
|
|
} from './local-location.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,
|
|
EXCHANGE_RULES,
|
|
NPC_DEFINITIONS,
|
|
NPC_EXCHANGE_PROFILES,
|
|
NPC_SHOPS,
|
|
RENOWN_MILESTONES,
|
|
SHOP_OFFERS,
|
|
} from './npc-content';
|
|
import {
|
|
ABANDONED_WATCHPOST_ID,
|
|
ASH_PIT_ID,
|
|
ASH_RAT_MONSTER_ID,
|
|
BURNED_HOUND_MONSTER_ID,
|
|
BURNED_ROAD_ID,
|
|
CHARRED_LOOTER_MONSTER_ID,
|
|
RAIDER_CAPTAIN_MONSTER_ID,
|
|
RAIDER_SCOUT_MONSTER_ID,
|
|
RAIDER_VETERAN_MONSTER_ID,
|
|
ROAD_BANDIT_MONSTER_ID,
|
|
SOUTH_GATE_ID,
|
|
WILD_ROAD_DOG_MONSTER_ID,
|
|
} from './vertical-slice.constants';
|
|
|
|
export async function seedVisibleVerticalSlice(
|
|
dataSource: DataSource,
|
|
): Promise<void> {
|
|
const locationRepository = dataSource.getRepository(LocationDefinition);
|
|
const connectionRepository = dataSource.getRepository(LocationConnection);
|
|
const characterRepository = dataSource.getRepository(Character);
|
|
const monsterRepository = dataSource.getRepository(MonsterDefinition);
|
|
const locationMonsterRepository = dataSource.getRepository(LocationMonster);
|
|
const itemRepository = dataSource.getRepository(ItemDefinition);
|
|
const lootTableRepository = dataSource.getRepository(LootTable);
|
|
const lootEntryRepository = dataSource.getRepository(LootTableEntry);
|
|
const lootBagDefinitionRepository =
|
|
dataSource.getRepository(LootBagDefinition);
|
|
const reputationFactionRepository =
|
|
dataSource.getRepository(ReputationFaction);
|
|
const renownMilestoneRepository = dataSource.getRepository(
|
|
RenownMilestoneDefinition,
|
|
);
|
|
const npcDefinitionRepository = dataSource.getRepository(NpcDefinition);
|
|
const dialogueNodeRepository = dataSource.getRepository(DialogueNode);
|
|
const npcShopRepository = dataSource.getRepository(NpcShop);
|
|
const shopOfferRepository = dataSource.getRepository(ShopOffer);
|
|
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 = [
|
|
{
|
|
id: SOUTH_GATE_ID,
|
|
key: 'south-gate',
|
|
name: 'Graufurt South Gate',
|
|
description:
|
|
"At the black South Gate, Graufurt's protection ends. Beyond the watch-fires begins the silent expanse of the Ashen Fields.",
|
|
regionKey: 'ashen-fields',
|
|
minRecommendedLevel: 1,
|
|
maxRecommendedLevel: 1,
|
|
dangerLevel: 0,
|
|
isSafe: true,
|
|
huntingEnabled: false,
|
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
|
...SOUTH_GATE_LOCAL_CONTENT,
|
|
},
|
|
{
|
|
id: BURNED_ROAD_ID,
|
|
key: 'burned-road',
|
|
name: 'Burned Road',
|
|
description:
|
|
'The old trade road cuts through charred fields. Among the ash and broken wagons, the first dangers wait.',
|
|
regionKey: 'ashen-fields',
|
|
minRecommendedLevel: 1,
|
|
maxRecommendedLevel: 2,
|
|
dangerLevel: 1,
|
|
isSafe: false,
|
|
huntingEnabled: true,
|
|
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
|
...BURNED_ROAD_LOCAL_CONTENT,
|
|
},
|
|
{
|
|
id: ABANDONED_WATCHPOST_ID,
|
|
key: 'abandoned-watchpost',
|
|
name: 'Abandoned Watchpost',
|
|
description:
|
|
'A border tower the Watch gave up on. Whoever holds it now is watching the road, not guarding it.',
|
|
regionKey: 'ashen-fields',
|
|
minRecommendedLevel: 2,
|
|
maxRecommendedLevel: 3,
|
|
dangerLevel: 2,
|
|
isSafe: false,
|
|
huntingEnabled: true,
|
|
artworkPath: '/images/backgrounds/Wachturm.png',
|
|
...ABANDONED_WATCHPOST_LOCAL_CONTENT,
|
|
},
|
|
{
|
|
id: ASH_PIT_ID,
|
|
key: 'ash-pit',
|
|
name: 'Ash Pit',
|
|
description:
|
|
'The old ash excavation east of the watchpost, cut in terraces and still being worked by someone.',
|
|
regionKey: 'ashen-fields',
|
|
minRecommendedLevel: 3,
|
|
maxRecommendedLevel: 4,
|
|
dangerLevel: 3,
|
|
isSafe: false,
|
|
huntingEnabled: false,
|
|
artworkPath: '/images/backgrounds/Aschengrube.png',
|
|
...ASH_PIT_LOCAL_CONTENT,
|
|
},
|
|
];
|
|
|
|
const locationIds = new Map<string, string>();
|
|
for (const location of locations) {
|
|
const existing = await locationRepository.findOneBy({ key: location.key });
|
|
const { id, key, ...definition } = location;
|
|
|
|
if (existing) {
|
|
await locationRepository.update(existing.id, definition);
|
|
} else {
|
|
await locationRepository.insert(location);
|
|
}
|
|
|
|
locationIds.set(key, existing?.id ?? id);
|
|
}
|
|
|
|
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
|
|
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
|
|
const watchpostId =
|
|
locationIds.get('abandoned-watchpost') ?? ABANDONED_WATCHPOST_ID;
|
|
const ashPitId = locationIds.get('ash-pit') ?? ASH_PIT_ID;
|
|
|
|
await connectionRepository.upsert(
|
|
[
|
|
{
|
|
fromLocationId: southGateId,
|
|
toLocationId: burnedRoadId,
|
|
travelDurationSeconds: 10,
|
|
ambushChance: '0.0500',
|
|
enabled: true,
|
|
requiresDiscovery: false,
|
|
},
|
|
{
|
|
fromLocationId: burnedRoadId,
|
|
toLocationId: southGateId,
|
|
travelDurationSeconds: 10,
|
|
ambushChance: '0.0500',
|
|
enabled: true,
|
|
requiresDiscovery: false,
|
|
},
|
|
{
|
|
fromLocationId: burnedRoadId,
|
|
toLocationId: watchpostId,
|
|
travelDurationSeconds: 15,
|
|
ambushChance: '0.1000',
|
|
enabled: true,
|
|
requiresDiscovery: false,
|
|
},
|
|
{
|
|
fromLocationId: watchpostId,
|
|
toLocationId: burnedRoadId,
|
|
travelDurationSeconds: 15,
|
|
ambushChance: '0.1000',
|
|
enabled: true,
|
|
requiresDiscovery: false,
|
|
},
|
|
// The one gated route in the game (slice §9). Discovery, not a level,
|
|
// is what opens it -- and the way back is never gated, so a character
|
|
// who walked in can always walk out.
|
|
{
|
|
fromLocationId: watchpostId,
|
|
toLocationId: ashPitId,
|
|
travelDurationSeconds: 20,
|
|
ambushChance: '0.1500',
|
|
enabled: true,
|
|
requiresDiscovery: true,
|
|
},
|
|
{
|
|
fromLocationId: ashPitId,
|
|
toLocationId: watchpostId,
|
|
travelDurationSeconds: 20,
|
|
ambushChance: '0.1500',
|
|
enabled: true,
|
|
requiresDiscovery: false,
|
|
},
|
|
],
|
|
['fromLocationId', 'toLocationId'],
|
|
);
|
|
|
|
// Content is upserted by its stable key so re-running never duplicates rows
|
|
// and never touches player-owned character_items or combat_rewards.
|
|
await itemRepository.upsert(ITEM_DEFINITIONS, ['key']);
|
|
await lootTableRepository.upsert(LOOT_TABLES, ['key']);
|
|
await lootEntryRepository.upsert(LOOT_TABLE_ENTRIES, [
|
|
'lootTableId',
|
|
'itemDefinitionId',
|
|
]);
|
|
await lootBagDefinitionRepository.upsert(LOOT_BAG_DEFINITIONS, ['key']);
|
|
await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']);
|
|
await renownMilestoneRepository.upsert(RENOWN_MILESTONES, ['key']);
|
|
|
|
// Burned Road roster (Playable Slice 0.7 V2 §2, §4). Each enemy has to be
|
|
// mechanically and economically distinct, so the differences live in
|
|
// `abilities` (what it does in a fight) and `lootTableId` (what it is worth)
|
|
// rather than in engine or UI branches.
|
|
const monsters = [
|
|
{
|
|
id: ASH_RAT_MONSTER_ID,
|
|
key: 'ash-rat',
|
|
name: 'Ash Rat',
|
|
monsterCategory: MonsterCategory.BEAST,
|
|
level: 1,
|
|
maxHp: 45,
|
|
attack: 5,
|
|
armor: 1,
|
|
flavorText: 'Scrawny and quick, it feeds on whatever the fires left.',
|
|
// Pure baseline combat: no telegraph, no status effect, short fight.
|
|
abilities: {},
|
|
artworkPath: '/images/monsters/ash-rat.png',
|
|
iconPath: '/images/combat/icons/ash-rat-128.png',
|
|
lootTableId: ASH_RAT_LOOT_TABLE_ID,
|
|
},
|
|
{
|
|
id: WILD_ROAD_DOG_MONSTER_ID,
|
|
key: 'wild-road-dog',
|
|
name: 'Feral Road Hound',
|
|
monsterCategory: MonsterCategory.BEAST,
|
|
level: 1,
|
|
maxHp: 55,
|
|
attack: 7,
|
|
armor: 1,
|
|
flavorText:
|
|
'It stopped waiting for scraps a long time ago. Its bites do not close.',
|
|
// Introduces Bleeding: every third round its bite also opens a wound
|
|
// that ticks for two rounds. Windows of clean rounds in between keep
|
|
// the effect readable instead of permanent.
|
|
abilities: {
|
|
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
|
|
},
|
|
artworkPath: '/images/monsters/wild-road-dog.png',
|
|
iconPath: '/images/combat/icons/wild-road-dog-128.png',
|
|
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
|
|
},
|
|
{
|
|
id: ROAD_BANDIT_MONSTER_ID,
|
|
key: 'road-bandit',
|
|
name: 'Road Bandit',
|
|
monsterCategory: MonsterCategory.HUMANOID,
|
|
level: 2,
|
|
maxHp: 75,
|
|
attack: 12,
|
|
armor: 7,
|
|
flavorText:
|
|
'This one has done it before, and winds up the swing where you can see it.',
|
|
// Reinforces telegraphing: winds up a Heavy Strike every third round,
|
|
// which Shield Bash can interrupt and Defend can blunt.
|
|
abilities: {
|
|
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
|
|
},
|
|
artworkPath: '/images/monsters/road-bandit.png',
|
|
iconPath: '/images/combat/icons/road-bandit-128.png',
|
|
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
|
|
},
|
|
{
|
|
id: CHARRED_LOOTER_MONSTER_ID,
|
|
key: 'charred-looter',
|
|
name: 'Charred Raider',
|
|
monsterCategory: MonsterCategory.HUMANOID,
|
|
level: 2,
|
|
maxHp: 85,
|
|
attack: 15,
|
|
armor: 7,
|
|
flavorText:
|
|
'Burned armor, a burned face, and no interest at all in your reasons.',
|
|
// The rare encounter: no new subsystem, just the known telegraph on a
|
|
// tighter cadence on top of higher stats. It is winding up almost
|
|
// constantly, so the player has to interrupt or brace.
|
|
abilities: {
|
|
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
|
|
},
|
|
artworkPath: '/images/monsters/charred-looter.png',
|
|
iconPath: '/images/combat/icons/charred-looter-128.png',
|
|
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
|
|
},
|
|
{
|
|
id: RAIDER_SCOUT_MONSTER_ID,
|
|
key: 'raider-scout',
|
|
name: 'Raider Scout',
|
|
monsterCategory: MonsterCategory.HUMANOID,
|
|
level: 2,
|
|
maxHp: 70,
|
|
attack: 12,
|
|
armor: 3,
|
|
flavorText:
|
|
'Light on their feet and already backing away from the fight they started.',
|
|
// The farming target: no mechanic at all, so the pool has somewhere
|
|
// for a player to breathe between the harder fights (slice §5).
|
|
abilities: {},
|
|
artworkPath: '/images/monsters/raider-scout.png',
|
|
iconPath: '/images/combat/icons/raider-scout-128.png',
|
|
lootTableId: RAIDER_SCOUT_LOOT_TABLE_ID,
|
|
},
|
|
{
|
|
id: BURNED_HOUND_MONSTER_ID,
|
|
key: 'burned-hound',
|
|
name: 'Burned Hound',
|
|
monsterCategory: MonsterCategory.BEAST,
|
|
level: 3,
|
|
maxHp: 80,
|
|
attack: 17,
|
|
armor: 2,
|
|
flavorText:
|
|
'The fire took its coat and left the cracks glowing underneath.',
|
|
// Bleeding on a tighter cadence than the road hound, plus a rage that
|
|
// punishes a player who lets the fight run long (slice §5).
|
|
abilities: {
|
|
bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 },
|
|
enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 },
|
|
},
|
|
artworkPath: '/images/monsters/burned-hound.png',
|
|
iconPath: '/images/combat/icons/burned-hound-128.png',
|
|
lootTableId: BURNED_HOUND_LOOT_TABLE_ID,
|
|
},
|
|
{
|
|
id: RAIDER_VETERAN_MONSTER_ID,
|
|
key: 'raider-veteran',
|
|
name: 'Raider Veteran',
|
|
monsterCategory: MonsterCategory.HUMANOID,
|
|
level: 3,
|
|
maxHp: 120,
|
|
attack: 16,
|
|
armor: 14,
|
|
flavorText:
|
|
'Plated, patient, and entirely willing to wait behind their guard.',
|
|
// The fight this location is built around: the telegraph the player
|
|
// already knows, plus a guard that answers to the same Shield Bash.
|
|
// Intervals 3 and 4 so the two only collide every twelfth round.
|
|
abilities: {
|
|
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
|
|
guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 },
|
|
},
|
|
artworkPath: '/images/monsters/raider-veteran.png',
|
|
iconPath: '/images/combat/icons/raider-veteran-128.png',
|
|
lootTableId: RAIDER_VETERAN_LOOT_TABLE_ID,
|
|
},
|
|
{
|
|
id: RAIDER_CAPTAIN_MONSTER_ID,
|
|
key: 'raider-captain',
|
|
name: 'Raider Captain',
|
|
monsterCategory: MonsterCategory.HUMANOID,
|
|
level: 4,
|
|
maxHp: 160,
|
|
attack: 17,
|
|
armor: 16,
|
|
flavorText:
|
|
'Whoever gave the order to watch this road is standing in front of you.',
|
|
// The elite (slice §5): the veteran's two mechanics on tighter
|
|
// cadences and better stats, not a third subsystem. Deliberately not
|
|
// the Captain of the Ashen Band -- that boss belongs to Slice 0.11.
|
|
abilities: {
|
|
telegraph: { roundInterval: 2, damageMultiplier: 1.7 },
|
|
guard: { roundInterval: 3, armorBonus: 12, durationRounds: 2 },
|
|
},
|
|
artworkPath: '/images/monsters/raider-captain.png',
|
|
iconPath: '/images/combat/icons/raider-captain-128.png',
|
|
lootTableId: RAIDER_CAPTAIN_LOOT_TABLE_ID,
|
|
},
|
|
];
|
|
|
|
const monsterIds = new Map<string, string>();
|
|
for (const monster of monsters) {
|
|
const existingMonster = await monsterRepository.findOneBy({
|
|
key: monster.key,
|
|
});
|
|
const { id, key, ...definition } = monster;
|
|
|
|
if (existingMonster) {
|
|
await monsterRepository.update(existingMonster.id, definition);
|
|
} else {
|
|
await monsterRepository.insert(monster);
|
|
}
|
|
|
|
monsterIds.set(key, existingMonster?.id ?? id);
|
|
}
|
|
|
|
// Weights read as "how often you meet this on the road" (spec §3). They also
|
|
// drive the location's danger rating, which is computed from the weighted
|
|
// average of the pool rather than from its single worst entry.
|
|
//
|
|
// The Charred Raider is deliberately the odd one out at weight 2: it is a
|
|
// rare find, and `EncounterType.RARE` is what the hunt card reads to mark it
|
|
// as such.
|
|
const encounterPool: ReadonlyArray<{
|
|
key: string;
|
|
weight: number;
|
|
encounterType: EncounterType;
|
|
}> = [
|
|
{ key: 'ash-rat', weight: 50, encounterType: EncounterType.NORMAL },
|
|
{ key: 'wild-road-dog', weight: 30, encounterType: EncounterType.NORMAL },
|
|
{ key: 'road-bandit', weight: 18, encounterType: EncounterType.NORMAL },
|
|
{ key: 'charred-looter', weight: 2, encounterType: EncounterType.RARE },
|
|
];
|
|
|
|
await locationMonsterRepository.upsert(
|
|
encounterPool.map(({ key, weight, encounterType }) => ({
|
|
locationId: burnedRoadId,
|
|
monsterId: monsterIds.get(key) as string,
|
|
weight,
|
|
encounterType,
|
|
enabled: true,
|
|
})),
|
|
['locationId', 'monsterId'],
|
|
);
|
|
|
|
// Watchpost roster (slice §4). The Road Bandit is reused deliberately: it
|
|
// bridges the two locations and keeps the Raider Insignia economy
|
|
// connected. Its definition and loot table are untouched.
|
|
const watchpostPool: ReadonlyArray<{
|
|
key: string;
|
|
weight: number;
|
|
encounterType: EncounterType;
|
|
}> = [
|
|
{ key: 'raider-scout', weight: 35, encounterType: EncounterType.NORMAL },
|
|
{ key: 'burned-hound', weight: 28, encounterType: EncounterType.NORMAL },
|
|
{ key: 'road-bandit', weight: 20, encounterType: EncounterType.NORMAL },
|
|
{ key: 'raider-veteran', weight: 14, encounterType: EncounterType.NORMAL },
|
|
{ key: 'raider-captain', weight: 3, encounterType: EncounterType.RARE },
|
|
];
|
|
|
|
await locationMonsterRepository.upsert(
|
|
watchpostPool.map(({ key, weight, encounterType }) => ({
|
|
locationId: watchpostId,
|
|
monsterId: monsterIds.get(key) as string,
|
|
weight,
|
|
encounterType,
|
|
enabled: true,
|
|
})),
|
|
['locationId', 'monsterId'],
|
|
);
|
|
|
|
const existing = await characterRepository.findOneBy({
|
|
id: DEMO_CHARACTER_ID,
|
|
});
|
|
|
|
if (!existing) {
|
|
await characterRepository.insert({
|
|
id: DEMO_CHARACTER_ID,
|
|
name: 'Aric Duskwalker',
|
|
renown: 1,
|
|
silver: 0,
|
|
baseHp: 100,
|
|
baseAttack: 6,
|
|
currentHp: 100,
|
|
hpRegenSince: new Date(),
|
|
currentLocationId: southGateId,
|
|
});
|
|
}
|
|
|
|
const characterItemRepository = dataSource.getRepository(CharacterItem);
|
|
const characterEquipmentRepository =
|
|
dataSource.getRepository(CharacterEquipment);
|
|
|
|
// Starting loadout is weapon-only -- no starter armor piece exists in
|
|
// content yet -- so the demo character's effective armor (sum of equipped
|
|
// bonusArmor) is 0 until the player loots and equips bandit-hood (+3
|
|
// armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19
|
|
// says to preserve the existing demo balance "as closely as the
|
|
// implemented content allows" and explicitly forbids fabricating a full
|
|
// starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6.
|
|
const existingStartingSword = await characterItemRepository.findOneBy({
|
|
characterId: DEMO_CHARACTER_ID,
|
|
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
|
});
|
|
const startingSwordItemId =
|
|
existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID;
|
|
if (!existingStartingSword) {
|
|
await characterItemRepository.insert({
|
|
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
|
characterId: DEMO_CHARACTER_ID,
|
|
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
|
quantity: 1,
|
|
});
|
|
}
|
|
|
|
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
|
|
characterId: DEMO_CHARACTER_ID,
|
|
slot: EquipmentSlot.WEAPON,
|
|
});
|
|
if (!existingWeaponEquipment) {
|
|
await characterEquipmentRepository.insert({
|
|
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
|
|
characterId: DEMO_CHARACTER_ID,
|
|
slot: EquipmentSlot.WEAPON,
|
|
characterItemId: startingSwordItemId,
|
|
});
|
|
}
|
|
|
|
// The demo character starts with no loot bag at all (Slice 0.9 decision D5).
|
|
//
|
|
// 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).
|
|
//
|
|
// Seeded after the character so a fresh database has somewhere to stand and
|
|
// someone to trade with in one pass. Every link uses a stable business key
|
|
// rather than a generated id (spec §4), and every write is an upsert, so
|
|
// re-running the seed re-tunes prices without duplicating Borin.
|
|
for (const npc of NPC_DEFINITIONS) {
|
|
const locationId = locationIds.get(npc.locationKey);
|
|
if (!locationId) {
|
|
throw new Error(
|
|
`NPC ${npc.key} references unknown location ${npc.locationKey}.`,
|
|
);
|
|
}
|
|
|
|
const { locationKey, ...definition } = npc;
|
|
await npcDefinitionRepository.upsert({ ...definition, locationId }, [
|
|
'key',
|
|
]);
|
|
}
|
|
|
|
// 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
|
|
// item, so the old pair is null for half the rows and useless as a conflict
|
|
// target. Migration 1796 cleared the anonymous 0.8 rows for exactly this.
|
|
await shopOfferRepository.upsert(SHOP_OFFERS, ['id']);
|
|
await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
|
|
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
|
|
'profileId',
|
|
'inputItemId',
|
|
]);
|
|
}
|