340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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 }),
|
|
]),
|
|
);
|
|
});
|
|
});
|