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