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

@@ -240,3 +240,147 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
], ],
localRewardPreview: [], localRewardPreview: [],
}; };
export const ABANDONED_WATCHPOST_LOCAL_CONTENT: LocalLocationContent = {
regionName: 'Ashen Fields',
regionTierLabel: 'Tier 1',
locationType: 'OUTPOST',
localDescription:
'A border tower the Watch gave up on. The palisade still stands, the gate does not, and someone has been sleeping here who was never posted here.',
localArtworkPath: '/images/backgrounds/Wachturm.png',
// Anchored to painted detail in `Wachturm.png`: the open ground before the
// tower, the tower base itself, the collapsed lean-to on the left, and the
// track leading off to the right.
localPointsOfInterest: [
{
key: 'hunt-area',
title: 'Hunting Ground',
actionLabel: 'Begin Hunt',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 62,
yPercent: 38,
enabled: true,
},
{
key: 'inspect-watchpost',
title: 'The Watchpost',
actionLabel: 'Inspect',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 44,
yPercent: 46,
enabled: true,
resultTitle: 'The Watchpost',
// Quoted from Playable Slice 0.10 §8.
resultText:
"The raiders weren't using the watchpost as shelter. They were using it to watch the road. Fresh tracks lead east, toward the old ash excavation.",
discoversLocationKey: 'ash-pit',
},
{
key: 'search-guard-quarters',
title: "Guards' Quarters",
actionLabel: 'Search',
type: 'SEARCH',
iconKey: 'search',
xPercent: 18,
yPercent: 68,
enabled: true,
resultTitle: "Guards' Quarters",
resultText:
'Straw, a cold hearth, and a duty roster with every name scratched out but one. Nothing here is worth carrying.',
},
{
key: 'east-road',
title: 'Track East',
actionLabel: 'To Map',
type: 'MAP',
iconKey: 'map',
xPercent: 88,
yPercent: 74,
enabled: true,
},
],
localPrimaryActions: [
{
key: 'start-hunt',
label: 'Begin Hunt',
description: 'Hunt in this area',
type: 'HUNT',
iconKey: 'hunt',
enabled: true,
},
{
key: 'inspect-watchpost',
label: 'Inspect the watchpost',
description: 'Find clues',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'inspect-watchpost',
},
{
key: 'search-quarters',
label: 'Search the quarters',
description: 'Find loot',
type: 'SEARCH',
iconKey: 'search',
enabled: true,
poiKey: 'search-guard-quarters',
},
{
key: 'open-map',
label: 'To Map',
description: 'Change area',
type: 'MAP',
iconKey: 'map',
enabled: true,
},
],
// Same rule as the Burned Road: only categories the loot tables here
// actually back, and no Silver or experience, because a normal kill grants
// neither (slice §6).
localRewardPreview: [
{ key: 'equipment', label: 'Equipment', iconKey: 'equipment' },
{ key: 'material', label: 'Trade Goods', iconKey: 'material' },
],
};
/**
* A stub (Playable Slice 0.10 §9, design decision D2).
*
* The discovered route needs a real destination for the gate to mean anything,
* so the Ash Pit exists as a place you can stand -- and nothing more. Slice
* 0.11 gives it an encounter pool, an elite and its own hotspots.
*/
export const ASH_PIT_LOCAL_CONTENT: LocalLocationContent = {
regionName: 'Ashen Fields',
regionTierLabel: 'Tier 1',
locationType: 'TRANSITION',
localDescription:
'The old ash excavation drops away in terraces, grey on grey. Something down there is still being worked.',
localArtworkPath: '/images/backgrounds/Aschengrube.png',
localPointsOfInterest: [
{
key: 'pit-rim',
title: 'Back Along the Track',
actionLabel: 'To Map',
type: 'MAP',
iconKey: 'map',
xPercent: 20,
yPercent: 76,
enabled: true,
},
],
localPrimaryActions: [
{
key: 'open-map',
label: 'To Map',
description: 'Change area',
type: 'MAP',
iconKey: 'map',
enabled: true,
},
],
localRewardPreview: [],
};

View File

