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);
});
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([
{
slot: EquipmentSlot.WEAPON,

View File

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

View File

@@ -209,7 +209,7 @@ function monster(
return {
id: MONSTER_ID,
key: 'road-bandit',
name: 'Straßenräuber',
name: 'Road Bandit',
level: 2,
maxHp: 75,
attack: 9,
@@ -301,7 +301,7 @@ function createHarness() {
itemDefinition({
id: BANDIT_BLADE_DEFINITION_ID,
key: 'bandit-blade',
name: 'Räuberklinge',
name: 'Bandit Blade',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
@@ -360,7 +360,7 @@ function createHarness() {
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 () => {
const { state, combatService, equipmentService } = createHarness();

View File

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

View File

@@ -250,7 +250,7 @@ describe('seedVisibleVerticalSlice', () => {
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 dataSource = createDataSource(
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 dataSource = createDataSource(
locationRepository,
@@ -347,7 +347,7 @@ describe('seedVisibleVerticalSlice', () => {
locationRepository.rows.push({
id: persistedSouthGateId,
key: 'south-gate',
name: 'Veraltetes Südtor',
name: 'Outdated South Gate',
});
const dataSource = createDataSource(
locationRepository,

View File

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

View File

@@ -6,7 +6,7 @@ const CHARACTER = { attack: 6, armor: 0, hp: 100 };
const CHARACTER_POWER = 44;
describe('calculateDangerRating', () => {
it('rates the seeded Aschenratte as MATCH', () => {
it('rates the seeded Ash Rat as MATCH', () => {
// Aschenratte: attack 5, armor 0, maxHp 45
// 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
@@ -15,7 +15,7 @@ describe('calculateDangerRating', () => {
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
// 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

View File

@@ -37,7 +37,7 @@ describe('HuntingController', () => {
it('delegates to huntingService.startHunt with the demo character id and returns its result', async () => {
const huntResult = {
id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
location: { id: 'loc-1', key: 'burned-road', name: 'Burned Road' },
encounters: [],
};
startHunt.mockResolvedValue(huntResult);
@@ -53,13 +53,13 @@ describe('HuntingController', () => {
it('serves the resumable hunt with its encounter statuses', async () => {
const huntResult = {
id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
location: { id: 'loc-1', key: 'burned-road', name: 'Burned Road' },
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
name: 'Ash Rat',
level: 1,
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 HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002';
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Aschenratte
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Strassenraeuber
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Ash Rat
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Road Bandit
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
@@ -191,7 +191,7 @@ function huntingLocation(): LocationDefinition {
return {
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
name: 'Burned Road',
description: 'A burned road.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
@@ -367,12 +367,12 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
'Ash Rat',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
'Road Bandit',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -394,7 +394,7 @@ describe('HuntingService', () => {
expect(result.location).toEqual({
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
name: 'Burned Road',
});
expect(dataSource.state.hunts).toHaveLength(1);
expect(dataSource.state.hunts[0]).toMatchObject({
@@ -447,12 +447,12 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
'Ash Rat',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
'Road Bandit',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -488,7 +488,7 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
'Ash Rat',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -524,12 +524,12 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
'Ash Rat',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
'Road Bandit',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -559,7 +559,7 @@ describe('HuntingService', () => {
const weakMonster = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
'Ash Rat',
{
attack: 1,
armor: 0,
@@ -595,7 +595,7 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
'Ash Rat',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -637,7 +637,7 @@ describe('HuntingService', () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
'Ash Rat',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
@@ -696,7 +696,7 @@ describe('HuntingService', () => {
expect(result?.location).toEqual({
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
name: 'Burned Road',
});
expect(result?.encounters.map((encounter) => encounter.status)).toEqual([
HuntEncounterStatus.AVAILABLE,

View File

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

View File

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

View File

@@ -158,7 +158,7 @@ function createState(overrides: Partial<State> = {}): State {
{
id: BANDIT_BLADE,
key: 'bandit-blade',
name: 'Räuberklinge',
name: 'Bandit Blade',
type: ItemType.EQUIPMENT,
rarity: ItemRarity.COMMON,
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 () => {
const state = createState();
@@ -266,7 +266,7 @@ describe('CombatRewardService', () => {
});
});
describe('Straßenräuber', () => {
describe('Road Bandit', () => {
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
it('grants a silver roll inside 9-15', async () => {
@@ -281,7 +281,7 @@ describe('CombatRewardService', () => {
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 reward = await service(
@@ -301,7 +301,7 @@ describe('CombatRewardService', () => {
characterItemId: state.characterItems[0].id,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
name: 'Bandit Blade',
rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png',
},
@@ -311,7 +311,7 @@ describe('CombatRewardService', () => {
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 reward = await service(state, fakeLoot()).grantVictoryRewards(
@@ -450,7 +450,7 @@ describe('CombatRewardService', () => {
{
id: BANDIT_BLADE,
key: 'bandit-blade',
name: 'Räuberklinge',
name: 'Bandit Blade',
type: ItemType.EQUIPMENT,
rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png',
@@ -458,7 +458,7 @@ describe('CombatRewardService', () => {
{
id: BANDIT_HOOD,
key: 'bandit-hood',
name: 'Räuberkapuze',
name: 'Bandit Hood',
type: ItemType.EQUIPMENT,
rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-hood.png',

View File

@@ -167,12 +167,12 @@ function createState(): FakeState {
const southGate = location(
SOUTH_GATE_ID,
'south-gate',
'S\u00fcdtor von Graufurt',
'Graufurt South Gate',
);
const burnedRoad = location(
BURNED_ROAD_ID,
'burned-road',
'Verbrannte Stra\u00dfe',
'Burned Road',
);
const character: Character = {
id: CHARACTER_ID,
@@ -237,12 +237,12 @@ describe('TravelService', () => {
originLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
name: 'Graufurt South Gate',
},
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
name: 'Burned Road',
},
startedAt: new Date('2026-08-18T10:00:00.000Z'),
arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
@@ -319,12 +319,12 @@ describe('TravelService', () => {
originLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
name: 'Graufurt South Gate',
},
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
name: 'Burned Road',
},
startedAt: new Date('2026-08-18T09:59:50.000Z'),
arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
@@ -341,12 +341,12 @@ describe('TravelService', () => {
originLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
name: 'Graufurt South Gate',
},
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
name: 'Burned Road',
},
startedAt: new Date('2026-08-18T09:59:50.000Z'),
arrivesAt: new Date('2026-08-18T10:00:00.001Z'),
@@ -367,7 +367,7 @@ describe('TravelService', () => {
targetLocation: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
name: 'Burned Road',
},
});
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[] = [
{
key: 'hunt-area',
title: 'Jagdgebiet',
actionLabel: 'Jagd beginnen',
title: 'Hunting Ground',
actionLabel: 'Begin Hunt',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 52,
@@ -27,42 +27,42 @@ const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
},
{
key: 'inspect-tracks',
title: 'Verdächtige Spuren',
actionLabel: 'Untersuchen',
title: 'Suspicious Tracks',
actionLabel: 'Investigate',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 32,
yPercent: 78,
enabled: true,
resultTitle: 'Verdächtige Spuren',
resultTitle: 'Suspicious Tracks',
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',
title: 'Versiegelte Krypta',
title: 'Sealed Crypt',
type: 'DUNGEON',
iconKey: 'search',
xPercent: 90,
yPercent: 20,
enabled: false,
resultTitle: 'Versiegelte Krypta',
resultText: 'Noch verschlossen.',
resultTitle: 'Sealed Crypt',
resultText: 'Still sealed.',
},
];
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
{
key: 'gate-watch',
title: 'Torwache',
actionLabel: 'Sprechen',
title: 'Gate Watch',
actionLabel: 'Talk',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
yPercent: 52,
enabled: true,
resultTitle: 'Torwache',
resultText: 'Nur am Südtor zu hören.',
resultTitle: 'Gate Watch',
resultText: 'Only heard at the South Gate.',
},
];
@@ -109,8 +109,8 @@ describe('WorldService.runLocalInteraction', () => {
service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
).resolves.toEqual({
interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren',
text: 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.',
title: 'Suspicious Tracks',
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', () => {
const runLocalInteraction = jest.fn().mockResolvedValue({
interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren',
text: 'Frische Stiefelabdrücke.',
title: 'Suspicious Tracks',
text: 'Fresh bootprints.',
});
const controller = new WorldController({
runLocalInteraction,

View File

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

View File

@@ -54,7 +54,7 @@ describe('GameApiService', () => {
const request = http.expectOne('/api/world/current-location/interactions/inspect-tracks');
expect(request.request.method).toBe('POST');
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', () => {

View File

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

View File

@@ -14,7 +14,7 @@ const activeCombat: Combat = {
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
name: 'Ash Rat',
level: 1,
maxHp: 45,
currentHp: 31,
@@ -28,7 +28,7 @@ const activeCombat: Combat = {
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 {
return haystack ? haystack.split(needle).length - 1 : 0;
@@ -97,7 +97,7 @@ describe('CombatPageComponent', () => {
expect(element.textContent).toContain('Aric Duskwalker');
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.querySelector('[data-combat-round]')?.textContent).toContain('Round 2');
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
@@ -169,7 +169,7 @@ describe('CombatPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
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 braces to defend.');
expect(element.textContent).toContain('Aschenratte is winding up a Heavy Strike.');
expect(element.textContent).toContain("Aric Duskwalker interrupts Aschenratte's attack.");
expect(element.textContent).toContain('Ash Rat is winding up a Heavy Strike.');
expect(element.textContent).toContain("Aric Duskwalker interrupts Ash Rat's attack.");
});
it('renders the structured events as readable English combat-log entries', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker hits Aschenratte for 14 damage.');
expect(element.textContent).toContain('Aschenratte hits Aric Duskwalker for 5 damage.');
expect(element.textContent).toContain('Aric Duskwalker hits Ash Rat for 14 damage.');
expect(element.textContent).toContain('Ash Rat hits Aric Duskwalker for 5 damage.');
});
it('calls combatStore.performAction("ATTACK") when Attack is clicked', async () => {
@@ -334,7 +334,7 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(540);
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(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 () => {
const fixture = await setup({
...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: {
silver: 12,
items: [
@@ -565,7 +565,7 @@ describe('CombatPageComponent', () => {
characterItemId: 'character-item-1',
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
name: 'Bandit Blade',
rarity: 'COMMON',
iconPath: '/images/items/bandit-blade.png',
},
@@ -579,7 +579,7 @@ describe('CombatPageComponent', () => {
expect(element.querySelector<HTMLImageElement>('[data-item-icon]')?.getAttribute('src')).toBe(
'/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-reward-empty]')).toBeNull();
});

View File

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

View File

@@ -7,7 +7,7 @@ const dawnwolfEncounter: HuntEncounter = {
id: 'encounter-id-9f2c',
monster: {
key: 'dawnwolf',
name: 'Dämmerwolf',
name: 'Dawnwolf',
level: 3,
artworkPath: '/images/enemies/Dawnwolf.png',
},
@@ -19,7 +19,7 @@ const ashRatEncounter: HuntEncounter = {
id: 'encounter-id-4b71',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
name: 'Ash Rat',
level: 1,
artworkPath: '/images/monsters/ash-rat.png',
},
@@ -45,11 +45,11 @@ describe('EncounterCardComponent', () => {
const element = render(dawnwolfEncounter);
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('Suitable');
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', () => {

View File

@@ -19,11 +19,11 @@ const burnedRoad = burnedRoadFixture({ connections: [] });
const threeEncounterHunt: HuntResult = {
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: [
{
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',
status: 'AVAILABLE',
},
@@ -31,7 +31,7 @@ const threeEncounterHunt: HuntResult = {
id: 'encounter-2',
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
name: 'Road Bandit',
level: 3,
artworkPath: '/images/enemies/RoadBandit.png',
},
@@ -40,7 +40,7 @@ const threeEncounterHunt: HuntResult = {
},
{
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',
status: 'AVAILABLE',
},
@@ -54,7 +54,7 @@ const startedCombat: Combat = {
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
name: 'Road Bandit',
level: 3,
maxHp: 75,
currentHp: 75,
@@ -130,7 +130,7 @@ describe('HuntPageComponent', () => {
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 element = fixture.nativeElement as HTMLElement;
@@ -164,7 +164,7 @@ describe('HuntPageComponent', () => {
const cards = element.querySelectorAll('app-encounter-card');
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(
element.querySelectorAll(
'.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 = {
id: 'hunt-id',
location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
location: { id: 'origin-id', key: 'south-gate', name: 'South Gate' },
encounters: [
{
id: 'encounter-1',
@@ -18,7 +18,7 @@ const huntResult: HuntResult = {
},
{
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',
status: 'DEFEATED',
},
@@ -27,11 +27,11 @@ const huntResult: HuntResult = {
const refreshedHuntResult: HuntResult = {
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: [
{
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',
status: 'AVAILABLE',
},

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ const inventory: InventoryResponse = {
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
description: 'Item description.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 8,
@@ -32,8 +32,8 @@ const inventory: InventoryResponse = {
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
name: 'Bandit Blade',
description: 'Item description.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
weaponDamage: 11,
@@ -61,7 +61,7 @@ const equipment: EquipmentResponse = {
const equippedAfter: EquipmentResponse = {
...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 },
};

View File

@@ -8,8 +8,8 @@ import { WorldStore } from './world.store';
const trackResult = {
interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren',
text: 'Frische Stiefelabdrücke führen nach Osten.',
title: 'Suspicious Tracks',
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> = {}) {
@@ -128,7 +128,7 @@ describe('LocalLocationStore', () => {
expect(
store.interactionKeyOf({
key: 'investigate-tracks',
label: 'Spuren untersuchen',
label: 'Investigate tracks',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,

View File

@@ -37,25 +37,27 @@ describe('LocationInteractionPanelComponent', () => {
const { element } = await setup({
result: {
interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren',
text: 'Frische Stiefelabdrücke führen nach Osten.',
title: 'Suspicious Tracks',
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('Frische Stiefelabdrücke führen nach Osten.');
expect(element.textContent).toContain('Suspicious Tracks');
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 () => {
const { element } = await setup({
result: {
interactionKey: 'wounded-scout',
title: 'Verwundeter Kundschafter',
text: '„Die Straße ist nicht mehr sicher."',
title: 'Wounded Scout',
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.
expect(element.querySelectorAll('button')).toHaveLength(1);
});

View File

@@ -62,11 +62,11 @@ describe('LocationPageComponent', () => {
it('names the place and its region straight away', async () => {
const { element } = await setup();
expect(element.querySelector('h1')?.textContent).toContain('Verbrannte Straße');
expect(element.textContent).toContain('Gebiet 1');
expect(element.textContent).toContain('Aschenfelder');
expect(element.querySelector('h1')?.textContent).toContain('Burned Road');
expect(element.textContent).toContain('Tier 1');
expect(element.textContent).toContain('Ashen Fields');
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;
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 () => {
@@ -96,10 +96,10 @@ describe('LocationPageComponent', () => {
const actions = element.querySelectorAll('[data-action]');
expect([...actions].map((action) => action.querySelector('.location-action__label')?.textContent?.trim())).toEqual([
'Jagd beginnen',
'Spuren untersuchen',
'Umgebung durchsuchen',
'Zur Karte',
'Begin Hunt',
'Investigate tracks',
'Search surroundings',
'To Map',
]);
});
@@ -173,13 +173,13 @@ describe('LocationPageComponent', () => {
interactionResult.set({
interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren',
text: 'Frische Stiefelabdrücke führen nach Osten.',
title: 'Suspicious Tracks',
text: 'Among the ash and broken stones you make out several fresh bootprints. They lead east, toward the abandoned watchpost.',
});
fixture.detectChanges();
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.
expect(element.querySelector('img')).not.toBeNull();
@@ -234,7 +234,7 @@ describe('LocationPageComponent', () => {
it('renders a second location from its own data alone', async () => {
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('[data-action]')).toHaveLength(1);
});
@@ -245,8 +245,8 @@ function southGateContent(): Partial<CurrentLocationResponse> {
pointsOfInterest: [
{
key: 'gate-watch',
title: 'Torwache',
actionLabel: 'Sprechen',
title: 'Gate Watch',
actionLabel: 'Talk',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
@@ -257,8 +257,8 @@ function southGateContent(): Partial<CurrentLocationResponse> {
primaryActions: [
{
key: 'talk-to-watch',
label: 'Wache ansprechen',
description: 'Lage erfragen',
label: 'Talk to the watch',
description: 'Ask about the situation',
type: 'NPC',
iconKey: 'speak',
enabled: true,

View File

@@ -4,8 +4,8 @@ import { LocationPoiComponent } from './location-poi.component';
const wagon: LocationPointOfInterest = {
key: 'search-abandoned-wagon',
title: 'Verlassener Wagen',
actionLabel: 'Durchsuchen',
title: 'Abandoned Wagon',
actionLabel: 'Search',
type: 'SEARCH',
iconKey: 'search',
xPercent: 80,
@@ -53,8 +53,8 @@ describe('LocationPoiComponent', () => {
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('Verlassener Wagen');
expect(text).toContain('Durchsuchen');
expect(text).toContain('Abandoned Wagon');
expect(text).toContain('Search');
});
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 () => {
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 () => {
@@ -111,6 +111,6 @@ describe('LocationPoiComponent', () => {
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 text = element.textContent ?? '';
expect(text).toContain('Aschenfelder');
expect(text).toContain('Verbrannte Straße');
expect(text).toContain('Ashen Fields');
expect(text).toContain('Burned Road');
expect(text).toContain('Hunting Ground');
expect(text).toContain('12');
});
@@ -44,8 +44,8 @@ describe('LocationSidebarComponent', () => {
const encounters = element.querySelectorAll('[data-sidebar="encounters"] li');
expect([...encounters].map((item) => item.textContent?.trim())).toEqual([
'Aschenratte',
'Straßenräuber',
'Ash Rat',
'Road Bandit',
]);
expect(element.textContent).toContain('the hunt rolls independently');
});
@@ -55,10 +55,10 @@ describe('LocationSidebarComponent', () => {
const interactions = element.querySelectorAll('[data-sidebar="interactions"] li');
expect([...interactions].map((item) => item.textContent?.trim())).toEqual([
'Jagd beginnen',
'Untersuchen',
'Durchsuchen',
'Sprechen',
'Begin Hunt',
'Investigate',
'Search',
'Talk',
]);
});
@@ -72,7 +72,7 @@ describe('LocationSidebarComponent', () => {
});
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');
expect([...rewards].map((item) => item.textContent?.trim())).toEqual([
'Ausrüstung',
'Equipment',
'Material',
]);
});

View File

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

View File

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

View File

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

View File

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

View File

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