diff --git a/apps/api/src/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts index fe48588..8de6996 100644 --- a/apps/api/src/database/seeds/local-location.content.ts +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -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: [], +}; diff --git a/apps/api/src/database/seeds/vertical-slice.constants.ts b/apps/api/src/database/seeds/vertical-slice.constants.ts index 45c0543..32270e6 100644 --- a/apps/api/src/database/seeds/vertical-slice.constants.ts +++ b/apps/api/src/database/seeds/vertical-slice.constants.ts @@ -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'; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index f4fa68a..d8ac3f5 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -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> + ).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); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 7a65c84..17fb938 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -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(); @@ -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(); @@ -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, });