@@ -4,3 +4,13 @@ export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003'; export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004'; export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
// Playable Slice 0.10.
export const ABANDONED_WATCHPOST_ID = '20000000-0000-4000-8000-000000000003';
export const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004';
export const RAIDER_SCOUT_MONSTER_ID = '30000000-0000-4000-8000-000000000005';
export const BURNED_HOUND_MONSTER_ID = '30000000-0000-4000-8000-000000000006';
export const RAIDER_VETERAN_MONSTER_ID =
'30000000-0000-4000-8000-000000000007';
export const RAIDER_CAPTAIN_MONSTER_ID =
'30000000-0000-4000-8000-000000000008';

View File

@@ -32,6 +32,14 @@ import {
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants'; } from './item.constants';
import {
ABANDONED_WATCHPOST_ID,
ASH_PIT_ID,
BURNED_HOUND_MONSTER_ID,
RAIDER_CAPTAIN_MONSTER_ID,
RAIDER_SCOUT_MONSTER_ID,
RAIDER_VETERAN_MONSTER_ID,
} from './vertical-slice.constants';
import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity'; import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity';
import { QuestDefinition } from '../../quests/entities/quest-definition.entity'; import { QuestDefinition } from '../../quests/entities/quest-definition.entity';
import { QuestObjective } from '../../quests/entities/quest-objective.entity'; import { QuestObjective } from '../../quests/entities/quest-objective.entity';
@@ -254,7 +262,9 @@ describe('seedVisibleVerticalSlice', () => {
expect(characterRepository.insert).toHaveBeenCalledWith( expect(characterRepository.insert).toHaveBeenCalledWith(
expect.objectContaining({ id: DEMO_CHARACTER_ID }), expect.objectContaining({ id: DEMO_CHARACTER_ID }),
); );
expect(locationRepository.rows).toHaveLength(2); // South Gate, Burned Road, plus the Watchpost and Ash Pit added in
// Playable Slice 0.10 §5, §9.
expect(locationRepository.rows).toHaveLength(4);
expect(locationRepository.rows).toEqual( expect(locationRepository.rows).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -267,7 +277,9 @@ describe('seedVisibleVerticalSlice', () => {
}), }),
]), ]),
); );
expect(connectionRepository.rows).toHaveLength(2); // 2 original (South Gate <-> Burned Road) + 2 new (Burned Road <->
// Watchpost) + 2 gated (Watchpost <-> Ash Pit) (Playable Slice 0.10 §9).
expect(connectionRepository.rows).toHaveLength(6);
expect(characterRepository.rows).toHaveLength(1); expect(characterRepository.rows).toHaveLength(1);
expect(characterRepository.rows[0]).toEqual( expect(characterRepository.rows[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
@@ -277,8 +289,9 @@ describe('seedVisibleVerticalSlice', () => {
}), }),
); );
expect(monsterRepository.insert).toHaveBeenCalledTimes(4); // 4 Burned Road monsters + 4 Watchpost monsters (Playable Slice 0.10 §5).
expect(monsterRepository.rows).toHaveLength(4); expect(monsterRepository.insert).toHaveBeenCalledTimes(8);
expect(monsterRepository.rows).toHaveLength(8);
expect(monsterRepository.rows).toEqual( expect(monsterRepository.rows).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -372,7 +385,8 @@ describe('seedVisibleVerticalSlice', () => {
]), ]),
['locationId', 'monsterId'], ['locationId', 'monsterId'],
); );
expect(locationMonsterRepository.rows).toHaveLength(4); // 4 Burned Road entries + 5 Watchpost entries (Playable Slice 0.10 §4).
expect(locationMonsterRepository.rows).toHaveLength(9);
}); });
it('seeds the local view content of the Burned Road with four points of interest', async () => { it('seeds the local view content of the Burned Road with four points of interest', async () => {
@@ -1368,4 +1382,201 @@ describe('seedVisibleVerticalSlice', () => {
?.dropChance, ?.dropChance,
).toBe('0.1800'); ).toBe('0.1800');
}); });
it('seeds the watchpost as a huntable outpost', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource({ location: locationRepository });
await seedVisibleVerticalSlice(dataSource);
const watchpost = locationRepository.rows.find(
(row) => row.key === 'abandoned-watchpost',
);
expect(watchpost).toMatchObject({
locationType: 'OUTPOST',
huntingEnabled: true,
isSafe: false,
regionKey: 'ashen-fields',
});
});
it('connects the burned road and the watchpost both ways without a gate', async () => {
const connectionRepository = new InMemoryRepository();
const dataSource = createDataSource({ connection: connectionRepository });
await seedVisibleVerticalSlice(dataSource);
const connections = connectionRepository.rows;
const outbound = connections.find(
(row) =>
row.fromLocationId === BURNED_ROAD_ID &&
row.toLocationId === ABANDONED_WATCHPOST_ID,
);
const inbound = connections.find(
(row) =>
row.fromLocationId === ABANDONED_WATCHPOST_ID &&
row.toLocationId === BURNED_ROAD_ID,
);
expect(outbound).toMatchObject({
travelDurationSeconds: 15,
ambushChance: '0.1000',
requiresDiscovery: false,
});
expect(inbound).toMatchObject({
travelDurationSeconds: 15,
requiresDiscovery: false,
});
});
it('gates the outbound ash pit route and leaves the way back open', async () => {
const connectionRepository = new InMemoryRepository();
const dataSource = createDataSource({ connection: connectionRepository });
await seedVisibleVerticalSlice(dataSource);
const connections = connectionRepository.rows;
const outbound = connections.find(
(row) =>
row.fromLocationId === ABANDONED_WATCHPOST_ID &&
row.toLocationId === ASH_PIT_ID,
);
const inbound = connections.find(
(row) =>
row.fromLocationId === ASH_PIT_ID &&
row.toLocationId === ABANDONED_WATCHPOST_ID,
);
expect(outbound?.requiresDiscovery).toBe(true);
// Whoever got there must always be able to leave.
expect(inbound?.requiresDiscovery).toBe(false);
});
it('points the watchpost investigation at the ash pit', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource({ location: locationRepository });
await seedVisibleVerticalSlice(dataSource);
const watchpost = locationRepository.rows.find(
(row) => row.key === 'abandoned-watchpost',
);
const poi = (
watchpost?.localPointsOfInterest as Array<Record<string, unknown>>
).find((entry) => entry.key === 'inspect-watchpost');
expect(poi?.discoversLocationKey).toBe('ash-pit');
});
it('gives the watchpost its own encounter pool', async () => {
const locationMonsterRepository = new InMemoryRepository();
const dataSource = createDataSource({
locationMonster: locationMonsterRepository,
});
await seedVisibleVerticalSlice(dataSource);
const pool = locationMonsterRepository.rows.filter(
(row) => row.locationId === ABANDONED_WATCHPOST_ID,
);
expect(pool.map((row) => row.monsterId).sort()).toEqual(
[
RAIDER_SCOUT_MONSTER_ID,
BURNED_HOUND_MONSTER_ID,
ROAD_BANDIT_MONSTER_ID,
RAIDER_VETERAN_MONSTER_ID,
RAIDER_CAPTAIN_MONSTER_ID,
].sort(),
);
});
it('leaves the burned road pool exactly as it was', async () => {
const locationMonsterRepository = new InMemoryRepository();
const dataSource = createDataSource({
locationMonster: locationMonsterRepository,
});
await seedVisibleVerticalSlice(dataSource);
const pool = locationMonsterRepository.rows.filter(
(row) => row.locationId === BURNED_ROAD_ID,
);
expect(pool).toHaveLength(4);
});
it('marks only the captain as a rare encounter', async () => {
const locationMonsterRepository = new InMemoryRepository();
const dataSource = createDataSource({
locationMonster: locationMonsterRepository,
});
await seedVisibleVerticalSlice(dataSource);
const pool = locationMonsterRepository.rows.filter(
(row) => row.locationId === ABANDONED_WATCHPOST_ID,
);
const rare = pool.filter((row) => row.encounterType === 'RARE');
expect(rare).toHaveLength(1);
expect(rare[0].monsterId).toBe(RAIDER_CAPTAIN_MONSTER_ID);
});
it('arms the veteran with a telegraph and a guard on different cadences', async () => {
const monsterRepository = new InMemoryRepository();
const dataSource = createDataSource({ monster: monsterRepository });
await seedVisibleVerticalSlice(dataSource);
const veteran = monsterRepository.rows.find(
(row) => row.key === 'raider-veteran',
);
expect(veteran?.abilities).toEqual({
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 },
});
});
it('gives the hound bleeding and a low-HP rage', async () => {
const monsterRepository = new InMemoryRepository();
const dataSource = createDataSource({ monster: monsterRepository });
await seedVisibleVerticalSlice(dataSource);
const hound = monsterRepository.rows.find(
(row) => row.key === 'burned-hound',
);
expect(hound?.abilities).toEqual({
bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 },
enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 },
});
});
it('seeds the ash pit as a stub with nothing to hunt yet', async () => {
const locationRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const dataSource = createDataSource({
location: locationRepository,
locationMonster: locationMonsterRepository,
});
await seedVisibleVerticalSlice(dataSource);
const ashPit = locationRepository.rows.find(
(row) => row.key === 'ash-pit',
);
const pool = locationMonsterRepository.rows.filter(
(row) => row.locationId === ASH_PIT_ID,
);
expect(ashPit?.huntingEnabled).toBe(false);
expect(pool).toHaveLength(0);
});
}); });

