feat(seed): open the abandoned watchpost and the route beyond it
This commit is contained in:
@@ -240,3 +240,147 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
|
||||
],
|
||||
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: [],
|
||||
};
|
||||
|
||||
@@ -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 WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
|
||||
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';
|
||||
|
||||
@@ -32,6 +32,14 @@ import {
|
||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||
} 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 { QuestDefinition } from '../../quests/entities/quest-definition.entity';
|
||||
import { QuestObjective } from '../../quests/entities/quest-objective.entity';
|
||||
@@ -254,7 +262,9 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
expect(characterRepository.insert).toHaveBeenCalledWith(
|
||||
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.arrayContaining([
|
||||
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[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -277,8 +289,9 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
|
||||
expect(monsterRepository.rows).toHaveLength(4);
|
||||
// 4 Burned Road monsters + 4 Watchpost monsters (Playable Slice 0.10 §5).
|
||||
expect(monsterRepository.insert).toHaveBeenCalledTimes(8);
|
||||
expect(monsterRepository.rows).toHaveLength(8);
|
||||
expect(monsterRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -372,7 +385,8 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
]),
|
||||
['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 () => {
|
||||
@@ -1368,4 +1382,201 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
?.dropChance,
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user