Brings in the First Loot slice. Resolved additively: - MonsterDefinition keeps both the new iconPath and master's lootTableId. - The seed keeps the four-monster pool and the local view content, and gives the two new monsters existing loot tables — the road dog shares the beast table, the charred looter the raider table. - The local location view migration moves to 1788700000000 so it orders deterministically after the loot migration, which claimed the same timestamp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
453 lines
15 KiB
TypeScript
453 lines
15 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';
|
|
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 }),
|
|
]),
|
|
);
|
|
});
|
|
});
|