Files
ashen-realms/apps/api/src/database/seeds/vertical-slice.seed.ts
Bastian Wagner 5fa344dbe2 seed
2026-08-23 18:31:31 +02:00

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',
]);
}