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
This commit is contained in:
Bastian Wagner
2026-08-21 22:59:34 +02:00
parent ae163f000a
commit 1322285b0f
38 changed files with 243 additions and 241 deletions

View File

@@ -61,7 +61,7 @@ describe('CharacterStatsService', () => {
expect(stats.armor).toBe(0); expect(stats.armor).toBe(0);
}); });
it("applies Räuberklinge's weapon damage and bonus attack", async () => { it("applies the Bandit Blade's weapon damage and bonus attack", async () => {
const scope = fakeScope([ const scope = fakeScope([
{ {
slot: EquipmentSlot.WEAPON, slot: EquipmentSlot.WEAPON,

View File

@@ -37,7 +37,7 @@ describe('CharactersService', () => {
currentLocation: { currentLocation: {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'Südtor von Graufurt', name: 'Graufurt South Gate',
}, },
}), }),
} as unknown as Repository<Character>; } as unknown as Repository<Character>;
@@ -57,7 +57,7 @@ describe('CharactersService', () => {
currentLocation: { currentLocation: {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'Südtor von Graufurt', name: 'Graufurt South Gate',
}, },
}); });
expect(characterStats.calculate).toHaveBeenCalledWith( expect(characterStats.calculate).toHaveBeenCalledWith(
@@ -78,7 +78,7 @@ describe('CharactersService', () => {
currentLocation: { currentLocation: {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'Südtor von Graufurt', name: 'Graufurt South Gate',
}, },
}), }),
} as unknown as Repository<Character>; } as unknown as Repository<Character>;

View File

@@ -209,7 +209,7 @@ function monster(
return { return {
id: MONSTER_ID, id: MONSTER_ID,
key: 'road-bandit', key: 'road-bandit',
name: 'Straßenräuber', name: 'Road Bandit',
level: 2, level: 2,
maxHp: 75, maxHp: 75,
attack: 9, attack: 9,
@@ -301,7 +301,7 @@ function createHarness() {
itemDefinition({ itemDefinition({
id: BANDIT_BLADE_DEFINITION_ID, id: BANDIT_BLADE_DEFINITION_ID,
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
weaponDamage: 11, weaponDamage: 11,
bonusAttack: 1, bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
@@ -360,7 +360,7 @@ function createHarness() {
return { state, equipmentService, combatService }; return { state, equipmentService, combatService };
} }
describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () => { describe('equipping the Bandit Blade increases combat damage (spec §45, §60)', () => {
it('deals more damage against the same monster after the upgrade than before it', async () => { it('deals more damage against the same monster after the upgrade than before it', async () => {
const { state, combatService, equipmentService } = createHarness(); const { state, combatService, equipmentService } = createHarness();

View File

@@ -193,7 +193,7 @@ function monster(
return { return {
id: MONSTER_ID, id: MONSTER_ID,
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Ash Rat',
level: 1, level: 1,
maxHp: 45, maxHp: 45,
attack: 5, attack: 5,
@@ -334,7 +334,7 @@ describe('CombatService', () => {
}); });
expect(combat.monster).toEqual({ expect(combat.monster).toEqual({
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Ash Rat',
level: 1, level: 1,
maxHp: 45, maxHp: 45,
currentHp: 45, currentHp: 45,
@@ -1016,7 +1016,7 @@ describe('CombatService', () => {
characterItemId: 'character-item-1', characterItemId: 'character-item-1',
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
rarity: 'COMMON', rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
}, },

View File

@@ -250,7 +250,7 @@ describe('seedVisibleVerticalSlice', () => {
expect(locationMonsterRepository.rows).toHaveLength(4); expect(locationMonsterRepository.rows).toHaveLength(4);
}); });
it('seeds the local view content of the Verbrannte Straße with four points of interest', async () => { it('seeds the local view content of the Burned Road with four points of interest', async () => {
const locationRepository = new InMemoryRepository(); const locationRepository = new InMemoryRepository();
const dataSource = createDataSource( const dataSource = createDataSource(
locationRepository, locationRepository,
@@ -306,7 +306,7 @@ describe('seedVisibleVerticalSlice', () => {
]); ]);
}); });
it('gives the Südtor its own local content so a second location needs no new component', async () => { it('gives the South Gate its own local content so a second location needs no new component', async () => {
const locationRepository = new InMemoryRepository(); const locationRepository = new InMemoryRepository();
const dataSource = createDataSource( const dataSource = createDataSource(
locationRepository, locationRepository,
@@ -347,7 +347,7 @@ describe('seedVisibleVerticalSlice', () => {
locationRepository.rows.push({ locationRepository.rows.push({
id: persistedSouthGateId, id: persistedSouthGateId,
key: 'south-gate', key: 'south-gate',
name: 'Veraltetes Südtor', name: 'Outdated South Gate',
}); });
const dataSource = createDataSource( const dataSource = createDataSource(
locationRepository, locationRepository,

View File

@@ -274,7 +274,7 @@ describe('EquipmentService', () => {
const banditBlade = itemDefinition({ const banditBlade = itemDefinition({
id: 'def-bandit-blade', id: 'def-bandit-blade',
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
weaponDamage: 11, weaponDamage: 11,
bonusAttack: 1, bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',

View File

@@ -6,7 +6,7 @@ const CHARACTER = { attack: 6, armor: 0, hp: 100 };
const CHARACTER_POWER = 44; const CHARACTER_POWER = 44;
describe('calculateDangerRating', () => { describe('calculateDangerRating', () => {
it('rates the seeded Aschenratte as MATCH', () => { it('rates the seeded Ash Rat as MATCH', () => {
// Aschenratte: attack 5, armor 0, maxHp 45 // Aschenratte: attack 5, armor 0, maxHp 45
// power(monster) = 5*4 + 0*2 + floor(45/5) = 20 + 0 + 9 = 29 // power(monster) = 5*4 + 0*2 + floor(45/5) = 20 + 0 + 9 = 29
// ratio = 29 / 44 = 0.6590909... -> not < 0.65, and < 1.0 -> MATCH // ratio = 29 / 44 = 0.6590909... -> not < 0.65, and < 1.0 -> MATCH
@@ -15,7 +15,7 @@ describe('calculateDangerRating', () => {
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.MATCH); expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.MATCH);
}); });
it('rates the seeded Straßenräuber as STRONG', () => { it('rates the seeded Road Bandit as STRONG', () => {
// Straßenräuber: attack 9, armor 5, maxHp 75 // Straßenräuber: attack 9, armor 5, maxHp 75
// power(monster) = 9*4 + 5*2 + floor(75/5) = 36 + 10 + 15 = 61 // power(monster) = 9*4 + 5*2 + floor(75/5) = 36 + 10 + 15 = 61
// ratio = 61 / 44 = 1.3863636... -> not < 1.0, and < 1.7 -> STRONG // ratio = 61 / 44 = 1.3863636... -> not < 1.0, and < 1.7 -> STRONG

View File

@@ -37,7 +37,7 @@ describe('HuntingController', () => {
it('delegates to huntingService.startHunt with the demo character id and returns its result', async () => { it('delegates to huntingService.startHunt with the demo character id and returns its result', async () => {
const huntResult = { const huntResult = {
id: 'hunt-1', id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' }, location: { id: 'loc-1', key: 'burned-road', name: 'Burned Road' },
encounters: [], encounters: [],
}; };
startHunt.mockResolvedValue(huntResult); startHunt.mockResolvedValue(huntResult);
@@ -53,13 +53,13 @@ describe('HuntingController', () => {
it('serves the resumable hunt with its encounter statuses', async () => { it('serves the resumable hunt with its encounter statuses', async () => {
const huntResult = { const huntResult = {
id: 'hunt-1', id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' }, location: { id: 'loc-1', key: 'burned-road', name: 'Burned Road' },
encounters: [ encounters: [
{ {
id: 'encounter-1', id: 'encounter-1',
monster: { monster: {
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Ash Rat',
level: 1, level: 1,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
}, },

View File

@@ -18,8 +18,8 @@ import type { RandomSource } from '../shared/random-source';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001'; const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002'; const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002';
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Aschenratte const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Ash Rat
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Strassenraeuber const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Road Bandit
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001'; const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002'; const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
@@ -191,7 +191,7 @@ function huntingLocation(): LocationDefinition {
return { return {
id: HUNTING_LOCATION_ID, id: HUNTING_LOCATION_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Strasse', name: 'Burned Road',
description: 'A burned road.', description: 'A burned road.',
regionKey: 'ashen-fields', regionKey: 'ashen-fields',
minRecommendedLevel: 1, minRecommendedLevel: 1,
@@ -367,12 +367,12 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition( const monsterA = monsterDefinition(
MONSTER_A_ID, MONSTER_A_ID,
'aschenratte', 'aschenratte',
'Aschenratte', 'Ash Rat',
); );
const monsterB = monsterDefinition( const monsterB = monsterDefinition(
MONSTER_B_ID, MONSTER_B_ID,
'strassenraeuber', 'strassenraeuber',
'Straßenräuber', 'Road Bandit',
); );
const state = createState(); const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID; state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -394,7 +394,7 @@ describe('HuntingService', () => {
expect(result.location).toEqual({ expect(result.location).toEqual({
id: HUNTING_LOCATION_ID, id: HUNTING_LOCATION_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Strasse', name: 'Burned Road',
}); });
expect(dataSource.state.hunts).toHaveLength(1); expect(dataSource.state.hunts).toHaveLength(1);
expect(dataSource.state.hunts[0]).toMatchObject({ expect(dataSource.state.hunts[0]).toMatchObject({
@@ -447,12 +447,12 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition( const monsterA = monsterDefinition(
MONSTER_A_ID, MONSTER_A_ID,
'aschenratte', 'aschenratte',
'Aschenratte', 'Ash Rat',
); );
const monsterB = monsterDefinition( const monsterB = monsterDefinition(
MONSTER_B_ID, MONSTER_B_ID,
'strassenraeuber', 'strassenraeuber',
'Straßenräuber', 'Road Bandit',
); );
const state = createState(); const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID; state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -488,7 +488,7 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition( const monsterA = monsterDefinition(
MONSTER_A_ID, MONSTER_A_ID,
'aschenratte', 'aschenratte',
'Aschenratte', 'Ash Rat',
); );
const state = createState(); const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID; state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -524,12 +524,12 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition( const monsterA = monsterDefinition(
MONSTER_A_ID, MONSTER_A_ID,
'aschenratte', 'aschenratte',
'Aschenratte', 'Ash Rat',
); );
const monsterB = monsterDefinition( const monsterB = monsterDefinition(
MONSTER_B_ID, MONSTER_B_ID,
'strassenraeuber', 'strassenraeuber',
'Straßenräuber', 'Road Bandit',
); );
const state = createState(); const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID; state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -559,7 +559,7 @@ describe('HuntingService', () => {
const weakMonster = monsterDefinition( const weakMonster = monsterDefinition(
MONSTER_A_ID, MONSTER_A_ID,
'aschenratte', 'aschenratte',
'Aschenratte', 'Ash Rat',
{ {
attack: 1, attack: 1,
armor: 0, armor: 0,
@@ -595,7 +595,7 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition( const monsterA = monsterDefinition(
MONSTER_A_ID, MONSTER_A_ID,
'aschenratte', 'aschenratte',
'Aschenratte', 'Ash Rat',
); );
const state = createState(); const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID; state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -637,7 +637,7 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition( const monsterA = monsterDefinition(
MONSTER_A_ID, MONSTER_A_ID,
'aschenratte', 'aschenratte',
'Aschenratte', 'Ash Rat',
); );
const state = createState(); const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID; state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -696,7 +696,7 @@ describe('HuntingService', () => {
expect(result?.location).toEqual({ expect(result?.location).toEqual({
id: HUNTING_LOCATION_ID, id: HUNTING_LOCATION_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Strasse', name: 'Burned Road',
}); });
expect(result?.encounters.map((encounter) => encounter.status)).toEqual([ expect(result?.encounters.map((encounter) => encounter.status)).toEqual([
HuntEncounterStatus.AVAILABLE, HuntEncounterStatus.AVAILABLE,

View File

@@ -18,8 +18,8 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
updatedAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'),
itemDefinition: { itemDefinition: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Worn Shortsword',
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.', description: "A recruit's blade, sharpened more often than it's been swung.",
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
type: ItemType.EQUIPMENT, type: ItemType.EQUIPMENT,
equipmentSlot: EquipmentSlot.WEAPON, equipmentSlot: EquipmentSlot.WEAPON,
@@ -66,8 +66,8 @@ describe('InventoryService', () => {
equipped: true, equipped: true,
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Worn Shortsword',
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.', description: "A recruit's blade, sharpened more often than it's been swung.",
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
weaponDamage: 8, weaponDamage: 8,

View File

@@ -121,7 +121,7 @@ function milestone(
return { return {
id: MILESTONE_ID, id: MILESTONE_ID,
key: 'first-hunt', key: 'first-hunt',
name: 'Erste erfolgreiche Jagd', name: 'First Successful Hunt',
description: '', description: '',
renownReward: 1, renownReward: 1,
repeatable: false, repeatable: false,

View File

@@ -158,7 +158,7 @@ function createState(overrides: Partial<State> = {}): State {
{ {
id: BANDIT_BLADE, id: BANDIT_BLADE,
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
type: ItemType.EQUIPMENT, type: ItemType.EQUIPMENT,
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
@@ -239,7 +239,7 @@ describe('CombatRewardService', () => {
}); });
}); });
describe('Aschenratte', () => { describe('Ash Rat', () => {
it('grants a silver roll inside 4-7, persisted on the character', async () => { it('grants a silver roll inside 4-7, persisted on the character', async () => {
const state = createState(); const state = createState();
@@ -266,7 +266,7 @@ describe('CombatRewardService', () => {
}); });
}); });
describe('Straßenräuber', () => { describe('Road Bandit', () => {
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID }); const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
it('grants a silver roll inside 9-15', async () => { it('grants a silver roll inside 9-15', async () => {
@@ -281,7 +281,7 @@ describe('CombatRewardService', () => {
expect(reward.silver).toBe(9); expect(reward.silver).toBe(9);
}); });
it('persists a dropped Räuberklinge as a CharacterItem and references it in the reward', async () => { it('persists a dropped Bandit Blade as a CharacterItem and references it in the reward', async () => {
const state = createState(); const state = createState();
const reward = await service( const reward = await service(
@@ -301,7 +301,7 @@ describe('CombatRewardService', () => {
characterItemId: state.characterItems[0].id, characterItemId: state.characterItems[0].id,
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
}, },
@@ -311,7 +311,7 @@ describe('CombatRewardService', () => {
expect(state.combatRewardItems).toHaveLength(1); expect(state.combatRewardItems).toHaveLength(1);
}); });
it('reports no items when the Räuberklinge does not drop', async () => { it('reports no items when the Bandit Blade does not drop', async () => {
const state = createState(); const state = createState();
const reward = await service(state, fakeLoot()).grantVictoryRewards( const reward = await service(state, fakeLoot()).grantVictoryRewards(
@@ -450,7 +450,7 @@ describe('CombatRewardService', () => {
{ {
id: BANDIT_BLADE, id: BANDIT_BLADE,
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
type: ItemType.EQUIPMENT, type: ItemType.EQUIPMENT,
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
@@ -458,7 +458,7 @@ describe('CombatRewardService', () => {
{ {
id: BANDIT_HOOD, id: BANDIT_HOOD,
key: 'bandit-hood', key: 'bandit-hood',
name: 'Räuberkapuze', name: 'Bandit Hood',
type: ItemType.EQUIPMENT, type: ItemType.EQUIPMENT,
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-hood.png', iconPath: '/images/items/bandit-hood.png',

View File

@@ -167,12 +167,12 @@ function createState(): FakeState {
const southGate = location( const southGate = location(
SOUTH_GATE_ID, SOUTH_GATE_ID,
'south-gate', 'south-gate',
'S\u00fcdtor von Graufurt', 'Graufurt South Gate',
); );
const burnedRoad = location( const burnedRoad = location(
BURNED_ROAD_ID, BURNED_ROAD_ID,
'burned-road', 'burned-road',
'Verbrannte Stra\u00dfe', 'Burned Road',
); );
const character: Character = { const character: Character = {
id: CHARACTER_ID, id: CHARACTER_ID,
@@ -237,12 +237,12 @@ describe('TravelService', () => {
originLocation: { originLocation: {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'S\u00fcdtor von Graufurt', name: 'Graufurt South Gate',
}, },
targetLocation: { targetLocation: {
id: BURNED_ROAD_ID, id: BURNED_ROAD_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Stra\u00dfe', name: 'Burned Road',
}, },
startedAt: new Date('2026-08-18T10:00:00.000Z'), startedAt: new Date('2026-08-18T10:00:00.000Z'),
arrivesAt: new Date('2026-08-18T10:00:10.000Z'), arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
@@ -319,12 +319,12 @@ describe('TravelService', () => {
originLocation: { originLocation: {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'S\u00fcdtor von Graufurt', name: 'Graufurt South Gate',
}, },
targetLocation: { targetLocation: {
id: BURNED_ROAD_ID, id: BURNED_ROAD_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Stra\u00dfe', name: 'Burned Road',
}, },
startedAt: new Date('2026-08-18T09:59:50.000Z'), startedAt: new Date('2026-08-18T09:59:50.000Z'),
arrivesAt: new Date('2026-08-18T10:00:10.000Z'), arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
@@ -341,12 +341,12 @@ describe('TravelService', () => {
originLocation: { originLocation: {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'S\u00fcdtor von Graufurt', name: 'Graufurt South Gate',
}, },
targetLocation: { targetLocation: {
id: BURNED_ROAD_ID, id: BURNED_ROAD_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Stra\u00dfe', name: 'Burned Road',
}, },
startedAt: new Date('2026-08-18T09:59:50.000Z'), startedAt: new Date('2026-08-18T09:59:50.000Z'),
arrivesAt: new Date('2026-08-18T10:00:00.001Z'), arrivesAt: new Date('2026-08-18T10:00:00.001Z'),
@@ -367,7 +367,7 @@ describe('TravelService', () => {
targetLocation: { targetLocation: {
id: BURNED_ROAD_ID, id: BURNED_ROAD_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Stra\u00dfe', name: 'Burned Road',
}, },
}); });
expect(dataSource.state.characters[0].currentLocationId).toBe( expect(dataSource.state.characters[0].currentLocationId).toBe(

View File

@@ -17,8 +17,8 @@ const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [ const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
{ {
key: 'hunt-area', key: 'hunt-area',
title: 'Jagdgebiet', title: 'Hunting Ground',
actionLabel: 'Jagd beginnen', actionLabel: 'Begin Hunt',
type: 'HUNT', type: 'HUNT',
iconKey: 'hunt', iconKey: 'hunt',
xPercent: 52, xPercent: 52,
@@ -27,42 +27,42 @@ const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
}, },
{ {
key: 'inspect-tracks', key: 'inspect-tracks',
title: 'Verdächtige Spuren', title: 'Suspicious Tracks',
actionLabel: 'Untersuchen', actionLabel: 'Investigate',
type: 'INVESTIGATE', type: 'INVESTIGATE',
iconKey: 'investigate', iconKey: 'investigate',
xPercent: 32, xPercent: 32,
yPercent: 78, yPercent: 78,
enabled: true, enabled: true,
resultTitle: 'Verdächtige Spuren', resultTitle: 'Suspicious Tracks',
resultText: resultText:
'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.', 'Between the ash and broken stones you make out several fresh bootprints.',
}, },
{ {
key: 'sealed-crypt', key: 'sealed-crypt',
title: 'Versiegelte Krypta', title: 'Sealed Crypt',
type: 'DUNGEON', type: 'DUNGEON',
iconKey: 'search', iconKey: 'search',
xPercent: 90, xPercent: 90,
yPercent: 20, yPercent: 20,
enabled: false, enabled: false,
resultTitle: 'Versiegelte Krypta', resultTitle: 'Sealed Crypt',
resultText: 'Noch verschlossen.', resultText: 'Still sealed.',
}, },
]; ];
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
{ {
key: 'gate-watch', key: 'gate-watch',
title: 'Torwache', title: 'Gate Watch',
actionLabel: 'Sprechen', actionLabel: 'Talk',
type: 'NPC', type: 'NPC',
iconKey: 'speak', iconKey: 'speak',
xPercent: 45, xPercent: 45,
yPercent: 52, yPercent: 52,
enabled: true, enabled: true,
resultTitle: 'Torwache', resultTitle: 'Gate Watch',
resultText: 'Nur am Südtor zu hören.', resultText: 'Only heard at the South Gate.',
}, },
]; ];
@@ -109,8 +109,8 @@ describe('WorldService.runLocalInteraction', () => {
service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'), service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
).resolves.toEqual({ ).resolves.toEqual({
interactionKey: 'inspect-tracks', interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren', title: 'Suspicious Tracks',
text: 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.', text: 'Between the ash and broken stones you make out several fresh bootprints.',
}); });
}); });

View File

@@ -19,8 +19,8 @@ describe('WorldController', () => {
it('forwards only the interaction key, never a caller-supplied location', () => { it('forwards only the interaction key, never a caller-supplied location', () => {
const runLocalInteraction = jest.fn().mockResolvedValue({ const runLocalInteraction = jest.fn().mockResolvedValue({
interactionKey: 'inspect-tracks', interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren', title: 'Suspicious Tracks',
text: 'Frische Stiefelabdrücke.', text: 'Fresh bootprints.',
}); });
const controller = new WorldController({ const controller = new WorldController({
runLocalInteraction, runLocalInteraction,

View File

@@ -20,23 +20,23 @@ const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
{ {
key: 'gate-watch', key: 'gate-watch',
title: 'Torwache', title: 'Gate Watch',
actionLabel: 'Sprechen', actionLabel: 'Talk',
type: 'NPC', type: 'NPC',
iconKey: 'speak', iconKey: 'speak',
xPercent: 45, xPercent: 45,
yPercent: 52, yPercent: 52,
enabled: true, enabled: true,
resultTitle: 'Torwache', resultTitle: 'Gate Watch',
resultText: 'Geheimer Servertext.', resultText: 'Secret server text.',
}, },
]; ];
const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [ const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
{ {
key: 'hunt-area', key: 'hunt-area',
title: 'Jagdgebiet', title: 'Hunting Ground',
actionLabel: 'Jagd beginnen', actionLabel: 'Begin Hunt',
type: 'HUNT', type: 'HUNT',
iconKey: 'hunt', iconKey: 'hunt',
xPercent: 52, xPercent: 52,
@@ -45,42 +45,42 @@ const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
}, },
{ {
key: 'inspect-tracks', key: 'inspect-tracks',
title: 'Verdächtige Spuren', title: 'Suspicious Tracks',
actionLabel: 'Untersuchen', actionLabel: 'Investigate',
type: 'INVESTIGATE', type: 'INVESTIGATE',
iconKey: 'investigate', iconKey: 'investigate',
xPercent: 32, xPercent: 32,
yPercent: 78, yPercent: 78,
enabled: true, enabled: true,
resultTitle: 'Verdächtige Spuren', resultTitle: 'Suspicious Tracks',
resultText: 'Frische Stiefelabdrücke führen nach Osten.', resultText: 'Fresh bootprints lead east.',
}, },
{ {
key: 'sealed-crypt', key: 'sealed-crypt',
title: 'Versiegelte Krypta', title: 'Sealed Crypt',
actionLabel: 'Öffnen', actionLabel: 'Open',
type: 'DUNGEON', type: 'DUNGEON',
iconKey: 'search', iconKey: 'search',
xPercent: 90, xPercent: 90,
yPercent: 20, yPercent: 20,
enabled: false, enabled: false,
resultTitle: 'Versiegelte Krypta', resultTitle: 'Sealed Crypt',
resultText: 'Noch verschlossen.', resultText: 'Still sealed.',
}, },
]; ];
const BURNED_ROAD_ACTIONS: LocationPrimaryActionContent[] = [ const BURNED_ROAD_ACTIONS: LocationPrimaryActionContent[] = [
{ {
key: 'start-hunt', key: 'start-hunt',
label: 'Jagd beginnen', label: 'Begin Hunt',
description: 'Im Gebiet jagen', description: 'Hunt in this area',
type: 'HUNT', type: 'HUNT',
iconKey: 'hunt', iconKey: 'hunt',
enabled: true, enabled: true,
}, },
{ {
key: 'investigate-tracks', key: 'investigate-tracks',
label: 'Spuren untersuchen', label: 'Investigate tracks',
type: 'INVESTIGATE', type: 'INVESTIGATE',
iconKey: 'investigate', iconKey: 'investigate',
enabled: true, enabled: true,
@@ -121,17 +121,17 @@ function poolEntry(
// worst entry this pool reads STRONG; judged by what a traveller actually // worst entry this pool reads STRONG; judged by what a traveller actually
// meets it reads MATCH. // meets it reads MATCH.
const BURNED_ROAD_POOL = [ const BURNED_ROAD_POOL = [
poolEntry('Aschenratte', 70, { level: 1, attack: 5, armor: 0, maxHp: 45 }), poolEntry('Ash Rat', 70, { level: 1, attack: 5, armor: 0, maxHp: 45 }),
poolEntry('Straßenräuber', 30, { level: 2, attack: 9, armor: 5, maxHp: 75 }), poolEntry('Road Bandit', 30, { level: 2, attack: 9, armor: 5, maxHp: 75 }),
]; ];
function currentLocation(): LocationDefinition { function currentLocation(): LocationDefinition {
return { return {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'S\u00fcdtor von Graufurt', name: 'Graufurt South Gate',
description: description:
'Das S\u00fcdtor ist der sichere Ausgangspunkt nach S\u00fcden.', 'The South Gate is the safe starting point heading south.',
regionKey: 'ashen-fields', regionKey: 'ashen-fields',
minRecommendedLevel: 1, minRecommendedLevel: 1,
maxRecommendedLevel: 1, maxRecommendedLevel: 1,
@@ -139,10 +139,10 @@ function currentLocation(): LocationDefinition {
isSafe: true, isSafe: true,
huntingEnabled: false, huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp', artworkPath: '/assets/locations/south-gate.webp',
regionName: 'Aschenfelder', regionName: 'Ashen Fields',
regionTierLabel: 'Gebiet 1', regionTierLabel: 'Tier 1',
locationType: 'TRANSITION', locationType: 'TRANSITION',
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.', localDescription: 'Beyond the watch-fires begin the Ashen Fields.',
localArtworkPath: '/images/backgrounds/Suedtor.png', localArtworkPath: '/images/backgrounds/Suedtor.png',
localPointsOfInterest: SOUTH_GATE_POIS, localPointsOfInterest: SOUTH_GATE_POIS,
localPrimaryActions: [], localPrimaryActions: [],
@@ -159,8 +159,8 @@ function burnedRoad(): LocationDefinition {
return { return {
id: BURNED_ROAD_ID, id: BURNED_ROAD_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Stra\u00dfe', name: 'Burned Road',
description: 'Eine verbrannte Handelsroute durch die Aschenfelder.', description: 'A burned trade route through the Ashen Fields.',
regionKey: 'ashen-fields', regionKey: 'ashen-fields',
minRecommendedLevel: 1, minRecommendedLevel: 1,
maxRecommendedLevel: 2, maxRecommendedLevel: 2,
@@ -168,14 +168,14 @@ function burnedRoad(): LocationDefinition {
isSafe: false, isSafe: false,
huntingEnabled: true, huntingEnabled: true,
artworkPath: '/assets/locations/burned-road.webp', artworkPath: '/assets/locations/burned-road.webp',
regionName: 'Aschenfelder', regionName: 'Ashen Fields',
regionTierLabel: 'Gebiet 1', regionTierLabel: 'Tier 1',
locationType: 'HUNTING_GROUND', locationType: 'HUNTING_GROUND',
localDescription: 'Ein alter Handelsweg, in Asche gelegt.', localDescription: 'An old trade road, burned to ash.',
localArtworkPath: '/images/backgrounds/Aschestrasse.png', localArtworkPath: '/images/backgrounds/Aschestrasse.png',
localPointsOfInterest: BURNED_ROAD_POIS, localPointsOfInterest: BURNED_ROAD_POIS,
localPrimaryActions: BURNED_ROAD_ACTIONS, localPrimaryActions: BURNED_ROAD_ACTIONS,
localRewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }], localRewardPreview: [{ key: 'silver', label: 'Silver', iconKey: 'silver' }],
createdAt: new Date('2026-08-18T09:00:00.000Z'), createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [], characters: [],
@@ -244,9 +244,9 @@ describe('WorldService', () => {
expect(result).toEqual({ expect(result).toEqual({
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'S\u00fcdtor von Graufurt', name: 'Graufurt South Gate',
description: description:
'Das S\u00fcdtor ist der sichere Ausgangspunkt nach S\u00fcden.', 'The South Gate is the safe starting point heading south.',
regionKey: 'ashen-fields', regionKey: 'ashen-fields',
minRecommendedLevel: 1, minRecommendedLevel: 1,
maxRecommendedLevel: 1, maxRecommendedLevel: 1,
@@ -254,18 +254,18 @@ describe('WorldService', () => {
isSafe: true, isSafe: true,
huntingEnabled: false, huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp', artworkPath: '/assets/locations/south-gate.webp',
regionName: 'Aschenfelder', regionName: 'Ashen Fields',
regionTierLabel: 'Gebiet 1', regionTierLabel: 'Tier 1',
locationType: 'TRANSITION', locationType: 'TRANSITION',
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.', localDescription: 'Beyond the watch-fires begin the Ashen Fields.',
localArtworkPath: '/images/backgrounds/Suedtor.png', localArtworkPath: '/images/backgrounds/Suedtor.png',
dangerRating: null, dangerRating: null,
recommendationLabel: '1', recommendationLabel: '1',
pointsOfInterest: [ pointsOfInterest: [
{ {
key: 'gate-watch', key: 'gate-watch',
title: 'Torwache', title: 'Gate Watch',
actionLabel: 'Sprechen', actionLabel: 'Talk',
type: 'NPC', type: 'NPC',
iconKey: 'speak', iconKey: 'speak',
xPercent: 45, xPercent: 45,
@@ -281,7 +281,7 @@ describe('WorldService', () => {
targetLocation: { targetLocation: {
id: BURNED_ROAD_ID, id: BURNED_ROAD_ID,
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Stra\u00dfe', name: 'Burned Road',
}, },
travelDurationSeconds: 10, travelDurationSeconds: 10,
danger: 'LOW', danger: 'LOW',
@@ -325,8 +325,8 @@ describe('WorldService', () => {
const result = await service.getCurrentLocation(CHARACTER_ID); const result = await service.getCurrentLocation(CHARACTER_ID);
expect(result.possibleMonsters).toEqual([ expect(result.possibleMonsters).toEqual([
'Aschenratte', 'Ash Rat',
'Stra\u00dfenr\u00e4uber', 'Road Bandit',
]); ]);
expect(findLocationMonsters).toHaveBeenCalledWith({ expect(findLocationMonsters).toHaveBeenCalledWith({
where: { locationId: BURNED_ROAD_ID, enabled: true }, where: { locationId: BURNED_ROAD_ID, enabled: true },
@@ -373,13 +373,13 @@ describe('WorldService', () => {
expect(result).toEqual( expect(result).toEqual(
expect.objectContaining({ expect.objectContaining({
regionName: 'Aschenfelder', regionName: 'Ashen Fields',
regionTierLabel: 'Gebiet 1', regionTierLabel: 'Tier 1',
locationType: 'HUNTING_GROUND', locationType: 'HUNTING_GROUND',
localDescription: 'Ein alter Handelsweg, in Asche gelegt.', localDescription: 'An old trade road, burned to ash.',
localArtworkPath: '/images/backgrounds/Aschestrasse.png', localArtworkPath: '/images/backgrounds/Aschestrasse.png',
recommendationLabel: '12', recommendationLabel: '12',
rewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }], rewardPreview: [{ key: 'silver', label: 'Silver', iconKey: 'silver' }],
}), }),
); );
expect(result.primaryActions).toEqual(BURNED_ROAD_ACTIONS); expect(result.primaryActions).toEqual(BURNED_ROAD_ACTIONS);
@@ -397,8 +397,8 @@ describe('WorldService', () => {
expect(result.pointsOfInterest).toEqual([ expect(result.pointsOfInterest).toEqual([
{ {
key: 'hunt-area', key: 'hunt-area',
title: 'Jagdgebiet', title: 'Hunting Ground',
actionLabel: 'Jagd beginnen', actionLabel: 'Begin Hunt',
type: 'HUNT', type: 'HUNT',
iconKey: 'hunt', iconKey: 'hunt',
xPercent: 52, xPercent: 52,
@@ -407,8 +407,8 @@ describe('WorldService', () => {
}, },
{ {
key: 'inspect-tracks', key: 'inspect-tracks',
title: 'Verdächtige Spuren', title: 'Suspicious Tracks',
actionLabel: 'Untersuchen', actionLabel: 'Investigate',
type: 'INVESTIGATE', type: 'INVESTIGATE',
iconKey: 'investigate', iconKey: 'investigate',
xPercent: 32, xPercent: 32,
@@ -417,8 +417,8 @@ describe('WorldService', () => {
}, },
{ {
key: 'sealed-crypt', key: 'sealed-crypt',
title: 'Versiegelte Krypta', title: 'Sealed Crypt',
actionLabel: 'Öffnen', actionLabel: 'Open',
type: 'DUNGEON', type: 'DUNGEON',
iconKey: 'search', iconKey: 'search',
xPercent: 90, xPercent: 90,
@@ -426,8 +426,8 @@ describe('WorldService', () => {
enabled: false, enabled: false,
}, },
]); ]);
expect(JSON.stringify(result)).not.toContain('Frische Stiefelabdrücke'); expect(JSON.stringify(result)).not.toContain('Fresh bootprints');
expect(JSON.stringify(result)).not.toContain('Noch verschlossen'); expect(JSON.stringify(result)).not.toContain('Still sealed');
}); });
it('derives the encounter preview from the location monster pool', async () => { it('derives the encounter preview from the location monster pool', async () => {
@@ -435,16 +435,16 @@ describe('WorldService', () => {
expect(result.encounterPreview).toEqual([ expect(result.encounterPreview).toEqual([
{ {
key: 'aschenratte', key: 'ash rat',
name: 'Aschenratte', name: 'Ash Rat',
level: 1, level: 1,
iconPath: '/images/monsters/icons/aschenratte-128.png', iconPath: '/images/monsters/icons/ash rat-128.png',
}, },
{ {
key: 'straßenräuber', key: 'road bandit',
name: 'Straßenräuber', name: 'Road Bandit',
level: 2, level: 2,
iconPath: '/images/monsters/icons/straßenräuber-128.png', iconPath: '/images/monsters/icons/road bandit-128.png',
}, },
]); ]);
}); });

View File

@@ -161,7 +161,7 @@ describe('App', () => {
attack: 1, attack: 1,
hpRegenPerSecond: 1, hpRegenPerSecond: 1,
hpRegenSince: null, hpRegenSince: null,
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' }, currentLocation: { id: 'location-id', key: 'south-gate', name: 'Graufurt South Gate' },
}; };
const value: CharacterResponse = { const value: CharacterResponse = {
id: 'character-id', id: 'character-id',
@@ -173,7 +173,7 @@ describe('App', () => {
attack: 12, attack: 12,
hpRegenPerSecond: 1, hpRegenPerSecond: 1,
hpRegenSince: null, hpRegenSince: null,
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' }, currentLocation: { id: 'location-id', key: 'south-gate', name: 'Graufurt South Gate' },
}; };
character.set(decoy); character.set(decoy);
displayedCharacter.set(value); displayedCharacter.set(value);

View File

@@ -54,7 +54,7 @@ describe('GameApiService', () => {
const request = http.expectOne('/api/world/current-location/interactions/inspect-tracks'); const request = http.expectOne('/api/world/current-location/interactions/inspect-tracks');
expect(request.request.method).toBe('POST'); expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({}); expect(request.request.body).toEqual({});
request.flush({ interactionKey: 'inspect-tracks', title: 'Spuren', text: '…' }); request.flush({ interactionKey: 'inspect-tracks', title: 'Suspicious Tracks', text: '…' });
}); });
it('escapes an interaction key so it cannot break out of its path segment', () => { it('escapes an interaction key so it cannot break out of its path segment', () => {

View File

@@ -12,7 +12,7 @@ const runningCombat: Combat = {
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62, potionsRemaining: 2, potionsMax: 2 }, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62, potionsRemaining: 2, potionsMax: 2 },
monster: { monster: {
key: 'road-bandit', key: 'road-bandit',
name: 'Straßenräuber', name: 'Road Bandit',
level: 3, level: 3,
maxHp: 75, maxHp: 75,
currentHp: 30, currentHp: 30,

View File

@@ -14,7 +14,7 @@ const activeCombat: Combat = {
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 }, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
monster: { monster: {
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Ash Rat',
level: 1, level: 1,
maxHp: 45, maxHp: 45,
currentHp: 31, currentHp: 31,
@@ -28,7 +28,7 @@ const activeCombat: Combat = {
rewards: null, rewards: null,
}; };
const monsterHitLine = 'Aschenratte hits Aric Duskwalker for 5 damage.'; const monsterHitLine = 'Ash Rat hits Aric Duskwalker for 5 damage.';
function countOccurrences(haystack: string | null, needle: string): number { function countOccurrences(haystack: string | null, needle: string): number {
return haystack ? haystack.split(needle).length - 1 : 0; return haystack ? haystack.split(needle).length - 1 : 0;
@@ -97,7 +97,7 @@ describe('CombatPageComponent', () => {
expect(element.textContent).toContain('Aric Duskwalker'); expect(element.textContent).toContain('Aric Duskwalker');
expect(element.textContent).toContain('95 / 100'); expect(element.textContent).toContain('95 / 100');
expect(element.textContent).toContain('Aschenratte'); expect(element.textContent).toContain('Ash Rat');
expect(element.textContent).toContain('31 / 45'); expect(element.textContent).toContain('31 / 45');
expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Round 2'); expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Round 2');
expect(element.querySelector('[data-combat-attack]')).toBeTruthy(); expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
@@ -169,7 +169,7 @@ describe('CombatPageComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain( expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain(
'Aschenratte is winding up a Heavy Strike.', 'Ash Rat is winding up a Heavy Strike.',
); );
}); });
@@ -194,16 +194,16 @@ describe('CombatPageComponent', () => {
expect(element.textContent).toContain('Aric Duskwalker drinks a potion and heals 35 HP.'); expect(element.textContent).toContain('Aric Duskwalker drinks a potion and heals 35 HP.');
expect(element.textContent).toContain('Aric Duskwalker braces to defend.'); expect(element.textContent).toContain('Aric Duskwalker braces to defend.');
expect(element.textContent).toContain('Aschenratte is winding up a Heavy Strike.'); expect(element.textContent).toContain('Ash Rat is winding up a Heavy Strike.');
expect(element.textContent).toContain("Aric Duskwalker interrupts Aschenratte's attack."); expect(element.textContent).toContain("Aric Duskwalker interrupts Ash Rat's attack.");
}); });
it('renders the structured events as readable English combat-log entries', async () => { it('renders the structured events as readable English combat-log entries', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker hits Aschenratte for 14 damage.'); expect(element.textContent).toContain('Aric Duskwalker hits Ash Rat for 14 damage.');
expect(element.textContent).toContain('Aschenratte hits Aric Duskwalker for 5 damage.'); expect(element.textContent).toContain('Ash Rat hits Aric Duskwalker for 5 damage.');
}); });
it('calls combatStore.performAction("ATTACK") when Attack is clicked', async () => { it('calls combatStore.performAction("ATTACK") when Attack is clicked', async () => {
@@ -334,7 +334,7 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(540); await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges(); fixture.detectChanges();
expect(element.textContent).toContain("Aric Duskwalker interrupts Aschenratte's attack."); expect(element.textContent).toContain("Aric Duskwalker interrupts Ash Rat's attack.");
expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
expect(monster?.classList.contains('sprite--lunge')).toBe(false); expect(monster?.classList.contains('sprite--lunge')).toBe(false);
}); });
@@ -557,7 +557,7 @@ describe('CombatPageComponent', () => {
it('renders a dropped item with its icon, name, and rarity', async () => { it('renders a dropped item with its icon, name, and rarity', async () => {
const fixture = await setup({ const fixture = await setup({
...wonWithRewards, ...wonWithRewards,
monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Straßenräuber', currentHp: 0 }, monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Road Bandit', currentHp: 0 },
rewards: { rewards: {
silver: 12, silver: 12,
items: [ items: [
@@ -565,7 +565,7 @@ describe('CombatPageComponent', () => {
characterItemId: 'character-item-1', characterItemId: 'character-item-1',
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
rarity: 'COMMON', rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
}, },
@@ -579,7 +579,7 @@ describe('CombatPageComponent', () => {
expect(element.querySelector<HTMLImageElement>('[data-item-icon]')?.getAttribute('src')).toBe( expect(element.querySelector<HTMLImageElement>('[data-item-icon]')?.getAttribute('src')).toBe(
'/images/items/bandit-blade.png', '/images/items/bandit-blade.png',
); );
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge'); expect(element.querySelector('[data-item-name]')?.textContent).toContain('Bandit Blade');
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Common'); expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Common');
expect(element.querySelector('[data-reward-empty]')).toBeNull(); expect(element.querySelector('[data-reward-empty]')).toBeNull();
}); });

View File

@@ -13,7 +13,7 @@ const startedCombat: Combat = {
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 }, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
monster: { monster: {
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Ash Rat',
level: 1, level: 1,
maxHp: 45, maxHp: 45,
currentHp: 45, currentHp: 45,

View File

@@ -7,7 +7,7 @@ const dawnwolfEncounter: HuntEncounter = {
id: 'encounter-id-9f2c', id: 'encounter-id-9f2c',
monster: { monster: {
key: 'dawnwolf', key: 'dawnwolf',
name: 'Dämmerwolf', name: 'Dawnwolf',
level: 3, level: 3,
artworkPath: '/images/enemies/Dawnwolf.png', artworkPath: '/images/enemies/Dawnwolf.png',
}, },
@@ -19,7 +19,7 @@ const ashRatEncounter: HuntEncounter = {
id: 'encounter-id-4b71', id: 'encounter-id-4b71',
monster: { monster: {
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Ash Rat',
level: 1, level: 1,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
}, },
@@ -45,11 +45,11 @@ describe('EncounterCardComponent', () => {
const element = render(dawnwolfEncounter); const element = render(dawnwolfEncounter);
const artwork = element.querySelector('.encounter-card__artwork'); const artwork = element.querySelector('.encounter-card__artwork');
expect(element.textContent).toContain('Dämmerwolf'); expect(element.textContent).toContain('Dawnwolf');
expect(element.textContent).toContain('Level 3'); expect(element.textContent).toContain('Level 3');
expect(element.textContent).toContain('Suitable'); expect(element.textContent).toContain('Suitable');
expect(artwork?.getAttribute('src')).toBe('/images/enemies/Dawnwolf.png'); expect(artwork?.getAttribute('src')).toBe('/images/enemies/Dawnwolf.png');
expect(artwork?.getAttribute('alt')).toBe('Dämmerwolf'); expect(artwork?.getAttribute('alt')).toBe('Dawnwolf');
}); });
it('shows the background-free cut-out instead of the framed artwork when one exists', () => { it('shows the background-free cut-out instead of the framed artwork when one exists', () => {

View File

@@ -19,11 +19,11 @@ const burnedRoad = burnedRoadFixture({ connections: [] });
const threeEncounterHunt: HuntResult = { const threeEncounterHunt: HuntResult = {
id: 'hunt-id', id: 'hunt-id',
location: { id: 'burned-road-id', key: 'burned-road', name: 'Verbrannte Straße' }, location: { id: 'burned-road-id', key: 'burned-road', name: 'Burned Road' },
encounters: [ encounters: [
{ {
id: 'encounter-1', id: 'encounter-1',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' }, monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK', dangerRating: 'WEAK',
status: 'AVAILABLE', status: 'AVAILABLE',
}, },
@@ -31,7 +31,7 @@ const threeEncounterHunt: HuntResult = {
id: 'encounter-2', id: 'encounter-2',
monster: { monster: {
key: 'road-bandit', key: 'road-bandit',
name: 'Straßenräuber', name: 'Road Bandit',
level: 3, level: 3,
artworkPath: '/images/enemies/RoadBandit.png', artworkPath: '/images/enemies/RoadBandit.png',
}, },
@@ -40,7 +40,7 @@ const threeEncounterHunt: HuntResult = {
}, },
{ {
id: 'encounter-3', id: 'encounter-3',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' }, monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK', dangerRating: 'WEAK',
status: 'AVAILABLE', status: 'AVAILABLE',
}, },
@@ -54,7 +54,7 @@ const startedCombat: Combat = {
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 }, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
monster: { monster: {
key: 'road-bandit', key: 'road-bandit',
name: 'Straßenräuber', name: 'Road Bandit',
level: 3, level: 3,
maxHp: 75, maxHp: 75,
currentHp: 75, currentHp: 75,
@@ -130,7 +130,7 @@ describe('HuntPageComponent', () => {
return fixture; return fixture;
} }
it('shows the hunting-unavailable state at the Südtor, with no Begin Hunt button, and a way back to the location', async () => { it('shows the hunting-unavailable state at the South Gate, with no Begin Hunt button, and a way back to the location', async () => {
const fixture = await setup(southGate); const fixture = await setup(southGate);
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
@@ -164,7 +164,7 @@ describe('HuntPageComponent', () => {
const cards = element.querySelectorAll('app-encounter-card'); const cards = element.querySelectorAll('app-encounter-card');
expect(cards.length).toBe(3); expect(cards.length).toBe(3);
expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/); expect(element.textContent).toMatch(/Ash Rat[\s\S]*Road Bandit[\s\S]*Ash Rat/);
expect( expect(
element.querySelectorAll( element.querySelectorAll(
'.encounter-card__artwork[src="/images/combat/sprites/ash-rat-760.png"]', '.encounter-card__artwork[src="/images/combat/sprites/ash-rat-760.png"]',

View File

@@ -8,7 +8,7 @@ import { HuntingStore } from './hunting.store';
const huntResult: HuntResult = { const huntResult: HuntResult = {
id: 'hunt-id', id: 'hunt-id',
location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, location: { id: 'origin-id', key: 'south-gate', name: 'South Gate' },
encounters: [ encounters: [
{ {
id: 'encounter-1', id: 'encounter-1',
@@ -18,7 +18,7 @@ const huntResult: HuntResult = {
}, },
{ {
id: 'encounter-2', id: 'encounter-2',
monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' }, monster: { key: 'bear', name: 'Bear', level: 3, artworkPath: '/images/enemies/Bear.png' },
dangerRating: 'STRONG', dangerRating: 'STRONG',
status: 'DEFEATED', status: 'DEFEATED',
}, },
@@ -27,11 +27,11 @@ const huntResult: HuntResult = {
const refreshedHuntResult: HuntResult = { const refreshedHuntResult: HuntResult = {
id: 'hunt-id-2', id: 'hunt-id-2',
location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, location: { id: 'origin-id', key: 'south-gate', name: 'South Gate' },
encounters: [ encounters: [
{ {
id: 'encounter-3', id: 'encounter-3',
monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' }, monster: { key: 'rat', name: 'Rat', level: 1, artworkPath: '/images/enemies/Rat.png' },
dangerRating: 'WEAK', dangerRating: 'WEAK',
status: 'AVAILABLE', status: 'AVAILABLE',
}, },

View File

@@ -10,7 +10,7 @@ const wornSword: InventoryItem = {
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.', description: 'Item description.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
weaponDamage: 8, weaponDamage: 8,
@@ -27,8 +27,8 @@ const banditBlade: InventoryItem = {
equipped: false, equipped: false,
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
description: 'Beschreibung des Gegenstands.', description: 'Item description.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
weaponDamage: 11, weaponDamage: 11,
@@ -46,7 +46,7 @@ const ashPelt: InventoryItem = {
item: { item: {
key: 'ash-pelt', key: 'ash-pelt',
name: 'Aschenfell', name: 'Aschenfell',
description: 'Beschreibung des Gegenstands.', description: 'Item description.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: null, equipmentSlot: null,
weaponDamage: 0, weaponDamage: 0,

View File

@@ -11,10 +11,10 @@ interface StatRow {
type StatKey = 'weaponDamage' | 'bonusAttack' | 'bonusHp' | 'bonusArmor'; type StatKey = 'weaponDamage' | 'bonusAttack' | 'bonusHp' | 'bonusArmor';
const STAT_LABELS: ReadonlyArray<{ label: string; key: StatKey }> = [ const STAT_LABELS: ReadonlyArray<{ label: string; key: StatKey }> = [
{ label: 'Waffenschaden', key: 'weaponDamage' }, { label: 'Weapon Damage', key: 'weaponDamage' },
{ label: 'Angriff', key: 'bonusAttack' }, { label: 'Attack', key: 'bonusAttack' },
{ label: 'Leben', key: 'bonusHp' }, { label: 'Health', key: 'bonusHp' },
{ label: 'Rüstung', key: 'bonusArmor' }, { label: 'Armor', key: 'bonusArmor' },
]; ];
/** Selected-item details and equip comparison (spec §3237). */ /** Selected-item details and equip comparison (spec §3237). */

View File

@@ -17,7 +17,7 @@ const inventory: InventoryResponse = {
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.', description: 'Item description.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
weaponDamage: 8, weaponDamage: 8,
@@ -33,8 +33,8 @@ const inventory: InventoryResponse = {
equipped: false, equipped: false,
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
description: 'Beschreibung des Gegenstands.', description: 'Item description.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
weaponDamage: 11, weaponDamage: 11,
@@ -70,7 +70,7 @@ const character: CharacterResponse = {
attack: 6, attack: 6,
hpRegenPerSecond: 1, hpRegenPerSecond: 1,
hpRegenSince: null, hpRegenSince: null,
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' }, currentLocation: { id: 'loc-1', key: 'south-gate', name: 'South Gate' },
}; };
interface SetupOptions { interface SetupOptions {
@@ -176,8 +176,8 @@ describe('InventoryPageComponent', () => {
equipped: true, equipped: true,
item: { item: {
key: 'iron-helm', key: 'iron-helm',
name: 'Eiserner Helm', name: 'Iron Helm',
description: 'Beschreibung des Gegenstands.', description: 'Item description.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'HEAD', equipmentSlot: 'HEAD',
weaponDamage: 0, weaponDamage: 0,

View File

@@ -16,7 +16,7 @@ const inventory: InventoryResponse = {
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert', name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.', description: 'Item description.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
weaponDamage: 8, weaponDamage: 8,
@@ -32,8 +32,8 @@ const inventory: InventoryResponse = {
equipped: false, equipped: false,
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
description: 'Beschreibung des Gegenstands.', description: 'Item description.',
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
weaponDamage: 11, weaponDamage: 11,
@@ -61,7 +61,7 @@ const equipment: EquipmentResponse = {
const equippedAfter: EquipmentResponse = { const equippedAfter: EquipmentResponse = {
...equipment, ...equipment,
slots: { ...equipment.slots, WEAPON: { characterItemId: 'item-blade', item: { key: 'bandit-blade', name: 'Räuberklinge', rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png' } } }, slots: { ...equipment.slots, WEAPON: { characterItemId: 'item-blade', item: { key: 'bandit-blade', name: 'Bandit Blade', rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png' } } },
stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 }, stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 },
}; };

View File

@@ -8,8 +8,8 @@ import { WorldStore } from './world.store';
const trackResult = { const trackResult = {
interactionKey: 'inspect-tracks', interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren', title: 'Suspicious Tracks',
text: 'Frische Stiefelabdrücke führen nach Osten.', text: 'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
}; };
function setup(api: Partial<GameApiService> = {}, world: Partial<WorldStore> = {}) { function setup(api: Partial<GameApiService> = {}, world: Partial<WorldStore> = {}) {
@@ -128,7 +128,7 @@ describe('LocalLocationStore', () => {
expect( expect(
store.interactionKeyOf({ store.interactionKeyOf({
key: 'investigate-tracks', key: 'investigate-tracks',
label: 'Spuren untersuchen', label: 'Investigate tracks',
type: 'INVESTIGATE', type: 'INVESTIGATE',
iconKey: 'investigate', iconKey: 'investigate',
enabled: true, enabled: true,

View File

@@ -37,25 +37,27 @@ describe('LocationInteractionPanelComponent', () => {
const { element } = await setup({ const { element } = await setup({
result: { result: {
interactionKey: 'inspect-tracks', interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren', title: 'Suspicious Tracks',
text: 'Frische Stiefelabdrücke führen nach Osten.', text: 'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
}, },
}); });
expect(element.textContent).toContain('Verdächtige Spuren'); expect(element.textContent).toContain('Suspicious Tracks');
expect(element.textContent).toContain('Frische Stiefelabdrücke führen nach Osten.'); expect(element.textContent).toContain(
'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
);
}); });
it('shows an NPC line through the same panel, with no branching choices', async () => { it('shows an NPC line through the same panel, with no branching choices', async () => {
const { element } = await setup({ const { element } = await setup({
result: { result: {
interactionKey: 'wounded-scout', interactionKey: 'wounded-scout',
title: 'Verwundeter Kundschafter', title: 'Wounded Scout',
text: '„Die Straße ist nicht mehr sicher."', text: '"The road isn\'t safe anymore. The raiders are coming from the direction of the old watchpost. If you keep going, keep your eyes open."',
}, },
}); });
expect(element.textContent).toContain('Verwundeter Kundschafter'); expect(element.textContent).toContain('Wounded Scout');
// Exactly one control: close. A dialogue tree is out of scope here. // Exactly one control: close. A dialogue tree is out of scope here.
expect(element.querySelectorAll('button')).toHaveLength(1); expect(element.querySelectorAll('button')).toHaveLength(1);
}); });

View File

@@ -62,11 +62,11 @@ describe('LocationPageComponent', () => {
it('names the place and its region straight away', async () => { it('names the place and its region straight away', async () => {
const { element } = await setup(); const { element } = await setup();
expect(element.querySelector('h1')?.textContent).toContain('Verbrannte Straße'); expect(element.querySelector('h1')?.textContent).toContain('Burned Road');
expect(element.textContent).toContain('Gebiet 1'); expect(element.textContent).toContain('Tier 1');
expect(element.textContent).toContain('Aschenfelder'); expect(element.textContent).toContain('Ashen Fields');
expect(element.textContent).toContain( expect(element.textContent).toContain(
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.', 'An old trade road, burned to ash by fire and war. Charred carts, broken weapons, and silenced cries line the path into the Ashen Fields.',
); );
}); });
@@ -75,7 +75,7 @@ describe('LocationPageComponent', () => {
const image = element.querySelector('img') as HTMLImageElement; const image = element.querySelector('img') as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/images/backgrounds/Aschestrasse.png'); expect(image.getAttribute('src')).toBe('/images/backgrounds/Aschestrasse.png');
expect(image.getAttribute('alt')).toBe('Location view: Verbrannte Straße'); expect(image.getAttribute('alt')).toBe('Location view: Burned Road');
}); });
it('places every hotspot on the artwork', async () => { it('places every hotspot on the artwork', async () => {
@@ -96,10 +96,10 @@ describe('LocationPageComponent', () => {
const actions = element.querySelectorAll('[data-action]'); const actions = element.querySelectorAll('[data-action]');
expect([...actions].map((action) => action.querySelector('.location-action__label')?.textContent?.trim())).toEqual([ expect([...actions].map((action) => action.querySelector('.location-action__label')?.textContent?.trim())).toEqual([
'Jagd beginnen', 'Begin Hunt',
'Spuren untersuchen', 'Investigate tracks',
'Umgebung durchsuchen', 'Search surroundings',
'Zur Karte', 'To Map',
]); ]);
}); });
@@ -173,13 +173,13 @@ describe('LocationPageComponent', () => {
interactionResult.set({ interactionResult.set({
interactionKey: 'inspect-tracks', interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren', title: 'Suspicious Tracks',
text: 'Frische Stiefelabdrücke führen nach Osten.', text: 'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
}); });
fixture.detectChanges(); fixture.detectChanges();
expect(element.querySelector('[data-interaction-panel]')?.textContent).toContain( expect(element.querySelector('[data-interaction-panel]')?.textContent).toContain(
'Frische Stiefelabdrücke führen nach Osten.', 'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
); );
// The scene is not replaced by the panel. // The scene is not replaced by the panel.
expect(element.querySelector('img')).not.toBeNull(); expect(element.querySelector('img')).not.toBeNull();
@@ -234,7 +234,7 @@ describe('LocationPageComponent', () => {
it('renders a second location from its own data alone', async () => { it('renders a second location from its own data alone', async () => {
const { element } = await setup(southGateFixture(southGateContent())); const { element } = await setup(southGateFixture(southGateContent()));
expect(element.querySelector('h1')?.textContent).toContain('Südtor von Graufurt'); expect(element.querySelector('h1')?.textContent).toContain('Graufurt South Gate');
expect(element.querySelectorAll('app-location-poi')).toHaveLength(1); expect(element.querySelectorAll('app-location-poi')).toHaveLength(1);
expect(element.querySelectorAll('[data-action]')).toHaveLength(1); expect(element.querySelectorAll('[data-action]')).toHaveLength(1);
}); });
@@ -245,8 +245,8 @@ function southGateContent(): Partial<CurrentLocationResponse> {
pointsOfInterest: [ pointsOfInterest: [
{ {
key: 'gate-watch', key: 'gate-watch',
title: 'Torwache', title: 'Gate Watch',
actionLabel: 'Sprechen', actionLabel: 'Talk',
type: 'NPC', type: 'NPC',
iconKey: 'speak', iconKey: 'speak',
xPercent: 45, xPercent: 45,
@@ -257,8 +257,8 @@ function southGateContent(): Partial<CurrentLocationResponse> {
primaryActions: [ primaryActions: [
{ {
key: 'talk-to-watch', key: 'talk-to-watch',
label: 'Wache ansprechen', label: 'Talk to the watch',
description: 'Lage erfragen', description: 'Ask about the situation',
type: 'NPC', type: 'NPC',
iconKey: 'speak', iconKey: 'speak',
enabled: true, enabled: true,

View File

@@ -4,8 +4,8 @@ import { LocationPoiComponent } from './location-poi.component';
const wagon: LocationPointOfInterest = { const wagon: LocationPointOfInterest = {
key: 'search-abandoned-wagon', key: 'search-abandoned-wagon',
title: 'Verlassener Wagen', title: 'Abandoned Wagon',
actionLabel: 'Durchsuchen', actionLabel: 'Search',
type: 'SEARCH', type: 'SEARCH',
iconKey: 'search', iconKey: 'search',
xPercent: 80, xPercent: 80,
@@ -53,8 +53,8 @@ describe('LocationPoiComponent', () => {
const { fixture } = await setup(); const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('Verlassener Wagen'); expect(text).toContain('Abandoned Wagon');
expect(text).toContain('Durchsuchen'); expect(text).toContain('Search');
}); });
it('emits the whole hotspot when clicked', async () => { it('emits the whole hotspot when clicked', async () => {
@@ -102,7 +102,7 @@ describe('LocationPoiComponent', () => {
it('describes itself with both title and action for screen readers', async () => { it('describes itself with both title and action for screen readers', async () => {
const { button } = await setup(); const { button } = await setup();
expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen: Durchsuchen'); expect(button.getAttribute('aria-label')).toBe('Abandoned Wagon: Search');
}); });
it('falls back to the title alone when a hotspot has no action label', async () => { it('falls back to the title alone when a hotspot has no action label', async () => {
@@ -111,6 +111,6 @@ describe('LocationPoiComponent', () => {
actionLabel: undefined, actionLabel: undefined,
}); });
expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen'); expect(button.getAttribute('aria-label')).toBe('Abandoned Wagon');
}); });
}); });

View File

@@ -20,8 +20,8 @@ describe('LocationSidebarComponent', () => {
const element = await render(burnedRoadFixture()); const element = await render(burnedRoadFixture());
const text = element.textContent ?? ''; const text = element.textContent ?? '';
expect(text).toContain('Aschenfelder'); expect(text).toContain('Ashen Fields');
expect(text).toContain('Verbrannte Straße'); expect(text).toContain('Burned Road');
expect(text).toContain('Hunting Ground'); expect(text).toContain('Hunting Ground');
expect(text).toContain('12'); expect(text).toContain('12');
}); });
@@ -44,8 +44,8 @@ describe('LocationSidebarComponent', () => {
const encounters = element.querySelectorAll('[data-sidebar="encounters"] li'); const encounters = element.querySelectorAll('[data-sidebar="encounters"] li');
expect([...encounters].map((item) => item.textContent?.trim())).toEqual([ expect([...encounters].map((item) => item.textContent?.trim())).toEqual([
'Aschenratte', 'Ash Rat',
'Straßenräuber', 'Road Bandit',
]); ]);
expect(element.textContent).toContain('the hunt rolls independently'); expect(element.textContent).toContain('the hunt rolls independently');
}); });
@@ -55,10 +55,10 @@ describe('LocationSidebarComponent', () => {
const interactions = element.querySelectorAll('[data-sidebar="interactions"] li'); const interactions = element.querySelectorAll('[data-sidebar="interactions"] li');
expect([...interactions].map((item) => item.textContent?.trim())).toEqual([ expect([...interactions].map((item) => item.textContent?.trim())).toEqual([
'Jagd beginnen', 'Begin Hunt',
'Untersuchen', 'Investigate',
'Durchsuchen', 'Search',
'Sprechen', 'Talk',
]); ]);
}); });
@@ -72,7 +72,7 @@ describe('LocationSidebarComponent', () => {
}); });
expect(element.querySelector('[data-sidebar="interactions"]')?.textContent).not.toContain( expect(element.querySelector('[data-sidebar="interactions"]')?.textContent).not.toContain(
'Sprechen', 'Talk',
); );
}); });
@@ -81,7 +81,7 @@ describe('LocationSidebarComponent', () => {
const rewards = element.querySelectorAll('[data-sidebar="rewards"] li'); const rewards = element.querySelectorAll('[data-sidebar="rewards"] li');
expect([...rewards].map((item) => item.textContent?.trim())).toEqual([ expect([...rewards].map((item) => item.textContent?.trim())).toEqual([
'Ausrüstung', 'Equipment',
'Material', 'Material',
]); ]);
}); });

View File

@@ -16,7 +16,7 @@ const burnedRoadConnection: CurrentLocationConnection = {
targetLocation: { targetLocation: {
id: 'burned-road-id', id: 'burned-road-id',
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Straße', name: 'Burned Road',
}, },
travelDurationSeconds: 10, travelDurationSeconds: 10,
danger: 'LOW', danger: 'LOW',
@@ -84,7 +84,7 @@ describe('WorldPageComponent', () => {
expect(store.selectConnection).toHaveBeenCalledWith(burnedRoadConnection); expect(store.selectConnection).toHaveBeenCalledWith(burnedRoadConnection);
expect(element.textContent).toContain('Destination'); expect(element.textContent).toContain('Destination');
expect(element.textContent).toContain('Verbrannte Straße'); expect(element.textContent).toContain('Burned Road');
expect(element.textContent).toContain('00:00:10'); expect(element.textContent).toContain('00:00:10');
expect(element.textContent).toContain('Low'); expect(element.textContent).toContain('Low');
}); });
@@ -129,7 +129,7 @@ describe('WorldPageComponent', () => {
it('renders the server-derived countdown while travel is active without enabling another start', () => { it('renders the server-derived countdown while travel is active without enabling another start', () => {
store.currentTravel.set({ store.currentTravel.set({
status: 'TRAVELLING', status: 'TRAVELLING',
originLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' }, originLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Graufurt South Gate' },
targetLocation: burnedRoadConnection.targetLocation, targetLocation: burnedRoadConnection.targetLocation,
startedAt: '2026-08-19T06:00:00.000Z', startedAt: '2026-08-19T06:00:00.000Z',
arrivesAt: '2026-08-19T06:00:06.000Z', arrivesAt: '2026-08-19T06:00:06.000Z',
@@ -140,7 +140,7 @@ describe('WorldPageComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Destination: Verbrannte Straße'); expect(element.textContent).toContain('Destination: Burned Road');
expect(element.textContent).toContain('Arrival in'); expect(element.textContent).toContain('Arrival in');
expect(element.textContent).toContain('00:00:06'); expect(element.textContent).toContain('00:00:06');
expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true); expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true);
@@ -194,7 +194,7 @@ describe('WorldPageComponent', () => {
store.arrived.set({ store.arrived.set({
id: 'burned-road-id', id: 'burned-road-id',
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Straße', name: 'Burned Road',
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -207,7 +207,7 @@ describe('WorldPageComponent', () => {
it('stays on the map while a journey is still running', () => { it('stays on the map while a journey is still running', () => {
store.currentTravel.set({ store.currentTravel.set({
status: 'TRAVELLING', status: 'TRAVELLING',
originLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' }, originLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Graufurt South Gate' },
targetLocation: burnedRoadConnection.targetLocation, targetLocation: burnedRoadConnection.targetLocation,
startedAt: '2026-08-20T10:00:00.000Z', startedAt: '2026-08-20T10:00:00.000Z',
arrivesAt: '2026-08-20T10:00:10.000Z', arrivesAt: '2026-08-20T10:00:10.000Z',

View File

@@ -21,12 +21,12 @@ const character: CharacterResponse = {
attack: 6, attack: 6,
hpRegenPerSecond: 1, hpRegenPerSecond: 1,
hpRegenSince: null, hpRegenSince: null,
currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, currentLocation: { id: 'origin-id', key: 'south-gate', name: 'South Gate' },
}; };
const currentLocation = southGateFixture({ const currentLocation = southGateFixture({
id: 'origin-id', id: 'origin-id',
name: 'Südtor', name: 'South Gate',
description: 'Der Ausgang zur Wildnis.', description: 'Der Ausgang zur Wildnis.',
dangerLevel: 1, dangerLevel: 1,
connections: [ connections: [
@@ -34,7 +34,7 @@ const currentLocation = southGateFixture({
targetLocation: { targetLocation: {
id: 'target-id', id: 'target-id',
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Straße', name: 'Burned Road',
}, },
travelDurationSeconds: 10, travelDurationSeconds: 10,
danger: 'LOW', danger: 'LOW',

View File

@@ -3,7 +3,7 @@
<div class="app-shell__content" [class.app-shell__content--no-context]="!showContextPanel()"> <div class="app-shell__content" [class.app-shell__content--no-context]="!showContextPanel()">
<app-side-navigation /> <app-side-navigation />
<main class="app-shell__main" aria-label="Spielinhalt"> <main class="app-shell__main" aria-label="Game content">
<router-outlet /> <router-outlet />
</main> </main>
@if (showContextPanel()) { @if (showContextPanel()) {

View File

@@ -6,7 +6,7 @@ import { ContextPanelComponent } from './context-panel.component';
const southGate = { const southGate = {
id: 'south-gate-id', id: 'south-gate-id',
key: 'south-gate', key: 'south-gate',
name: 'Südtor von Graufurt', name: 'Graufurt South Gate',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.', description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields', regionKey: 'ashen-fields',
minRecommendedLevel: 1, minRecommendedLevel: 1,
@@ -23,11 +23,11 @@ const burnedRoad = {
...southGate, ...southGate,
id: 'burned-road-id', id: 'burned-road-id',
key: 'burned-road', key: 'burned-road',
name: 'Verbrannte Straße', name: 'Burned Road',
isSafe: false, isSafe: false,
huntingEnabled: true, huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png', artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'], possibleMonsters: ['Ash Rat', 'Road Bandit'],
}; };
describe('ContextPanelComponent', () => { describe('ContextPanelComponent', () => {
@@ -97,7 +97,7 @@ describe('ContextPanelComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Possible Encounters'); expect(element.textContent).toContain('Possible Encounters');
expect(element.textContent).toContain('Aschenratte, Straßenräuber'); expect(element.textContent).toContain('Ash Rat, Road Bandit');
expect(element.textContent).not.toMatch(/\d+\s?%/); expect(element.textContent).not.toMatch(/\d+\s?%/);
}); });
}); });

View File

@@ -4,7 +4,7 @@ import { ItemCardComponent } from './item-card.component';
const banditBlade: RewardItemSummary = { const banditBlade: RewardItemSummary = {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Räuberklinge', name: 'Bandit Blade',
rarity: 'COMMON', rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
}; };
@@ -27,8 +27,8 @@ describe('ItemCardComponent', () => {
const icon = element.querySelector<HTMLImageElement>('[data-item-icon]'); const icon = element.querySelector<HTMLImageElement>('[data-item-icon]');
expect(icon?.getAttribute('src')).toBe('/images/items/bandit-blade.png'); expect(icon?.getAttribute('src')).toBe('/images/items/bandit-blade.png');
expect(icon?.getAttribute('alt')).toBe('Räuberklinge'); expect(icon?.getAttribute('alt')).toBe('Bandit Blade');
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge'); expect(element.querySelector('[data-item-name]')?.textContent).toContain('Bandit Blade');
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Common'); expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Common');
}); });