This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -11,13 +11,22 @@ import { EquipmentSlot } from '../../items/equipment-slot.enum';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity';
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { 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 { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.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,
@@ -25,15 +34,30 @@ import {
} from './item-content';
import {
ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID,
ITEM_IDS,
ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants';
import {
BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content';
import {
BASIC_HIDE_BAG_ID,
BASIC_TROPHY_POUCH_ID,
LOOT_BAG_DEFINITIONS,
} from './loot-bag-content';
import { REPUTATION_FACTIONS } from './reputation-content';
import { TURN_IN_DEFINITIONS } from './turn-in-content';
import {
DIALOGUE_NODES,
EXCHANGE_RULES,
NPC_DEFINITIONS,
NPC_EXCHANGE_PROFILES,
NPC_SHOPS,
RENOWN_MILESTONES,
SHOP_OFFERS,
} from './npc-content';
import {
ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID,
@@ -54,9 +78,20 @@ export async function seedVisibleVerticalSlice(
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 turnInDefinitionRepository = dataSource.getRepository(TurnInDefinition);
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 locations = [
{
@@ -136,20 +171,27 @@ export async function seedVisibleVerticalSlice(
'lootTableId',
'itemDefinitionId',
]);
await lootBagDefinitionRepository.upsert(LOOT_BAG_DEFINITIONS, ['key']);
await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']);
await turnInDefinitionRepository.upsert(TURN_IN_DEFINITIONS, ['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: 0,
silverMin: 0,
silverMax: 0,
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,
@@ -158,28 +200,39 @@ export async function seedVisibleVerticalSlice(
id: WILD_ROAD_DOG_MONSTER_ID,
key: 'wild-road-dog',
name: 'Feral Road Hound',
monsterCategory: MonsterCategory.BEAST,
level: 1,
maxHp: 55,
attack: 7,
armor: 0,
silverMin: 0,
silverMax: 0,
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',
// Shares the beast table: both are scorched road animals that leave a
// pelt behind. A table of its own waits for content that differs.
lootTableId: ASH_RAT_LOOT_TABLE_ID,
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: 9,
armor: 5,
silverMin: 0,
silverMax: 0,
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,
@@ -188,16 +241,22 @@ export async function seedVisibleVerticalSlice(
id: CHARRED_LOOTER_MONSTER_ID,
key: 'charred-looter',
name: 'Charred Raider',
monsterCategory: MonsterCategory.HUMANOID,
level: 2,
maxHp: 85,
attack: 11,
armor: 6,
silverMin: 0,
silverMax: 0,
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',
// Shares the raider table: same gear, taken from the same caravans.
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
},
];
@@ -217,22 +276,30 @@ export async function seedVisibleVerticalSlice(
monsterIds.set(key, existingMonster?.id ?? id);
}
// Weights read as "how often you meet this on the road". 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.
const encounterWeights: Readonly<Record<string, number>> = {
'ash-rat': 40,
'wild-road-dog': 30,
'road-bandit': 20,
'charred-looter': 10,
};
// 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(
Object.entries(encounterWeights).map(([key, weight]) => ({
encounterPool.map(({ key, weight, encounterType }) => ({
locationId: burnedRoadId,
monsterId: monsterIds.get(key) as string,
weight,
encounterType: EncounterType.NORMAL,
encounterType,
enabled: true,
})),
['locationId', 'monsterId'],
@@ -294,4 +361,62 @@ export async function seedVisibleVerticalSlice(
characterItemId: startingSwordItemId,
});
}
// ASSUMPTION (Playable Slice 0.7.5 §7 leaves acquisition to the merchant
// and quest slices, so nothing in 0.7.5 hands out a bag).
//
// The demo character starts with both starter bags anyway. Without them the
// bagless default of 1 applies, and since the merchant that empties a bag
// only arrives in 0.8, the second kill of a hunt would leave its trade good
// behind forever -- the hunting loop would be unplayable between these two
// slices. Capacity 5 lets the fill-up actually be experienced.
//
// Smallest reversible choice: two seed rows, no acquisition mechanism.
// Delete them once the merchant sells bags.
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
for (const lootBagDefinitionId of [
BASIC_HIDE_BAG_ID,
BASIC_TROPHY_POUCH_ID,
]) {
const existingBag = await characterLootBagRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId,
});
if (!existingBag) {
await characterLootBagRepository.insert({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId,
active: true,
});
}
}
// 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',
]);
}
await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
await shopOfferRepository.upsert(SHOP_OFFERS, ['shopId', 'itemDefinitionId']);
await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
'profileId',
'inputItemId',
]);
}