import { DataSource } from 'typeorm'; import { Character } from '../../characters/entities/character.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTable } from '../../loot/entities/loot-table.entity'; import { LocationMonster } from '../../monsters/entities/location-monster.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001'; const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002'; const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001'; const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003'; const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004'; class InMemoryRepository { readonly rows: Row[] = []; readonly upsert = jest.fn( async ( values: Row | Row[], conflictPaths: string[] | { conflictPaths: string[] }, ) => { const conflictKeys = Array.isArray(conflictPaths) ? conflictPaths : conflictPaths.conflictPaths; for (const value of Array.isArray(values) ? values : [values]) { const existing = this.rows.find((row) => conflictKeys.every((key) => row[key] === value[key]), ); if (existing) { Object.assign(existing, value); } else { this.rows.push({ ...value }); } } }, ); readonly findOneBy = jest.fn(async (criteria: Row) => this.rows.find((row) => Object.entries(criteria).every(([key, value]) => row[key] === value), ), ); readonly insert = jest.fn(async (value: Row) => { this.rows.push({ ...value }); }); readonly update = jest.fn(async (criteria: string | Row, value: Row) => { const row = this.rows.find((candidate) => typeof criteria === 'string' ? candidate.id === criteria : Object.entries(criteria).every( ([key, expected]) => candidate[key] === expected, ), ); if (row) { Object.assign(row, value); } }); } function createDataSource( locationRepository: InMemoryRepository, connectionRepository: InMemoryRepository, characterRepository: InMemoryRepository, monsterRepository: InMemoryRepository, locationMonsterRepository: InMemoryRepository, itemRepository: InMemoryRepository = new InMemoryRepository(), lootTableRepository: InMemoryRepository = new InMemoryRepository(), lootEntryRepository: InMemoryRepository = new InMemoryRepository(), ): DataSource { return { getRepository: jest.fn((entity: unknown) => { if (entity === LocationDefinition) return locationRepository; if (entity === LocationConnection) return connectionRepository; if (entity === Character) return characterRepository; if (entity === MonsterDefinition) return monsterRepository; if (entity === LocationMonster) return locationMonsterRepository; if (entity === ItemDefinition) return itemRepository; if (entity === LootTable) return lootTableRepository; if (entity === LootTableEntry) return lootEntryRepository; throw new Error('Unexpected repository'); }), } as unknown as DataSource; } describe('seedVisibleVerticalSlice', () => { it('upserts the two locations and directed connections while inserting one stable demo character', async () => { const locationRepository = new InMemoryRepository(); const connectionRepository = new InMemoryRepository(); const characterRepository = new InMemoryRepository(); const monsterRepository = new InMemoryRepository(); const locationMonsterRepository = new InMemoryRepository(); const dataSource = createDataSource( locationRepository, connectionRepository, characterRepository, monsterRepository, locationMonsterRepository, ); await seedVisibleVerticalSlice(dataSource); Object.assign(characterRepository.rows[0], { currentLocationId: BURNED_ROAD_ID, currentHp: 57, experience: 39, }); await seedVisibleVerticalSlice(dataSource); expect(connectionRepository.upsert).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ fromLocationId: SOUTH_GATE_ID, toLocationId: BURNED_ROAD_ID, }), expect.objectContaining({ fromLocationId: BURNED_ROAD_ID, toLocationId: SOUTH_GATE_ID, }), ]), ['fromLocationId', 'toLocationId'], ); expect(characterRepository.insert).toHaveBeenCalledWith( expect.objectContaining({ id: DEMO_CHARACTER_ID }), ); expect(locationRepository.rows).toHaveLength(2); expect(locationRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ key: 'south-gate', artworkPath: '/images/backgrounds/Suedtor.png', }), expect.objectContaining({ key: 'burned-road', artworkPath: '/images/backgrounds/Aschestrasse.png', }), ]), ); expect(connectionRepository.rows).toHaveLength(2); expect(characterRepository.rows).toHaveLength(1); expect(characterRepository.rows[0]).toEqual( expect.objectContaining({ currentLocationId: BURNED_ROAD_ID, currentHp: 57, experience: 39, }), ); expect(monsterRepository.insert).toHaveBeenCalledTimes(4); expect(monsterRepository.rows).toHaveLength(4); expect(monsterRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ key: 'ash-rat', name: 'Aschenratte', level: 1, maxHp: 45, attack: 5, armor: 0, experienceReward: 8, silverMin: 4, silverMax: 7, artworkPath: '/images/monsters/ash-rat.png', }), expect.objectContaining({ key: 'road-bandit', name: 'Straßenräuber', level: 2, maxHp: 75, attack: 9, armor: 5, experienceReward: 16, silverMin: 9, silverMax: 15, artworkPath: '/images/monsters/road-bandit.png', }), expect.objectContaining({ key: 'wild-road-dog', name: 'Verwilderter Straßenhund', level: 1, artworkPath: '/images/monsters/wild-road-dog.png', iconPath: '/images/monsters/icons/wild-road-dog-128.png', }), expect.objectContaining({ key: 'charred-looter', name: 'Verkohlter Plünderer', level: 2, artworkPath: '/images/monsters/charred-looter.png', iconPath: '/images/monsters/icons/charred-looter-128.png', }), ]), ); expect(locationMonsterRepository.upsert).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: ASH_RAT_MONSTER_ID, weight: 40, }), expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: WILD_ROAD_DOG_MONSTER_ID, weight: 30, }), expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: ROAD_BANDIT_MONSTER_ID, weight: 20, }), expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: CHARRED_LOOTER_MONSTER_ID, weight: 10, }), ]), ['locationId', 'monsterId'], ); expect(locationMonsterRepository.rows).toHaveLength(4); }); it('seeds the local view content of the Verbrannte Straße with four points of interest', async () => { const locationRepository = new InMemoryRepository(); const dataSource = createDataSource( locationRepository, new InMemoryRepository(), new InMemoryRepository(), new InMemoryRepository(), new InMemoryRepository(), ); await seedVisibleVerticalSlice(dataSource); const burnedRoad = locationRepository.rows.find( (row) => row.key === 'burned-road', ) as Row; expect(burnedRoad).toEqual( expect.objectContaining({ regionName: 'Aschenfelder', regionTierLabel: 'Gebiet 1', locationType: 'HUNTING_GROUND', localArtworkPath: '/images/backgrounds/Aschestrasse.png', }), ); expect(burnedRoad.localDescription).toContain( 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.', ); const pointsOfInterest = burnedRoad.localPointsOfInterest as { key: string; type: string; }[]; expect(pointsOfInterest.map((poi) => poi.key)).toEqual([ 'hunt-area', 'wounded-scout', 'inspect-tracks', 'search-abandoned-wagon', ]); expect(pointsOfInterest.map((poi) => poi.type)).toEqual([ 'HUNT', 'NPC', 'INVESTIGATE', 'SEARCH', ]); const primaryActions = burnedRoad.localPrimaryActions as { label: string; }[]; expect(primaryActions.map((action) => action.label)).toEqual([ 'Jagd beginnen', 'Spuren untersuchen', 'Umgebung durchsuchen', 'Zur Karte', ]); }); it('gives the Südtor its own local content so a second location needs no new component', async () => { const locationRepository = new InMemoryRepository(); const dataSource = createDataSource( locationRepository, new InMemoryRepository(), new InMemoryRepository(), new InMemoryRepository(), new InMemoryRepository(), ); await seedVisibleVerticalSlice(dataSource); const southGate = locationRepository.rows.find( (row) => row.key === 'south-gate', ) as Row; expect(southGate).toEqual( expect.objectContaining({ locationType: 'TRANSITION', localArtworkPath: '/images/backgrounds/Suedtor.png', }), ); expect(southGate.localPointsOfInterest).toHaveLength(3); // A transition location offers no hunt, so no HUNT hotspot may appear. expect( (southGate.localPointsOfInterest as { type: string }[]).some( (poi) => poi.type === 'HUNT', ), ).toBe(false); }); it('preserves existing location IDs and uses them for the directed connections', async () => { const locationRepository = new InMemoryRepository(); const connectionRepository = new InMemoryRepository(); const characterRepository = new InMemoryRepository(); const monsterRepository = new InMemoryRepository(); const locationMonsterRepository = new InMemoryRepository(); const persistedSouthGateId = '40000000-0000-4000-8000-000000000001'; locationRepository.rows.push({ id: persistedSouthGateId, key: 'south-gate', name: 'Veraltetes Südtor', }); const dataSource = createDataSource( locationRepository, connectionRepository, characterRepository, monsterRepository, locationMonsterRepository, ); await seedVisibleVerticalSlice(dataSource); expect(locationRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ id: persistedSouthGateId, key: 'south-gate', name: 'Südtor von Graufurt', }), expect.objectContaining({ id: BURNED_ROAD_ID, key: 'burned-road', }), ]), ); expect(connectionRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ fromLocationId: persistedSouthGateId, toLocationId: BURNED_ROAD_ID, }), expect.objectContaining({ fromLocationId: BURNED_ROAD_ID, toLocationId: persistedSouthGateId, }), ]), ); expect(characterRepository.rows).toEqual([ expect.objectContaining({ id: DEMO_CHARACTER_ID, currentLocationId: persistedSouthGateId, }), ]); }); it('seeds the tier-1 items and both loot tables idempotently and wires them to the monsters', async () => { const locationRepository = new InMemoryRepository(); const connectionRepository = new InMemoryRepository(); const characterRepository = new InMemoryRepository(); const monsterRepository = new InMemoryRepository(); const locationMonsterRepository = new InMemoryRepository(); const itemRepository = new InMemoryRepository(); const lootTableRepository = new InMemoryRepository(); const lootEntryRepository = new InMemoryRepository(); const dataSource = createDataSource( locationRepository, connectionRepository, characterRepository, monsterRepository, locationMonsterRepository, itemRepository, lootTableRepository, lootEntryRepository, ); await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); expect(itemRepository.rows).toHaveLength(12); expect(itemRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ key: 'bandit-blade', name: 'Räuberklinge', type: 'WEAPON', equipmentSlot: 'WEAPON', rarity: 'COMMON', weaponDamage: 11, bonusAttack: 1, sellPrice: 0, iconPath: '/images/items/bandit-blade.png', }), expect.objectContaining({ key: 'ash-pelt', name: 'Aschenfell', type: 'MATERIAL', equipmentSlot: null, }), ]), ); expect(lootTableRepository.rows).toHaveLength(2); expect(lootEntryRepository.rows).toHaveLength(5); expect(lootEntryRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ lootTableId: ASH_RAT_LOOT_TABLE_ID, itemDefinitionId: ITEM_IDS['ash-pelt'], position: 1, dropChance: '0.6000', }), expect.objectContaining({ lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, itemDefinitionId: ITEM_IDS['bandit-blade'], position: 1, dropChance: '0.1800', }), ]), ); // The Kleiner Heiltrank entry is deliberately deferred (spec §16). expect( lootEntryRepository.rows.some( (row) => row.itemDefinitionId === ITEM_IDS['small-healing-potion'], ), ).toBe(false); expect(monsterRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ key: 'ash-rat', lootTableId: ASH_RAT_LOOT_TABLE_ID }), expect.objectContaining({ key: 'road-bandit', lootTableId: ROAD_BANDIT_LOOT_TABLE_ID }), ]), ); }); });