Files
ashen-realms/apps/api/src/database/seeds/vertical-slice.seed.spec.ts
Bastian Wagner 1322285b0f fix: close out remaining German player-facing text across web and API tests
Repo-wide grep sweep (apps/web/src, apps/api/src) for leftover German
content strings missed by Tasks 1-9, mostly in spec fixtures/assertions
that mirror already-translated seed content (monster/item names, POI
titles and action labels, location names/descriptions) plus a few real
source-file gaps:

- inventory-detail-panel.component.ts: STAT_LABELS (Waffenschaden,
  Angriff, Leben, Rüstung) were never translated by Task 6; now match
  the identical English labels already used in inventory-page.component.html.
- app-shell.component.html: aria-label="Spielinhalt" -> "Game content"
  (this file was outside every prior task's file list).
- location-interaction-panel.component.spec.ts: dead NPC-quote fixture
  translated to match the real wounded-scout POI text.

Code comments referencing German source-spec section titles or
not-yet-seeded faction names, and inline calculation-documentation
comments, are left as-is per the source spec's scope (dev-facing
comments may stay German).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACkMEDYiwtcfchqKkiUJNX
2026-08-21 22:59:34 +02:00

658 lines
22 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 { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { TurnInDefinition } from '../../turn-in/entities/turn-in-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';
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(),
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
reputationFactionRepository: InMemoryRepository = new InMemoryRepository(),
turnInDefinitionRepository: 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;
if (entity === ReputationFaction) return reputationFactionRepository;
if (entity === TurnInDefinition) return turnInDefinitionRepository;
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,
// Deliberately NOT the seed default of 1: if a re-seed clobbered player
// state back to defaults, an assertion on the default value could not
// tell the difference.
renown: 5,
});
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,
renown: 5,
}),
);
expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
expect(monsterRepository.rows).toHaveLength(4);
expect(monsterRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'ash-rat',
name: 'Ash Rat',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/ash-rat.png',
}),
expect.objectContaining({
key: 'road-bandit',
name: 'Road Bandit',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/road-bandit.png',
}),
expect.objectContaining({
key: 'wild-road-dog',
name: 'Feral Road Hound',
level: 1,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png',
}),
expect.objectContaining({
key: 'charred-looter',
name: 'Charred Raider',
level: 2,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/combat/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 Burned Road 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: 'Ashen Fields',
regionTierLabel: 'Tier 1',
locationType: 'HUNTING_GROUND',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
}),
);
expect(burnedRoad.localDescription).toContain(
'An old trade road, burned to ash by fire and war.',
);
const pointsOfInterest = burnedRoad.localPointsOfInterest as {
key: string;
type: string;
}[];
expect(pointsOfInterest.map((poi) => poi.key)).toEqual([
'hunt-area',
'inspect-tracks',
'search-abandoned-wagon',
'wounded-scout',
]);
expect(pointsOfInterest.map((poi) => poi.type)).toEqual([
'HUNT',
'INVESTIGATE',
'SEARCH',
'NPC',
]);
const primaryActions = burnedRoad.localPrimaryActions as {
label: string;
}[];
expect(primaryActions.map((action) => action.label)).toEqual([
'Begin Hunt',
'Investigate tracks',
'Search surroundings',
'To Map',
]);
});
it('gives the South Gate 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: 'Outdated South Gate',
});
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: 'Graufurt South Gate',
}),
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(13);
expect(itemRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'bandit-blade',
name: 'Bandit Blade',
type: 'EQUIPMENT',
equipmentSlot: 'WEAPON',
rarity: 'COMMON',
weaponDamage: 11,
bonusAttack: 1,
sellPrice: 0,
iconPath: '/images/items/bandit-blade.png',
}),
expect.objectContaining({
key: 'ash-pelt',
name: 'Ash Pelt',
type: 'TRADE_GOOD',
equipmentSlot: null,
}),
expect.objectContaining({
key: 'bandit-insignia',
name: 'Bandit Insignia',
type: 'TROPHY',
equipmentSlot: null,
}),
]),
);
expect(lootTableRepository.rows).toHaveLength(2);
expect(lootEntryRepository.rows).toHaveLength(6);
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,
}),
);
});
it('seeds the Grenzwacht faction', async () => {
const reputationFactionRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
reputationFactionRepository,
);
await seedVisibleVerticalSlice(dataSource);
const faction = await dataSource
.getRepository(ReputationFaction)
.findOneBy({ key: 'border-guard' });
expect(faction).toMatchObject({ name: 'Border Watch', enabled: true });
});
it('seeds both turn-in definitions', async () => {
const turnInDefinitionRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
turnInDefinitionRepository,
);
await seedVisibleVerticalSlice(dataSource);
expect(
turnInDefinitionRepository.rows.map((row: Row) => row.key).sort(),
).toEqual(['ash-pelt-border-guard', 'bandit-insignia-border-guard']);
});
});