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'; 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(2); expect(monsterRepository.rows).toHaveLength(2); 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(locationMonsterRepository.upsert).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: ASH_RAT_MONSTER_ID, weight: 70, }), expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: ROAD_BANDIT_MONSTER_ID, weight: 30, }), ]), ['locationId', 'monsterId'], ); expect(locationMonsterRepository.rows).toHaveLength(2); }); 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 }), ]), ); }); });