View File

@@ -36,12 +36,18 @@ import {
} from './item-content'; } from './item-content';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
BURNED_HOUND_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID,
ITEM_IDS, ITEM_IDS,
RAIDER_CAPTAIN_LOOT_TABLE_ID,
RAIDER_SCOUT_LOOT_TABLE_ID,
RAIDER_VETERAN_LOOT_TABLE_ID,
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants'; } from './item.constants';
import { import {
ABANDONED_WATCHPOST_LOCAL_CONTENT,
ASH_PIT_LOCAL_CONTENT,
BURNED_ROAD_LOCAL_CONTENT, BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content'; } from './local-location.content';
@@ -62,9 +68,15 @@ import {
SHOP_OFFERS, SHOP_OFFERS,
} from './npc-content'; } from './npc-content';
import { import {
ABANDONED_WATCHPOST_ID,
ASH_PIT_ID,
ASH_RAT_MONSTER_ID, ASH_RAT_MONSTER_ID,
BURNED_HOUND_MONSTER_ID,
BURNED_ROAD_ID, BURNED_ROAD_ID,
CHARRED_LOOTER_MONSTER_ID, CHARRED_LOOTER_MONSTER_ID,
RAIDER_CAPTAIN_MONSTER_ID,
RAIDER_SCOUT_MONSTER_ID,
RAIDER_VETERAN_MONSTER_ID,
ROAD_BANDIT_MONSTER_ID, ROAD_BANDIT_MONSTER_ID,
SOUTH_GATE_ID, SOUTH_GATE_ID,
WILD_ROAD_DOG_MONSTER_ID, WILD_ROAD_DOG_MONSTER_ID,
@@ -131,6 +143,36 @@ export async function seedVisibleVerticalSlice(
artworkPath: '/images/backgrounds/Aschestrasse.png', artworkPath: '/images/backgrounds/Aschestrasse.png',
...BURNED_ROAD_LOCAL_CONTENT, ...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>(); const locationIds = new Map<string, string>();
@@ -149,6 +191,9 @@ export async function seedVisibleVerticalSlice(
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID; const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_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( await connectionRepository.upsert(
[ [
@@ -158,6 +203,7 @@ export async function seedVisibleVerticalSlice(
travelDurationSeconds: 10, travelDurationSeconds: 10,
ambushChance: '0.0500', ambushChance: '0.0500',
enabled: true, enabled: true,
requiresDiscovery: false,
}, },
{ {
fromLocationId: burnedRoadId, fromLocationId: burnedRoadId,
@@ -165,6 +211,42 @@ export async function seedVisibleVerticalSlice(
travelDurationSeconds: 10, travelDurationSeconds: 10,
ambushChance: '0.0500', ambushChance: '0.0500',
enabled: true, 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'], ['fromLocationId', 'toLocationId'],
@@ -265,6 +347,89 @@ export async function seedVisibleVerticalSlice(
iconPath: '/images/combat/icons/charred-looter-128.png', iconPath: '/images/combat/icons/charred-looter-128.png',
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID, 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>(); const monsterIds = new Map<string, string>();
@@ -312,6 +477,32 @@ export async function seedVisibleVerticalSlice(
['locationId', 'monsterId'], ['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({ const existing = await characterRepository.findOneBy({
id: DEMO_CHARACTER_ID, id: DEMO_CHARACTER_ID,
}); });