feat(seed): open the abandoned watchpost and the route beyond it

This commit is contained in:
Bastian Wagner
2026-08-23 11:50:21 +02:00
parent 35964683f7
commit b3ca4687dc
4 changed files with 561 additions and 5 deletions

View File

@@ -36,12 +36,18 @@ import {
} 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';
@@ -62,9 +68,15 @@ import {
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,
@@ -131,6 +143,36 @@ export async function seedVisibleVerticalSlice(
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>();
@@ -149,6 +191,9 @@ export async function seedVisibleVerticalSlice(
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(
[
@@ -158,6 +203,7 @@ export async function seedVisibleVerticalSlice(
travelDurationSeconds: 10,
ambushChance: '0.0500',
enabled: true,
requiresDiscovery: false,
},
{
fromLocationId: burnedRoadId,
@@ -165,6 +211,42 @@ export async function seedVisibleVerticalSlice(
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'],
@@ -265,6 +347,89 @@ export async function seedVisibleVerticalSlice(
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: 10,
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: 12,
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: 14,
armor: 10,
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: 12,
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>();
@@ -312,6 +477,32 @@ export async function seedVisibleVerticalSlice(
['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,
});