The CharacterItem idempotency check was looking up by the seed's own literal id instead of the (characterId, itemDefinitionId) unique index that CharacterItem actually enforces. If the demo character had already looted a worn-short-sword naturally, re-running the seed would miss that row and try to insert a colliding duplicate, breaking seeding instead of being a safe no-op. Look up by the real domain key and reuse whatever id is found when wiring up the CharacterEquipment row.
468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
import { DataSource } from 'typeorm';
|
|
import { Character } from '../../characters/entities/character.entity';
|
|
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
|
|
import { CharacterItem } from '../../items/entities/character-item.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 { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
|
|
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(),
|
|
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
|
|
characterEquipmentRepository: 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;
|
|
if (entity === CharacterItem) return characterItemRepository;
|
|
if (entity === CharacterEquipment) return characterEquipmentRepository;
|
|
|
|
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 }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
|
|
const locationRepository = new InMemoryRepository();
|
|
const connectionRepository = new InMemoryRepository();
|
|
const characterRepository = new InMemoryRepository();
|
|
const monsterRepository = new InMemoryRepository();
|
|
const locationMonsterRepository = new InMemoryRepository();
|
|
const characterItemRepository = new InMemoryRepository();
|
|
const characterEquipmentRepository = new InMemoryRepository();
|
|
const dataSource = createDataSource(
|
|
locationRepository,
|
|
connectionRepository,
|
|
characterRepository,
|
|
monsterRepository,
|
|
locationMonsterRepository,
|
|
new InMemoryRepository(),
|
|
new InMemoryRepository(),
|
|
new InMemoryRepository(),
|
|
characterItemRepository,
|
|
characterEquipmentRepository,
|
|
);
|
|
|
|
await seedVisibleVerticalSlice(dataSource);
|
|
await seedVisibleVerticalSlice(dataSource);
|
|
|
|
expect(characterItemRepository.rows).toHaveLength(1);
|
|
expect(characterItemRepository.rows[0]).toEqual(
|
|
expect.objectContaining({
|
|
characterId: DEMO_CHARACTER_ID,
|
|
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
|
quantity: 1,
|
|
}),
|
|
);
|
|
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
|
expect(characterEquipmentRepository.rows[0]).toEqual(
|
|
expect.objectContaining({
|
|
characterId: DEMO_CHARACTER_ID,
|
|
slot: 'WEAPON',
|
|
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('never re-equips the starting sword once the player has equipped different gear', async () => {
|
|
const locationRepository = new InMemoryRepository();
|
|
const connectionRepository = new InMemoryRepository();
|
|
const characterRepository = new InMemoryRepository();
|
|
const monsterRepository = new InMemoryRepository();
|
|
const locationMonsterRepository = new InMemoryRepository();
|
|
const characterItemRepository = new InMemoryRepository();
|
|
const characterEquipmentRepository = new InMemoryRepository();
|
|
const dataSource = createDataSource(
|
|
locationRepository,
|
|
connectionRepository,
|
|
characterRepository,
|
|
monsterRepository,
|
|
locationMonsterRepository,
|
|
new InMemoryRepository(),
|
|
new InMemoryRepository(),
|
|
new InMemoryRepository(),
|
|
characterItemRepository,
|
|
characterEquipmentRepository,
|
|
);
|
|
|
|
await seedVisibleVerticalSlice(dataSource);
|
|
// Simulate the player having equipped earned loot instead.
|
|
characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id';
|
|
|
|
await seedVisibleVerticalSlice(dataSource);
|
|
|
|
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
|
expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe(
|
|
'earned-bandit-blade-item-id',
|
|
);
|
|
expect(characterItemRepository.rows).toHaveLength(1);
|
|
});
|
|
|
|
it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => {
|
|
const locationRepository = new InMemoryRepository();
|
|
const connectionRepository = new InMemoryRepository();
|
|
const characterRepository = new InMemoryRepository();
|
|
const monsterRepository = new InMemoryRepository();
|
|
const locationMonsterRepository = new InMemoryRepository();
|
|
const characterItemRepository = new InMemoryRepository();
|
|
const characterEquipmentRepository = new InMemoryRepository();
|
|
const dataSource = createDataSource(
|
|
locationRepository,
|
|
connectionRepository,
|
|
characterRepository,
|
|
monsterRepository,
|
|
locationMonsterRepository,
|
|
new InMemoryRepository(),
|
|
new InMemoryRepository(),
|
|
new InMemoryRepository(),
|
|
characterItemRepository,
|
|
characterEquipmentRepository,
|
|
);
|
|
|
|
// Simulate the demo character having already looted a worn-short-sword
|
|
// naturally, under a DB-generated id that differs from the seed's
|
|
// stable literal constant.
|
|
const naturallyLootedItemId = 'naturally-looted-sword-item-id';
|
|
characterItemRepository.rows.push({
|
|
id: naturallyLootedItemId,
|
|
characterId: DEMO_CHARACTER_ID,
|
|
itemDefinitionId: ITEM_IDS['worn-short-sword'],
|
|
quantity: 1,
|
|
});
|
|
|
|
await seedVisibleVerticalSlice(dataSource);
|
|
|
|
expect(characterItemRepository.rows).toHaveLength(1);
|
|
expect(characterEquipmentRepository.rows).toHaveLength(1);
|
|
expect(characterEquipmentRepository.rows[0]).toEqual(
|
|
expect.objectContaining({
|
|
characterId: DEMO_CHARACTER_ID,
|
|
slot: 'WEAPON',
|
|
characterItemId: naturallyLootedItemId,
|
|
}),
|
|
);
|
|
});
|
|
});
|