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: [],
|
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 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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user