Compare commits

...

74 Commits

Author SHA1 Message Date
Bastian Wagner
221819880c arts und agents 2026-08-21 17:32:05 +02:00
Bastian Wagner
ab6227881e feat(web): bind the HUD health bar to the locally-ticking HP value
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:12:55 +02:00
Bastian Wagner
848f9d9195 feat(web): count displayed HP up locally between server syncs 2026-08-21 16:06:54 +02:00
Bastian Wagner
c27b6c5026 feat(web): model HP regen fields and map CHARACTER_TOO_WOUNDED
- Add hpRegenPerSecond and hpRegenSince fields to CharacterResponse
- Update inventory-page.component.spec.ts fixture with new fields
- Add CHARACTER_TOO_WOUNDED error mapping to combat.store
- Add test for CHARACTER_TOO_WOUNDED error mapping
- Update app.spec.ts and world.store.spec.ts fixtures for compilation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:59:32 +02:00
Bastian Wagner
caf63b34a4 chore(api): anchor the seeded demo character's HP regeneration
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:53:20 +02:00
Bastian Wagner
cfe1866956 test(api): update combat/equipment integration harness for HP vitals wiring 2026-08-21 15:49:35 +02:00
Bastian Wagner
52178ba79d test(api): make equip() re-anchor test exercise the overflow scenario
The re-anchor test in equipment.service.spec.ts anchored hpRegenSince
at null, which meant currentHp already equaled the old maxHp and
nothing in equip() could change it whether settle() ran correctly, was
a no-op, or read the wrong (post-change) maxHp. Anchor currentHp at 90
with hpRegenSince 600s before the harness's fixed clock so settle()
must actually cap accumulated regen at the OLD maxHp for the assertion
to hold, catching both failure modes the original fixture missed.
2026-08-21 15:46:28 +02:00
Bastian Wagner
dd204db4b3 feat(api): re-anchor HP regeneration before an equipment-driven max-HP change 2026-08-21 15:40:27 +02:00
Bastian Wagner
b070bf2b0d feat(api): mirror HP onto the character each round and resume regen at combat end 2026-08-21 15:08:04 +02:00
Bastian Wagner
ac1329be46 feat(api): carry HP into combat and gate starting a fight on it 2026-08-21 14:56:05 +02:00
Bastian Wagner
432483e958 feat(api): add CHARACTER_TOO_WOUNDED combat error 2026-08-21 14:48:05 +02:00
Bastian Wagner
909f793eb6 feat(api): expose effective HP and regen anchor from GET /characters/me
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:45:31 +02:00
Bastian Wagner
02037b2917 feat(api): CharacterStatsService reports effective (regenerated) HP
- Update CharacterStatsService constructor to accept CharacterVitalsService
- Compute currentHp via CharacterVitalsService.effectiveHp() instead of raw pass-through
- Add hpRegenPerSecond and hpRegenSince fields to EffectiveCharacterStats return type
- Update spec with new test cases for regeneration calculation and field pass-through
- Equipment and combat tests now fail as expected (separate tasks will fix constructor calls)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:41:17 +02:00
Bastian Wagner
47494abf50 feat(api): provide CharacterVitalsService from CharactersModule 2026-08-21 14:36:22 +02:00
Bastian Wagner
71f58bc1b6 feat(api): add CharacterVitalsService for anchored HP regeneration
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:32:18 +02:00
Bastian Wagner
ea26e4b844 feat(api): add HP regeneration rate constant 2026-08-21 14:29:35 +02:00
Bastian Wagner
19e08f0b16 feat(api): add hp_regen_since column for persistent HP regeneration
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 14:26:44 +02:00
Bastian Wagner
b55518b451 refactor(api): move Clock abstraction from travel to shared 2026-08-21 14:21:39 +02:00
Bastian Wagner
2579b23a5c Add implementation plan for persistent HP and out-of-combat regeneration
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 14:12:38 +02:00
Bastian Wagner
a75a670509 Add design doc for persistent character HP and out-of-combat regeneration
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 13:56:43 +02:00
Bastian Wagner
dd90e05446 optik 2026-08-21 13:39:52 +02:00
Bastian Wagner
987242541d inventory 2026-08-21 12:52:44 +02:00
Bastian Wagner
a376fb7128 Add implementation plan for Playable Slice 0.6.5 (Renown & Reputation)
16 tasks: pure-function power curve and rank resolver, one migration
covering Character/ItemDefinition/ItemType/CombatReward changes plus
five new tables, three new domain services (Renown, Reputation,
TurnIn), XP removal from the combat reward pipeline, requiredLevel
gate removal, seed content for Grenzwacht/Räuberabzeichen/turn-ins,
a fixture sweep, and the web-side Renown/Reputation surfaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:34:22 +02:00
Bastian Wagner
65dfb466cd Add design doc for Playable Slice 0.6.5 (Renown & Reputation Foundation)
Records every judgment call the spec left open — Renown/Reputation
data model, service signatures, migration strategy, combat reward
pipeline changes, and API/frontend surface — as an explicit, auditable
ruling with its cost if wrong, per instruction to implement this slice
without confirmation stops.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:21:14 +02:00
Bastian Wagner
62d6677298 fix(combat): show the potion heal immediately instead of folding it into the monster's reply 2026-08-20 23:54:02 +02:00
Bastian Wagner
1aac3416fa fix(combat): clear the monster's pending action once it is defeated 2026-08-20 23:10:47 +02:00
Bastian Wagner
e2f29d5eb6 fix(combat): keep the busy() re-entrancy guard, split the action-mapping test instead
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 22:38:52 +02:00
Bastian Wagner
0126d1dea1 feat(combat): add the five-action bar, telegraph banner, and generalized round animation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 22:19:13 +02:00
Bastian Wagner
b8d00278b8 feat(combat): generalize the web combat action and expose potions/monster intent 2026-08-20 22:01:28 +02:00
Bastian Wagner
c7b9601eb4 feat(combat): validate potions, persist telegraph state, and expose both on the combat DTO
Also updates the pre-existing exact-equality playerState assertion in
combat-equipment-integration.spec.ts, which broke from the new
potionsRemaining field but wasn't listed in the task brief's file scope.
2026-08-20 21:48:51 +02:00
Bastian Wagner
43d2085d2c feat(combat): add the COMBAT_NO_POTIONS_REMAINING domain error 2026-08-20 21:40:56 +02:00
Bastian Wagner
6eb0f21360 test(combat): cover DEFEND mitigating a resolving Heavy Attack 2026-08-20 21:37:36 +02:00
Bastian Wagner
b3dac8f61c feat(combat): implement HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION, and monster telegraphing 2026-08-20 21:27:12 +02:00
Bastian Wagner
fc1873e41f feat(combat): model monster intent and potion count in combat state types 2026-08-20 21:21:08 +02:00
Bastian Wagner
922c12f20f feat(combat): migrate combat_event_type_enum for Slice 0.6 event types 2026-08-20 21:17:37 +02:00
Bastian Wagner
6f1afaf5a3 feat(combat): add the Slice 0.6 action and event-type enum members 2026-08-20 21:12:46 +02:00
Bastian Wagner
eed247d318 feat(combat): support a damage multiplier in calculateDamage 2026-08-20 21:06:54 +02:00
Bastian Wagner
96d04d8ba3 Fix plan defect found in SDD pre-flight scan: Task 8 fixture patch
hunt-page.component.spec.ts and resume-combat.spec.ts each build a
direct type-annotated Combat literal that the new required
CombatPlayer/CombatMonster fields would break at compile time.
Verified with an isolated tsc --strict probe, and used the same probe
to confirm the combat.service.spec.ts `as Combat` fixtures do NOT
break (structural widening through the type assertion), so no change
needed there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:04:19 +02:00
Bastian Wagner
67df86a818 Ignore .worktrees/ for isolated feature-branch workspaces
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:52:36 +02:00
Bastian Wagner
9d396b7d96 Add implementation plan for Playable Slice 0.6 (Full First Combat)
Covers the engine rewrite for HEAVY_STRIKE/SHIELD_BASH/DEFEND/POTION,
deterministic monster telegraphing/interrupt, the DB migration for the
new combat event types, and the web action bar + telegraph banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:51:17 +02:00
Bastian Wagner
49a0008a2b Merge branch 'worktree-playable-slice-0.5-first-upgrade'
# Conflicts:
#	apps/web/src/app/core/api/game-api.service.ts
#	apps/web/src/app/features/combat/combat-page/combat-page.component.html
#	apps/web/src/app/features/combat/combat-page/combat-page.component.scss
#	apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
#	apps/web/src/app/features/combat/combat-page/combat-page.component.ts
2026-08-20 19:42:10 +02:00
Bastian Wagner
0ce3b420e6 fix: address final-review findings (TopBar hydration, snapshot test, armor doc, inventory refresh test)
Fixes 4 Important findings from the final whole-branch review:
- /inventory never called WorldStore.load(), leaving the TopBar stuck on
  "loading" and characterLevel() silently defaulting to 1 for any character
  above level 1. Mirrors the same guard already used in HuntPageComponent.
- The combat/equipment snapshot-immutability integration test asserted only
  status/round, never the actual playerState snapshot the whole test claims
  to prove is untouched after a post-fight equip.
- Documented (comment only, no behavior change) that the demo character's
  armor dropping from the old hardcoded 6 to 0 is an intentional,
  spec-sanctioned tradeoff (Slice 0.5 spec Section19), not a bug.
- inventory.store.spec.ts's equip test used an identical inventory fixture
  before and after equip(), so a regression dropping the post-equip
  inventory re-fetch would still have passed. Now asserts the refetched
  fixture is actually reflected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:44:39 +02:00
Bastian Wagner
7f031ac1ce test(web): update app.spec.ts for the now-enabled Inventar nav button 2026-08-20 18:15:30 +02:00
Bastian Wagner
b1968da754 feat(web): add Inventar öffnen button to the victory screen 2026-08-20 18:04:13 +02:00
Bastian Wagner
724443de1e feat(web): route and enable the Inventar nav entry 2026-08-20 17:58:32 +02:00
Bastian Wagner
fdd8ae4e41 test(web): cover the same-slot equipped-item filter in InventoryPageComponent
The equippedItemInSelectedSlot computed is the riskiest logic this
component owns, but every existing test mocked selectedItem() as null,
so the slot-matching branch never ran and a regression to "any equipped
item" would have gone unnoticed. Adds a test with three items across two
slots that asserts the detail panel receives the same-slot equipped item,
not just any equipped item.
2026-08-20 17:54:38 +02:00
Bastian Wagner
9cb0158d80 import aufgeräumt 2026-08-20 17:52:04 +02:00
Bastian Wagner
1015505f38 feat(web): add inventory page with grid, detail panel, and equipment overview 2026-08-20 17:46:09 +02:00
Bastian Wagner
973d4e3ab4 feat(web): add inventory item detail/comparison panel 2026-08-20 17:39:46 +02:00
Bastian Wagner
10dd838465 feat(web): add InventoryStore 2026-08-20 17:32:52 +02:00
Bastian Wagner
c994a4c46f feat(web): add inventory/equipment API models and client methods 2026-08-20 17:27:15 +02:00
Bastian Wagner
90094ba1ae fix(api): key starting-sword seed idempotency on its real unique index
The CharacterItem idempotency check was looking up by the seed's own
literal id instead of the (characterId, itemDefinitionId) unique index
that CharacterItem actually enforces. If the demo character had already
looted a worn-short-sword naturally, re-running the seed would miss
that row and try to insert a colliding duplicate, breaking seeding
instead of being a safe no-op. Look up by the real domain key and reuse
whatever id is found when wiring up the CharacterEquipment row.
2026-08-20 17:23:45 +02:00
Bastian Wagner
6178b88573 feat(api): seed the starting sword as a real, equipped CharacterItem 2026-08-20 17:11:18 +02:00
Bastian Wagner
2526ac230d items 2026-08-20 17:07:40 +02:00
Bastian Wagner
2859a00597 test(api): prove equipping a weapon upgrade increases future combat damage 2026-08-20 17:01:45 +02:00
Bastian Wagner
820c69704f feat(api): add inventory API (GET /api/inventory) 2026-08-20 16:50:50 +02:00
Bastian Wagner
d1c7ffea86 Merge branch 'worktree-local-location-view' 2026-08-20 16:42:57 +02:00
Bastian Wagner
9df740e7d4 feat(api): add equipment API (GET/POST /api/equipment) 2026-08-20 16:39:23 +02:00
Bastian Wagner
3ee107694c address code review: POI order matches plan, self-documenting height reserve
Reorders the Verbrannte Straße POIs to plan §8's authored sequence
(hunt, investigate, search, then the scout) — purely a keyboard tab-order
fix, since hotspots are placed by percentage, not list order.

Rewrites the location page's viewport-height reserve as a calc() over the
same rem values the top bar and footer already declare as their own
min-block-size, with file:line pointers to both, instead of an opaque
191px constant. Doesn't remove the underlying coupling (still no
ResizeObserver / shared token), but a future edit to either component's
minimum height now has a documented, unit-matching term to update instead
of an unexplained magic number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:33:42 +02:00
Bastian Wagner
f45f4b03cb feat(api): combat snapshots now use CharacterStatsService 2026-08-20 16:32:15 +02:00
Bastian Wagner
e5746dec5c feat(api): retire CharacterCombatStatsService; characters/me returns effective stats 2026-08-20 16:26:33 +02:00
Bastian Wagner
67c29e783f feat(api): add CharacterStatsService as authoritative effective-stat source 2026-08-20 16:20:44 +02:00
Bastian Wagner
8a5f57fa1c specs 2026-08-20 16:17:19 +02:00
Bastian Wagner
8debb6f550 feat(api): add equipment domain errors 2026-08-20 16:16:44 +02:00
Bastian Wagner
59e88bcf9c feat(api): add character_equipment table and entity 2026-08-20 16:13:19 +02:00
Bastian Wagner
2cbb7cb235 docs: add Playable Slice 0.5 implementation plan 2026-08-20 16:08:51 +02:00
Bastian Wagner
c158260623 Merge branch 'master' into worktree-local-location-view
Brings in the encounter-status feature (cleared/resumed hunt encounters)
and its own independent Verwilderter Straßenhund / Verkohlter Plünderer
assets. Both branches added the same two monsters at the same time;
resolved by keeping master's asset set as canonical (images/combat/icons/,
images/combat/sprites/) rather than maintaining a parallel copy under
images/monsters/icons/ — dropped that directory and pointed the seed's
MonsterDefinition.iconPath, the local-view test fixtures and the frontend
icon lookup at the existing combat/icons paths instead. Kept this
branch's COMBAT_MONSTER_SCALE entries for the two monsters, since master
never added them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:03:59 +02:00
Bastian Wagner
60e1734f59 fix(web): keep the primary action bar on screen at every desktop width
The shell sizes itself with min-block-size everywhere, which is a floor,
not a ceiling. Against that indefinite ancestor, the location page's
minmax(0, 1fr) scene row fell back to content-based sizing instead of
being bounded, so the artwork could grow tall enough to push the action
bar off screen — confirmed visually at 1920 and 1024px widths, where the
bar was fully or partially clipped.

Gives the location page its own definite, viewport-bounded height
(reserve = stable top bar + footer + own padding) instead of touching the
shared shell, which other screens still size freely. The narrow/tablet
breakpoint had a second instance of the same class of bug: the sidebar's
auto-sized row claimed its full content height before the 1fr main row
saw any space at all, collapsing the action bar to 0px height. Swapping
which row is auto vs. 1fr — main first — fixes it the same way.

Also re-anchors the four Verbrannte Straße hotspots to painted detail in
the real artwork (cart, roadside grave, road, cracked stones) rather than
the composition-reference coordinates, and lets primary-action labels
stay on one line via clamp() instead of wrapping unevenly across widths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:50:31 +02:00
Bastian Wagner
ec91f3ac7b grafiken 2026-08-20 15:11:17 +02:00
Bastian Wagner
9b839623ce feat(web): route hunt, combat and arrival back to the location
A finished journey now opens the location view instead of leaving the
player on the map, and backing out of the hunt returns to the place the
hunt happens in. The victory and defeat screens gain "Zum Ort" alongside
"Weiter jagen", so the location is always reachable without costing the
hunt loop its one-click rhythm.

The store raises the arrival only after the server-owned current location
has been re-read, and does not navigate itself — timers, arrival times and
the server-side completion are untouched; only the screen that shows the
result changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:58:46 +02:00
Bastian Wagner
6f0020137b feat(web): add the local location view at /location
The screen a player stands on between activities: name, region, scene
artwork with hotspots pinned by percentage, a four-button action bar and
a context sidebar covering identity, danger, encounters, interactions and
rewards.

It owns no knowledge of any particular place. Hotspots and actions are
routed by interaction type: HUNT and MAP hand off to the existing hunt
and map screens, and everything that reveals text goes through the
server-authoritative interaction endpoint. A second location therefore
renders by supplying different content, which the Südtor case in the page
spec exercises.

The shell drops its generic area rail on /location, where the screen's
own sidebar says the same thing better, and Ort joins the navigation as
its first entry. Root and unknown routes now land on the location rather
than the map: arriving somewhere should mean arriving at a place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:51:08 +02:00
Bastian Wagner
c7e7e97252 Merge branch 'master' into worktree-local-location-view
Brings in the First Loot slice. Resolved additively:

- MonsterDefinition keeps both the new iconPath and master's lootTableId.
- The seed keeps the four-monster pool and the local view content, and
  gives the two new monsters existing loot tables — the road dog shares
  the beast table, the charred looter the raider table.
- The local location view migration moves to 1788700000000 so it orders
  deterministically after the loot migration, which claimed the same
  timestamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:38:18 +02:00
Bastian Wagner
2664495ef4 feat(web): add local location contracts, shared fixtures and content glyphs
Mirrors the extended current-location payload and the interaction endpoint
in the web API client. Replaces the per-spec CurrentLocationResponse
literals with one shared fixture factory, so a contract change is answered
in a single place. Adds the drawn glyph set used by hotspots, actions and
reward previews.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:34:41 +02:00
Bastian Wagner
c85a70484f feat(world): serve local location view content from the API
Adds the server side of the local location view: location_definitions
carries region naming, a location type, a scene-level description and
artwork, plus JSONB points of interest, primary actions and a reward
preview. Locations become content, so a second location renders through
the same components with different data.

GET /api/world/current-location gains those fields, a recommendation
label and a danger rating derived from the weighted average of the
location's own monster pool — a rare elite no longer makes a beginner
road read as lethal. The encounter preview is derived from that same
pool rather than duplicating it.

POST /api/world/current-location/interactions/:key reveals a hotspot's
authored result. The location is resolved from the character, never from
the request, and result text never ships with the location payload, so a
caller cannot read or trigger a hotspot it has not travelled to.

Seeds the Verbrannte Straße with its four hotspots and the Südtor with
its own transition content. Adds Verwilderter Straßenhund and Verkohlter
Plünderer to the road's pool, including combat sprites, so the preview
shows encounters the hunt can actually roll. Medallion icons move to
images/monsters/icons, where both combat and the location view read them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:30:01 +02:00
184 changed files with 31003 additions and 1861 deletions

2
.gitignore vendored
View File

@@ -13,3 +13,5 @@ coverage/
.vscode/ .vscode/
.DS_Store .DS_Store
.worktrees/

1175
AGENTS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module'; import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module'; import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { EquipmentModule } from './equipment/equipment.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module'; import { HuntingModule } from './hunting/hunting.module';
import { InventoryModule } from './inventory/inventory.module';
import { TravelModule } from './travel/travel.module'; import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module'; import { WorldModule } from './world/world.module';
@@ -16,6 +18,8 @@ import { WorldModule } from './world/world.module';
WorldModule, WorldModule,
HuntingModule, HuntingModule,
CombatModule, CombatModule,
EquipmentModule,
InventoryModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,16 +0,0 @@
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { Character } from './entities/character.entity';
describe('CharacterCombatStatsService', () => {
it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => {
const service = new CharacterCombatStatsService();
const character = { baseHp: 100, baseAttack: 6 } as Character;
expect(service.getStats(character)).toEqual({
maxHp: 100,
attack: 6,
weaponDamage: 8,
armor: 6,
});
});
});

View File

@@ -1,28 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Character } from './entities/character.entity';
export interface CharacterCombatStats {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants
// stand in for the starting weapon/armor until Slice 0.5 introduces real
// equipment. Replacing them there must not change this method's signature
// or the combat API it feeds (spec §10).
const TEMPORARY_WEAPON_DAMAGE = 8;
const TEMPORARY_ARMOR = 6;
@Injectable()
export class CharacterCombatStatsService {
getStats(character: Character): CharacterCombatStats {
return {
maxHp: character.baseHp,
attack: character.baseAttack,
weaponDamage: TEMPORARY_WEAPON_DAMAGE,
armor: TEMPORARY_ARMOR,
};
}
}

View File

@@ -0,0 +1,155 @@
// apps/api/src/characters/character-stats.service.spec.ts
import { DataSource } from 'typeorm';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterVitalsService } from './character-vitals.service';
type EquippedFixture = {
slot: EquipmentSlot;
item: Partial<ItemDefinition>;
};
function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> {
const rows = equipped.map((entry) => ({
slot: entry.slot,
characterItem: {
itemDefinition: {
weaponDamage: 0,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
...entry.item,
},
},
}));
return {
getRepository: () => ({ find: async () => rows }) as never,
} as unknown as Pick<DataSource, 'getRepository'>;
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: 'character-1',
baseHp: 100,
baseAttack: 6,
currentHp: 90,
hpRegenSince: null,
...overrides,
} as Character;
}
describe('CharacterStatsService', () => {
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-21T12:00:00.000Z'),
});
const service = new CharacterStatsService({} as DataSource, characterVitals);
it('derives stats from the starting weapon alone', async () => {
const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]);
const stats = await service.calculate(character(), scope);
expect(stats.attack).toBe(6);
expect(stats.weaponDamage).toBe(8);
expect(stats.maxHp).toBe(100);
expect(stats.armor).toBe(0);
});
it('applies Räuberklinge\'s weapon damage and bonus attack', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.attack).toBe(7);
expect(stats.weaponDamage).toBe(11);
});
it('sums bonusArmor across multiple equipped armor pieces', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
{ slot: EquipmentSlot.CHEST, item: { bonusArmor: 7 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.armor).toBe(10);
});
it('sums bonusHp across equipped items on top of base HP', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusHp: 5 } },
{ slot: EquipmentSlot.CHEST, item: { bonusHp: 10 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.maxHp).toBe(115);
});
it('reports weaponDamage as 0 when no weapon is equipped', async () => {
const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]);
const stats = await service.calculate(character(), scope);
expect(stats.weaponDamage).toBe(0);
});
it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } },
]);
const stats = await service.calculate(character(), scope);
// maxHp=105, attack=7, weaponDamage=11, armor=3
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
});
it('returns the raw current HP unchanged while regeneration is paused', async () => {
const scope = fakeScope([]);
const stats = await service.calculate(
character({ currentHp: 42, hpRegenSince: null }),
scope,
);
expect(stats.currentHp).toBe(42);
});
it('adds elapsed regeneration, clamped to maxHp, when a regen anchor is set', async () => {
const scope = fakeScope([]);
const regenerating = await service.calculate(
character({
currentHp: 40,
hpRegenSince: new Date('2026-08-21T11:59:30.000Z'),
}),
scope,
);
expect(regenerating.currentHp).toBe(70);
const clamped = await service.calculate(
character({
currentHp: 40,
hpRegenSince: new Date('2026-08-21T11:40:00.000Z'),
}),
scope,
);
expect(clamped.currentHp).toBe(100);
});
it('reports the regeneration rate and anchor alongside the effective stats', async () => {
const scope = fakeScope([]);
const anchor = new Date('2026-08-21T11:59:30.000Z');
const stats = await service.calculate(character({ hpRegenSince: anchor }), scope);
expect(stats.hpRegenPerSecond).toBe(1);
expect(stats.hpRegenSince).toEqual(anchor);
});
});

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
import { CharacterVitalsService } from './character-vitals.service';
import { Character } from './entities/character.entity';
export interface EffectiveCharacterStats {
maxHp: number;
currentHp: number;
attack: number;
weaponDamage: number;
armor: number;
combatPower: number;
hpRegenPerSecond: number;
hpRegenSince: Date | null;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
/**
* Single authoritative source of effective character stats (spec §18).
* Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut.
*/
@Injectable()
export class CharacterStatsService {
constructor(
private readonly dataSource: DataSource,
private readonly characterVitals: CharacterVitalsService,
) {}
async calculate(
character: Character,
scope?: RepositoryScope,
): Promise<EffectiveCharacterStats> {
const db = scope ?? this.dataSource;
const equipped = await db.getRepository(CharacterEquipment).find({
where: { characterId: character.id },
relations: { characterItem: { itemDefinition: true } },
});
let weaponDamage = 0;
let bonusHp = 0;
let bonusAttack = 0;
let bonusArmor = 0;
for (const slot of equipped) {
const definition = slot.characterItem.itemDefinition;
if (slot.slot === EquipmentSlot.WEAPON) {
weaponDamage = definition.weaponDamage;
}
bonusHp += definition.bonusHp;
bonusAttack += definition.bonusAttack;
bonusArmor += definition.bonusArmor;
}
const maxHp = character.baseHp + bonusHp;
const attack = character.baseAttack + bonusAttack;
const armor = bonusArmor;
return {
maxHp,
currentHp: this.characterVitals.effectiveHp(character, maxHp),
attack,
weaponDamage,
armor,
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
hpRegenPerSecond: HP_REGEN_PER_SECOND,
hpRegenSince: character.hpRegenSince,
};
}
}

View File

@@ -0,0 +1 @@
export const HP_REGEN_PER_SECOND = 1;

View File

@@ -0,0 +1,136 @@
import { Clock } from '../shared/clock';
import { CharacterVitalsService } from './character-vitals.service';
import { Character } from './entities/character.entity';
function fakeClock(initialIso: string): {
clock: Clock;
advanceSeconds: (seconds: number) => void;
} {
let current = Date.parse(initialIso);
return {
clock: { now: () => new Date(current) },
advanceSeconds: (seconds: number) => {
current += seconds * 1000;
},
};
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: 'character-1',
currentHp: 50,
hpRegenSince: null,
...overrides,
} as Character;
}
describe('CharacterVitalsService', () => {
describe('effectiveHp', () => {
it('returns the raw current HP when regeneration is paused', () => {
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const hp = service.effectiveHp(character({ currentHp: 37, hpRegenSince: null }), 100);
expect(hp).toBe(37);
});
it('adds one HP per elapsed second since the anchor', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 40, hpRegenSince: anchor });
advanceSeconds(25);
expect(service.effectiveHp(target, 100)).toBe(65);
});
it('floors partial seconds instead of rounding up', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 40, hpRegenSince: anchor });
advanceSeconds(1.9);
expect(service.effectiveHp(target, 100)).toBe(41);
});
it('clamps regeneration at maxHp', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 90, hpRegenSince: anchor });
advanceSeconds(50);
expect(service.effectiveHp(target, 100)).toBe(100);
});
it('never lets HP fall if the clock moves backwards', () => {
const clock: Clock = { now: () => new Date('2026-08-21T11:59:00.000Z') };
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 40, hpRegenSince: anchor });
expect(service.effectiveHp(target, 100)).toBe(40);
});
});
describe('pause', () => {
it('freezes current HP at the given value and clears the anchor', () => {
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const target = character({
currentHp: 100,
hpRegenSince: new Date('2026-08-21T11:00:00.000Z'),
});
service.pause(target, 62);
expect(target.currentHp).toBe(62);
expect(target.hpRegenSince).toBeNull();
});
});
describe('resume', () => {
it('sets current HP and anchors regeneration at now', () => {
const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const target = character({ currentHp: 0, hpRegenSince: null });
service.resume(target, 15);
expect(target.currentHp).toBe(15);
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:00.000Z'));
});
});
describe('settle', () => {
it('re-anchors at the current effective value without changing it', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 40, hpRegenSince: anchor });
advanceSeconds(10);
service.settle(target, 100);
expect(target.currentHp).toBe(50);
expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:10.000Z'));
});
it('does not gift overflow past the pre-change maxHp when re-anchoring', () => {
const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock);
const anchor = new Date('2026-08-21T12:00:00.000Z');
const target = character({ currentHp: 100, hpRegenSince: anchor });
advanceSeconds(600);
service.settle(target, 100);
expect(target.currentHp).toBe(100);
});
});
});

View File

@@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import { CLOCK } from '../shared/clock';
import type { Clock } from '../shared/clock';
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
import { Character } from './entities/character.entity';
/**
* The only place that turns (current_hp, hp_regen_since) into an effective
* HP value, or moves that pair. `current_hp` is exact only while the anchor
* is null; everything else must go through here (persistent-hp-and-
* regeneration design, R3).
*/
@Injectable()
export class CharacterVitalsService {
constructor(@Inject(CLOCK) private readonly clock: Clock) {}
effectiveHp(
character: Pick<Character, 'currentHp' | 'hpRegenSince'>,
maxHp: number,
): number {
if (character.hpRegenSince === null) {
return Math.min(maxHp, character.currentHp);
}
const elapsedSeconds = Math.max(
0,
(this.clock.now().getTime() - character.hpRegenSince.getTime()) / 1000,
);
const regenerated = Math.floor(elapsedSeconds * HP_REGEN_PER_SECOND);
return Math.min(maxHp, character.currentHp + regenerated);
}
pause(character: Character, value: number): void {
character.currentHp = value;
character.hpRegenSince = null;
}
resume(character: Character, value: number): void {
character.currentHp = value;
character.hpRegenSince = this.clock.now();
}
settle(character: Character, maxHp: number): void {
this.resume(character, this.effectiveHp(character, maxHp));
}
}

View File

@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterCombatStatsService } from './character-combat-stats.service'; import { CLOCK, systemClock } from '../shared/clock';
import { CharacterStatsService } from './character-stats.service';
import { CharacterVitalsService } from './character-vitals.service';
import { CharactersController } from './characters.controller'; import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service'; import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity'; import { Character } from './entities/character.entity';
@@ -8,7 +10,12 @@ import { Character } from './entities/character.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Character])], imports: [TypeOrmModule.forFeature([Character])],
controllers: [CharactersController], controllers: [CharactersController],
providers: [CharactersService, CharacterCombatStatsService], providers: [
exports: [CharacterCombatStatsService], CharactersService,
CharacterStatsService,
CharacterVitalsService,
{ provide: CLOCK, useValue: systemClock },
],
exports: [CharacterStatsService, CharacterVitalsService],
}) })
export class CharactersModule {} export class CharactersModule {}

View File

@@ -2,11 +2,29 @@ import { NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants'; import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity'; import { Character } from './entities/character.entity';
import { CharactersService } from './characters.service'; import { CharactersService } from './characters.service';
function fakeCharacterStats(
overrides: Partial<{ maxHp: number; attack: number }> = {},
): CharacterStatsService {
return {
calculate: jest.fn().mockResolvedValue({
maxHp: overrides.maxHp ?? 100,
currentHp: 100,
attack: overrides.attack ?? 6,
weaponDamage: 8,
armor: 0,
combatPower: 0,
hpRegenPerSecond: 1,
hpRegenSince: new Date('2026-08-18T09:00:00.000Z'),
}),
} as unknown as CharacterStatsService;
}
describe('CharactersService', () => { describe('CharactersService', () => {
it('returns the demo character with its current location summary', async () => { it('returns the demo character with effective attack/HP and its location summary', async () => {
const repository = { const repository = {
findOne: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue({
id: DEMO_CHARACTER_ID, id: DEMO_CHARACTER_ID,
@@ -20,11 +38,12 @@ describe('CharactersService', () => {
currentLocation: { currentLocation: {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'S\u00fcdtor von Graufurt', name: 'Südtor von Graufurt',
}, },
}), }),
} as unknown as Repository<Character>; } as unknown as Repository<Character>;
const service = new CharactersService(repository); const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 });
const service = new CharactersService(repository, characterStats);
await expect(service.getDemoCharacter()).resolves.toEqual({ await expect(service.getDemoCharacter()).resolves.toEqual({
id: DEMO_CHARACTER_ID, id: DEMO_CHARACTER_ID,
@@ -33,18 +52,19 @@ describe('CharactersService', () => {
experience: 0, experience: 0,
silver: 0, silver: 0,
currentHp: 100, currentHp: 100,
maxHp: 100, maxHp: 115,
attack: 6, attack: 7,
hpRegenPerSecond: 1,
hpRegenSince: '2026-08-18T09:00:00.000Z',
currentLocation: { currentLocation: {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'S\u00fcdtor von Graufurt', name: 'Südtor von Graufurt',
}, },
}); });
expect(repository.findOne).toHaveBeenCalledWith({ expect(characterStats.calculate).toHaveBeenCalledWith(
where: { id: DEMO_CHARACTER_ID }, expect.objectContaining({ id: DEMO_CHARACTER_ID }),
relations: { currentLocation: true }, );
});
}); });
it('exposes the persisted silver so the HUD never has to guess', async () => { it('exposes the persisted silver so the HUD never has to guess', async () => {
@@ -65,7 +85,7 @@ describe('CharactersService', () => {
}, },
}), }),
} as unknown as Repository<Character>; } as unknown as Repository<Character>;
const service = new CharactersService(repository); const service = new CharactersService(repository, fakeCharacterStats());
await expect(service.getDemoCharacter()).resolves.toEqual( await expect(service.getDemoCharacter()).resolves.toEqual(
expect.objectContaining({ experience: 24, silver: 18 }), expect.objectContaining({ experience: 24, silver: 18 }),
@@ -76,7 +96,7 @@ describe('CharactersService', () => {
const repository = { const repository = {
findOne: jest.fn().mockResolvedValue(null), findOne: jest.fn().mockResolvedValue(null),
} as unknown as Repository<Character>; } as unknown as Repository<Character>;
const service = new CharactersService(repository); const service = new CharactersService(repository, fakeCharacterStats());
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf( await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
NotFoundException, NotFoundException,

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity'; import { Character } from './entities/character.entity';
@Injectable() @Injectable()
@@ -9,6 +10,7 @@ export class CharactersService {
constructor( constructor(
@InjectRepository(Character) @InjectRepository(Character)
private readonly characters: Repository<Character>, private readonly characters: Repository<Character>,
private readonly characterStats: CharacterStatsService,
) {} ) {}
async getDemoCharacter() { async getDemoCharacter() {
@@ -21,15 +23,19 @@ export class CharactersService {
throw new NotFoundException('Demo character has not been seeded'); throw new NotFoundException('Demo character has not been seeded');
} }
const stats = await this.characterStats.calculate(character);
return { return {
id: character.id, id: character.id,
name: character.name, name: character.name,
level: character.level, level: character.level,
experience: character.experience, experience: character.experience,
silver: character.silver, silver: character.silver,
currentHp: character.currentHp, currentHp: stats.currentHp,
maxHp: character.baseHp, maxHp: stats.maxHp,
attack: character.baseAttack, attack: stats.attack,
hpRegenPerSecond: stats.hpRegenPerSecond,
hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null,
currentLocation: { currentLocation: {
id: character.currentLocation.id, id: character.currentLocation.id,
key: character.currentLocation.key, key: character.currentLocation.key,

View File

@@ -35,6 +35,12 @@ export class Character {
@Column({ name: 'current_hp', type: 'integer' }) @Column({ name: 'current_hp', type: 'integer' })
currentHp!: number; currentHp!: number;
// `current_hp` is only exact while this is null (regeneration paused, e.g.
// mid-combat). Otherwise it's the HP as of this timestamp -- read it
// through CharacterVitalsService.effectiveHp(), never directly.
@Column({ name: 'hp_regen_since', type: 'timestamptz', nullable: true })
hpRegenSince!: Date | null;
@Column({ name: 'current_location_id', type: 'uuid' }) @Column({ name: 'current_location_id', type: 'uuid' })
currentLocationId!: string; currentLocationId!: string;

View File

@@ -1,6 +1,7 @@
// Only ATTACK is implemented in Slice 0.3. Future slices add HEAVY_STRIKE,
// SHIELD_BASH, DEFEND, POTION, FLEE as real members with their own
// CombatEngineService cases — do not add them here until their behavior ships.
export enum CombatAction { export enum CombatAction {
ATTACK = 'ATTACK', ATTACK = 'ATTACK',
HEAVY_STRIKE = 'HEAVY_STRIKE',
SHIELD_BASH = 'SHIELD_BASH',
DEFEND = 'DEFEND',
POTION = 'POTION',
} }

View File

@@ -14,4 +14,13 @@ describe('calculateDamage', () => {
// raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8 // raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8
expect(calculateDamage({ attack: 9 }, 5)).toBe(8); expect(calculateDamage({ attack: 9 }, 5)).toBe(8);
}); });
it('applies a damage multiplier before the minimum-1 floor', () => {
// raw = 27; mitigated = 20.25; *1.6 = 32.4 -> rounds to 32
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20, 1.6)).toBe(32);
});
it('still floors at 1 damage even with a small multiplier', () => {
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(1);
});
}); });

View File

@@ -5,9 +5,13 @@ export interface DamageAttacker {
const ARMOR_MITIGATION_CONSTANT = 60; const ARMOR_MITIGATION_CONSTANT = 60;
export function calculateDamage(attacker: DamageAttacker, targetArmor: number): number { export function calculateDamage(
attacker: DamageAttacker,
targetArmor: number,
multiplier = 1,
): number {
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0); const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
const mitigatedDamage = const mitigatedDamage =
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor); (rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor);
return Math.max(1, Math.round(mitigatedDamage)); return Math.max(1, Math.round(mitigatedDamage * multiplier));
} }

View File

@@ -101,7 +101,190 @@ describe('CombatEngineService', () => {
it('throws UnsupportedCombatActionError for an action it does not implement', () => { it('throws UnsupportedCombatActionError for an action it does not implement', () => {
expect(() => expect(() =>
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }), engine.resolveAction(baseState(), { action: 'FLEE' as CombatAction }),
).toThrow(UnsupportedCombatActionError); ).toThrow(UnsupportedCombatActionError);
}); });
it('HEAVY_STRIKE deals 160% damage to the monster', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE });
// raw = 14; 14 * 1.6 = 22.4 -> rounds to 22
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 22,
});
expect(result.state.monster.currentHp).toBe(45 - 22);
});
it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH });
// raw = 14; 14 * 0.7 = 9.8 -> rounds to 10
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 10,
});
expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false);
});
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
const state = baseState({
monster: {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
},
});
const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH });
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT },
]);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100);
expect(result.state.round).toBe(2);
});
it('DEFEND deals no damage and halves the monster normal attack this round', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND });
expect(result.state.monster.currentHp).toBe(45);
// raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 },
]);
expect(result.state.player.currentHp).toBe(98);
});
it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => {
const state = baseState({
player: {
currentHp: 60,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 },
},
});
const result = engine.resolveAction(state, { action: CombatAction.POTION });
// 35% of 100 = 35
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.HEAL,
amount: 35,
});
expect(result.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
});
// 60 + 35 healed - 5 monster hit = 90
expect(result.state.player.currentHp).toBe(90);
expect(result.state.player.stats.potionsRemaining).toBe(1);
});
it('caps POTION healing at the maximum HP', () => {
const state = baseState({
player: {
currentHp: 90,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 },
},
});
const result = engine.resolveAction(state, { action: CombatAction.POTION });
// 35% of 100 = 35, but only 10 HP is missing
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.PLAYER,
type: CombatEventType.HEAL,
amount: 10,
});
});
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
const round3State = baseState({ round: 3 });
const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK });
expect(telegraphResult.state.player.currentHp).toBe(100);
expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
expect(telegraphResult.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.TELEGRAPH,
});
expect(telegraphResult.state.round).toBe(4);
const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK });
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7
expect(resolveResult.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 7,
});
expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined();
expect(resolveResult.state.player.currentHp).toBe(100 - 7);
});
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
const state = baseState({
monster: {
currentHp: 10,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
},
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.player.currentHp).toBe(100);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
]);
});
it('is deterministic for HEAVY_STRIKE as well', () => {
const state = baseState();
const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE });
expect(first).toEqual(second);
});
it('DEFEND mitigates a resolving Heavy Attack by half', () => {
const state = baseState({
monster: {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
},
});
const result = engine.resolveAction(state, { action: CombatAction.DEFEND });
// raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 4 },
]);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100 - 4);
});
}); });

View File

@@ -4,6 +4,7 @@ import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum'; import { Combatant } from './combatant.enum';
import { import {
CombatActionInput, CombatActionInput,
CombatEngineCombatant,
CombatEngineEvent, CombatEngineEvent,
CombatEngineResult, CombatEngineResult,
CombatEngineState, CombatEngineState,
@@ -17,62 +18,123 @@ export class UnsupportedCombatActionError extends Error {
} }
} }
// The monster telegraphs a Heavy Attack instead of striking every third
// round it acts, then resolves it the round after unless SHIELD_BASH
// interrupts it. A fixed cadence (not RNG) keeps combat deterministic
// (Playable Slice 0.6 spec §10/§11).
const TELEGRAPH_ROUND_INTERVAL = 3;
const HEAVY_ATTACK_MULTIPLIER = 1.6;
const SHIELD_BASH_MULTIPLIER = 0.7;
const DEFEND_MITIGATION_MULTIPLIER = 0.5;
const POTION_HEAL_FRACTION = 0.35;
@Injectable() @Injectable()
export class CombatEngineService { export class CombatEngineService {
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult { resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
switch (input.action) { switch (input.action) {
case CombatAction.ATTACK: case CombatAction.ATTACK:
return this.resolveAttack(state); return this.resolvePlayerStrike(state, 1);
case CombatAction.HEAVY_STRIKE:
return this.resolvePlayerStrike(state, HEAVY_ATTACK_MULTIPLIER);
case CombatAction.SHIELD_BASH:
return this.resolveShieldBash(state);
case CombatAction.DEFEND:
return this.resolveDefend(state);
case CombatAction.POTION:
return this.resolvePotion(state);
default: default:
throw new UnsupportedCombatActionError(input.action); throw new UnsupportedCombatActionError(input.action);
} }
} }
private resolveAttack(state: CombatEngineState): CombatEngineResult { private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = []; const events: CombatEngineEvent[] = [];
const player = { ...state.player };
const monster = { ...state.monster };
const playerDamage = calculateDamage(player.stats, monster.stats.armor); const damage = calculateDamage(player.stats, monster.stats.armor, multiplier);
monster.currentHp = Math.max(0, monster.currentHp - playerDamage); monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({ events.push({
source: Combatant.PLAYER, source: Combatant.PLAYER,
target: Combatant.MONSTER, target: Combatant.MONSTER,
type: CombatEventType.DAMAGE, type: CombatEventType.DAMAGE,
amount: playerDamage, amount: damage,
}); });
if (monster.currentHp <= 0) { return this.finishRound(state, player, monster, events, false);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
});
return {
state: { ...state, player, monster, status: CombatStatus.WON },
events,
};
} }
const monsterDamage = calculateDamage(monster.stats, player.stats.armor); private resolveShieldBash(state: CombatEngineState): CombatEngineResult {
player.currentHp = Math.max(0, player.currentHp - monsterDamage); const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [];
const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER);
monster.currentHp = Math.max(0, monster.currentHp - damage);
events.push({ events.push({
source: Combatant.MONSTER, source: Combatant.PLAYER,
target: Combatant.PLAYER, target: Combatant.MONSTER,
type: CombatEventType.DAMAGE, type: CombatEventType.DAMAGE,
amount: monsterDamage, amount: damage,
}); });
let interrupted = false;
if (monster.stats.pendingAction) {
monster.stats.pendingAction = undefined;
interrupted = true;
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT });
}
return this.finishRound(state, player, monster, events, false, interrupted);
}
private resolveDefend(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const events: CombatEngineEvent[] = [
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND },
];
return this.finishRound(state, player, monster, events, true);
}
private resolvePotion(state: CombatEngineState): CombatEngineResult {
const player = this.cloneCombatant(state.player);
const monster = this.cloneCombatant(state.monster);
const rawHeal = Math.round(player.maxHp * POTION_HEAL_FRACTION);
const healed = Math.min(rawHeal, player.maxHp - player.currentHp);
player.currentHp += healed;
player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1;
const events: CombatEngineEvent[] = [
{ source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed },
];
return this.finishRound(state, player, monster, events, false);
}
private finishRound(
state: CombatEngineState,
player: CombatEngineCombatant,
monster: CombatEngineCombatant,
events: CombatEngineEvent[],
defended: boolean,
interrupted = false,
): CombatEngineResult {
if (monster.currentHp <= 0) {
events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON });
const defeatedMonster = { ...monster, stats: { ...monster.stats, pendingAction: undefined } };
return { state: { ...state, player, monster: defeatedMonster, status: CombatStatus.WON }, events };
}
if (!interrupted) {
this.resolveMonsterTurn(state.round, monster, player, defended, events);
if (player.currentHp <= 0) { if (player.currentHp <= 0) {
events.push({ events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST });
source: Combatant.MONSTER, return { state: { ...state, player, monster, status: CombatStatus.LOST }, events };
target: Combatant.PLAYER, }
type: CombatEventType.COMBAT_LOST,
});
return {
state: { ...state, player, monster, status: CombatStatus.LOST },
events,
};
} }
return { return {
@@ -80,4 +142,46 @@ export class CombatEngineService {
events, events,
}; };
} }
private resolveMonsterTurn(
round: number,
monster: CombatEngineCombatant,
player: CombatEngineCombatant,
defended: boolean,
events: CombatEngineEvent[],
): void {
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
monster.stats.pendingAction = undefined;
const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier);
player.currentHp = Math.max(0, player.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: damage,
});
return;
}
if (round % TELEGRAPH_ROUND_INTERVAL === 0) {
monster.stats.pendingAction = 'HEAVY_ATTACK';
events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH });
return;
}
const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier);
player.currentHp = Math.max(0, player.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: damage,
});
}
private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant {
return { ...combatant, stats: { ...combatant.stats } };
}
} }

View File

@@ -3,10 +3,21 @@ import { CombatAction } from './combat-action.enum';
import { CombatEventType } from './combat-event-type.enum'; import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum'; import { CombatStatus } from './combat-status.enum';
// Only HEAVY_ATTACK needs telegraphing today; NORMAL_ATTACK resolves
// immediately and is never held as a pending intent (Playable Slice 0.6
// spec §5). Add members here as future slices add more prepared actions.
export type CombatIntent = 'HEAVY_ATTACK';
export interface CombatEngineCombatantStats { export interface CombatEngineCombatantStats {
attack: number; attack: number;
weaponDamage?: number; weaponDamage?: number;
armor: number; armor: number;
// Player-only: seeded at combat start, decremented by POTION. Optional
// because the monster's stats never carry it.
potionsRemaining?: number;
// Monster-only: set when it telegraphs, cleared when the action resolves
// or is interrupted. Optional because the player's stats never carry it.
pendingAction?: CombatIntent;
} }
export interface CombatEngineCombatant { export interface CombatEngineCombatant {

View File

@@ -0,0 +1,396 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentService } from '../equipment/equipment.service';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { CombatRewardService } from '../rewards/combat-reward.service';
import { TravelService } from '../travel/travel.service';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatService } from './combat.service';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
interface FakeState {
characters: Character[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
monsters: MonsterDefinition[];
combats: Combat[];
combatEvents: CombatEvent[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
private readonly target: EntityTarget<T>,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
}
find(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where));
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
}
count(options: { where: Partial<T> }): Promise<number> {
return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private withRelations(row: T, relations?: Record<string, unknown>): T {
if (!relations) {
return row;
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
copy['characterItem'] = characterItem
? {
...characterItem,
itemDefinition: this.state.itemDefinitions.find(
(d) => d.id === characterItem.itemDefinitionId,
),
}
: undefined;
}
return copy as T;
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === Hunt) return this.state.hunts as T[];
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
if (this.target === MonsterDefinition) return this.state.monsters as T[];
if (this.target === Combat) return this.state.combats as T[];
if (this.target === CombatEvent) return this.state.combatEvents as T[];
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
}
}
class FakeDataSource {
private readonly idCounters = new Map<string, number>();
constructor(public state: FakeState) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, this);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
} as unknown as EntityManager);
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
hpRegenSince: null,
currentLocationId: 'location-1',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Character;
}
function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition {
return {
id: MONSTER_ID,
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as MonsterDefinition;
}
function hunt(overrides: Partial<Hunt> = {}): Hunt {
return {
id: HUNT_ID,
characterId: CHARACTER_ID,
locationId: 'location-1',
status: HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Hunt;
}
function encounter(id: string, overrides: Partial<HuntEncounter> = {}): HuntEncounter {
return {
id,
huntId: HUNT_ID,
monsterDefinitionId: MONSTER_ID,
position: 0,
status: HuntEncounterStatus.AVAILABLE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as HuntEncounter;
}
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
return {
id: WORN_SWORD_DEFINITION_ID,
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: '',
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
rarity: ItemRarity.COMMON,
tier: 1,
requiredLevel: 1,
weaponDamage: 8,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
sellPrice: 0,
iconPath: '/images/items/worn-short-sword.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as ItemDefinition;
}
function fakeTravelService(): TravelService {
return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService;
}
function fakeRewardService(): CombatRewardService {
return {
grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }),
loadRewards: jest.fn().mockResolvedValue(null),
} as unknown as CombatRewardService;
}
function createHarness() {
const state: FakeState = {
characters: [character()],
hunts: [hunt()],
huntEncounters: [],
monsters: [monster()],
combats: [],
combatEvents: [],
itemDefinitions: [
itemDefinition(),
itemDefinition({
id: BANDIT_BLADE_DEFINITION_ID,
key: 'bandit-blade',
name: 'Räuberklinge',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
}),
],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: WORN_SWORD_DEFINITION_ID,
quantity: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: BANDIT_BLADE_DEFINITION_ID,
quantity: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterItem,
],
characterEquipment: [
{
id: 'equip-1',
characterId: CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: WORN_SWORD_ITEM_ID,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterEquipment,
],
};
const dataSource = new FakeDataSource(state);
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
});
const characterStats = new CharacterStatsService(
dataSource as unknown as DataSource,
characterVitals,
);
const equipmentService = new EquipmentService(
dataSource as unknown as DataSource,
characterStats,
characterVitals,
);
const combatService = new CombatService(
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
characterStats,
characterVitals,
fakeRewardService(),
);
return { state, equipmentService, combatService };
}
describe('equipping Räuberklinge 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();
state.huntEncounters.push(encounter('encounter-1'));
const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
let beforeDamage = 0;
let beforeResult = before;
for (let round = 0; round < 10 && beforeResult.status === 'ACTIVE'; round += 1) {
beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK);
if (round === 0) {
beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp;
}
}
// Equipment cannot change during an active combat (spec §46), so this
// first fight must be resolved to completion before equipping.
expect(beforeResult.status).not.toBe('ACTIVE');
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
state.huntEncounters.push(encounter('encounter-2'));
const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2');
const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK);
const afterDamage = after.monster.maxHp - afterResult.monster.currentHp;
// (6+8) vs 5 armor -> round(14 * 60/65) = 13
expect(beforeDamage).toBe(13);
// (7+11) vs 5 armor -> round(18 * 60/65) = 17
expect(afterDamage).toBe(17);
expect(afterDamage).toBeGreaterThan(beforeDamage);
});
it('rejects equipping during an active combat, and never retroactively rewrites a finished combat\'s snapshot', async () => {
const { state, combatService, equipmentService } = createHarness();
state.huntEncounters.push(encounter('encounter-1'));
const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
// Equipment cannot change while this combat is ACTIVE (spec §46).
await expect(
equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID),
).rejects.toMatchObject({ code: 'CHARACTER_IN_COMBAT' });
// Resolve the fight, then equip — the already-finished combat's snapshot
// (status/round) must stay exactly what it was when the fight ended.
let result = combat;
for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) {
result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK);
}
expect(result.status).not.toBe('ACTIVE');
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
// The finished combat's playerState snapshot (written once at startCombat)
// must not be retroactively rewritten by equipping after the fight ends.
expect(state.combats[0].playerState).toEqual({
attack: 6,
weaponDamage: 8,
armor: 0,
potionsRemaining: 2,
});
const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id);
expect(reloaded.status).toBe(result.status);
expect(reloaded.round).toBe(result.round);
});
});

View File

@@ -1,5 +1,9 @@
export enum CombatEventType { export enum CombatEventType {
DAMAGE = 'DAMAGE', DAMAGE = 'DAMAGE',
HEAL = 'HEAL',
DEFEND = 'DEFEND',
TELEGRAPH = 'TELEGRAPH',
INTERRUPT = 'INTERRUPT',
COMBAT_WON = 'COMBAT_WON', COMBAT_WON = 'COMBAT_WON',
COMBAT_LOST = 'COMBAT_LOST', COMBAT_LOST = 'COMBAT_LOST',
} }

View File

@@ -80,7 +80,7 @@ describe('CombatController', () => {
it('rejects an unknown action value', async () => { it('rejects an unknown action value', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/api/combats/combat-1/actions') .post('/api/combats/combat-1/actions')
.send({ action: 'HEAVY_STRIKE' }) .send({ action: 'FLEE' })
.expect(400); .expect(400);
expect(performAction).not.toHaveBeenCalled(); expect(performAction).not.toHaveBeenCalled();

View File

@@ -5,10 +5,12 @@ export type CombatErrorCode =
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED' | 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
| 'INVALID_HUNT_ENCOUNTER' | 'INVALID_HUNT_ENCOUNTER'
| 'CHARACTER_TRAVELLING' | 'CHARACTER_TRAVELLING'
| 'CHARACTER_TOO_WOUNDED'
| 'COMBAT_ALREADY_ACTIVE' | 'COMBAT_ALREADY_ACTIVE'
| 'COMBAT_NOT_FOUND' | 'COMBAT_NOT_FOUND'
| 'COMBAT_ALREADY_FINISHED' | 'COMBAT_ALREADY_FINISHED'
| 'COMBAT_STATE_INVALID'; | 'COMBAT_STATE_INVALID'
| 'COMBAT_NO_POTIONS_REMAINING';
export class CombatDomainError extends HttpException { export class CombatDomainError extends HttpException {
constructor( constructor(
@@ -52,6 +54,14 @@ export function characterTravelling(): CombatDomainError {
); );
} }
export function characterTooWounded(): CombatDomainError {
return new CombatDomainError(
'CHARACTER_TOO_WOUNDED',
HttpStatus.CONFLICT,
'The character is too wounded to fight.',
);
}
export function combatAlreadyActive(): CombatDomainError { export function combatAlreadyActive(): CombatDomainError {
return new CombatDomainError( return new CombatDomainError(
'COMBAT_ALREADY_ACTIVE', 'COMBAT_ALREADY_ACTIVE',
@@ -84,4 +94,12 @@ export function combatStateInvalid(): CombatDomainError {
); );
} }
export function combatNoPotionsRemaining(): CombatDomainError {
return new CombatDomainError(
'COMBAT_NO_POTIONS_REMAINING',
HttpStatus.CONFLICT,
'No potions remain in this combat.',
);
}
export { characterNotFound } from '../travel/travel.errors'; export { characterNotFound } from '../travel/travel.errors';

View File

@@ -1,5 +1,6 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm'; import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service'; import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity'; import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
@@ -179,6 +180,7 @@ function character(overrides: Partial<Character> = {}): Character {
baseHp: 100, baseHp: 100,
baseAttack: 6, baseAttack: 6,
currentHp: 100, currentHp: 100,
hpRegenSince: null,
currentLocationId: 'location-1', currentLocationId: 'location-1',
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'),
@@ -242,6 +244,19 @@ function createState(overrides: Partial<FakeState> = {}): FakeState {
}; };
} }
function fakeCharacterStats(): CharacterStatsService {
return {
calculate: jest.fn(async (character: Character) => ({
maxHp: character.baseHp,
currentHp: character.currentHp,
attack: character.baseAttack,
weaponDamage: 8,
armor: 6,
combatPower: 0,
})),
} as unknown as CharacterStatsService;
}
function fakeTravelService( function fakeTravelService(
status: 'IDLE' | 'TRAVELLING' = 'IDLE', status: 'IDLE' | 'TRAVELLING' = 'IDLE',
): TravelService { ): TravelService {
@@ -271,12 +286,16 @@ function createService(
const dataSource = new FakeDataSource(state); const dataSource = new FakeDataSource(state);
const travelService = options.travelService ?? fakeTravelService(); const travelService = options.travelService ?? fakeTravelService();
const combatEngine = new CombatEngineService(); const combatEngine = new CombatEngineService();
const characterCombatStats = new CharacterCombatStatsService(); const characterCombatStats = fakeCharacterStats();
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
});
const service = new CombatService( const service = new CombatService(
dataSource as unknown as DataSource, dataSource as unknown as DataSource,
travelService, travelService,
combatEngine, combatEngine,
characterCombatStats, characterCombatStats,
characterVitals,
fakeRewardService(), fakeRewardService(),
); );
return { dataSource, service, travelService }; return { dataSource, service, travelService };
@@ -312,6 +331,8 @@ describe('CombatService', () => {
name: 'Aric Duskwalker', name: 'Aric Duskwalker',
maxHp: 100, maxHp: 100,
currentHp: 100, currentHp: 100,
potionsRemaining: 2,
potionsMax: 2,
}); });
expect(combat.monster).toEqual({ expect(combat.monster).toEqual({
key: 'ash-rat', key: 'ash-rat',
@@ -320,6 +341,7 @@ describe('CombatService', () => {
maxHp: 45, maxHp: 45,
currentHp: 45, currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
}); });
expect(combat.events).toEqual([]); expect(combat.events).toEqual([]);
expect(dataSource.state.combats).toHaveLength(1); expect(dataSource.state.combats).toHaveLength(1);
@@ -333,6 +355,49 @@ describe('CombatService', () => {
}); });
}); });
it('seeds player HP from the character, carrying HP from a previous fight rather than starting full', async () => {
const state = createState({ characters: [character({ currentHp: 63 })] });
const { dataSource, service } = createService({ state });
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(combat.player.currentHp).toBe(63);
expect(dataSource.state.combats[0].playerCurrentHp).toBe(63);
});
it('pauses regeneration on the character once a combat starts', async () => {
const state = createState({
characters: [
character({ currentHp: 63, hpRegenSince: new Date('2026-08-18T08:00:00.000Z') }),
],
});
const { dataSource, service } = createService({ state });
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.state.characters[0].currentHp).toBe(63);
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
});
it('rejects starting a combat when the character has 0 effective HP', async () => {
const state = createState({ characters: [character({ currentHp: 0 })] });
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'CHARACTER_TOO_WOUNDED',
);
});
it('allows starting a combat at exactly 1 effective HP', async () => {
const state = createState({ characters: [character({ currentHp: 1 })] });
const { service } = createService({ state });
await expect(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
).resolves.toMatchObject({ status: 'ACTIVE' });
});
it('marks the encounter as IN_PROGRESS', async () => { it('marks the encounter as IN_PROGRESS', async () => {
const { dataSource, service } = createService(); const { dataSource, service } = createService();
@@ -514,6 +579,41 @@ describe('CombatService', () => {
}); });
}); });
it('mirrors the player HP onto the character each round while the fight continues', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.characters[0].currentHp).toBe(95);
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
});
it('restarts regeneration on the character once the fight is won', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.characters[0].currentHp).toBe(
dataSource.state.combats[0].playerCurrentHp,
);
expect(dataSource.state.characters[0].hpRegenSince).toEqual(
new Date('2026-08-18T09:00:00.000Z'),
);
});
it('restarts regeneration from 0 HP once the fight is lost', async () => {
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.characters[0].currentHp).toBe(0);
expect(dataSource.state.characters[0].hpRegenSince).toEqual(
new Date('2026-08-18T09:00:00.000Z'),
);
});
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => { it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] }); const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state); const { dataSource, service, combatId } = await startedCombat(state);
@@ -534,7 +634,7 @@ describe('CombatService', () => {
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => { it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({ const state = createState({
characters: [character({ baseHp: 1 })], characters: [character({ baseHp: 1, currentHp: 1 })],
}); });
const { dataSource, service, combatId } = await startedCombat(state); const { dataSource, service, combatId } = await startedCombat(state);
@@ -565,7 +665,7 @@ describe('CombatService', () => {
}); });
it('frees the encounter for another attempt when the fight is lost', async () => { it('frees the encounter for another attempt when the fight is lost', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] }); const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state); const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
@@ -585,11 +685,19 @@ describe('CombatService', () => {
); );
}); });
it('lets a lost encounter be fought again as a fresh combat', async () => { it('lets a lost encounter be fought again once the character has recovered HP', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] }); const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state); const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
// The loss now mirrors 0 HP onto the character (this task); simulate
// that regeneration has since restored it before retrying. This test
// is about the encounter itself being retryable once the character
// can fight again, not about regen math (covered by
// CharacterVitalsService's own tests).
dataSource.state.characters[0].currentHp = 1;
dataSource.state.characters[0].hpRegenSince = null;
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(retry.id).not.toBe(combatId); expect(retry.id).not.toBe(combatId);
@@ -640,6 +748,43 @@ describe('CombatService', () => {
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]), expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
); );
}); });
it('resolves POTION, heals the player, and persists the reduced potion count', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
expect(result.player.potionsRemaining).toBe(1);
expect(result.player.currentHp).toBe(95);
const reloaded = await service.getCombat(CHARACTER_ID, combatId);
expect(reloaded.player.potionsRemaining).toBe(1);
});
it('rejects POTION once both potions have been used', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION);
await expectCombatDomainError(
service.performAction(CHARACTER_ID, combatId, CombatAction.POTION),
'COMBAT_NO_POTIONS_REMAINING',
);
});
it('persists the telegraphed Heavy Attack across a reload', async () => {
const { service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const telegraphed = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(telegraphed.monster.pendingIntent).toBe('HEAVY_ATTACK');
const reloaded = await service.getCombat(CHARACTER_ID, combatId);
expect(reloaded.monster.pendingIntent).toBe('HEAVY_ATTACK');
});
}); });
describe('getCombat', () => { describe('getCombat', () => {
@@ -728,7 +873,7 @@ describe('CombatService', () => {
it('keeps returning LOST after the combat has ended', async () => { it('keeps returning LOST after the combat has ended', async () => {
const state = createState({ const state = createState({
characters: [character({ baseHp: 1 })], characters: [character({ baseHp: 1, currentHp: 1 })],
}); });
const context = createService({ state }); const context = createService({ state });
const started = await context.service.startCombat( const started = await context.service.startCombat(
@@ -785,7 +930,10 @@ describe('CombatService', () => {
dataSource as unknown as DataSource, dataSource as unknown as DataSource,
fakeTravelService(), fakeTravelService(),
new CombatEngineService(), new CombatEngineService(),
new CharacterCombatStatsService(), fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards, rewards,
); );
@@ -816,7 +964,7 @@ describe('CombatService', () => {
huntEncounterId: ENCOUNTER_ID, huntEncounterId: ENCOUNTER_ID,
monsterDefinitionId: MONSTER_ID, monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE, status: CombatStatus.ACTIVE,
round: 3, round: 2,
playerMaxHp: 100, playerMaxHp: 100,
playerCurrentHp: 1, playerCurrentHp: 1,
monsterMaxHp: 45, monsterMaxHp: 45,
@@ -833,7 +981,10 @@ describe('CombatService', () => {
dataSource as unknown as DataSource, dataSource as unknown as DataSource,
fakeTravelService(), fakeTravelService(),
new CombatEngineService(), new CombatEngineService(),
new CharacterCombatStatsService(), fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards, rewards,
); );
@@ -890,7 +1041,10 @@ describe('CombatService', () => {
dataSource as unknown as DataSource, dataSource as unknown as DataSource,
fakeTravelService(), fakeTravelService(),
new CombatEngineService(), new CombatEngineService(),
new CharacterCombatStatsService(), fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards, rewards,
); );
@@ -927,7 +1081,10 @@ describe('CombatService', () => {
dataSource as unknown as DataSource, dataSource as unknown as DataSource,
fakeTravelService(), fakeTravelService(),
new CombatEngineService(), new CombatEngineService(),
new CharacterCombatStatsService(), fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
fakeRewardService({ fakeRewardService({
// Genuinely write XP/silver through the transaction's manager // Genuinely write XP/silver through the transaction's manager
// before failing, so the assertions below prove the rollback // before failing, so the assertions below prove the rollback

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service'; import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity'; import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
@@ -13,12 +14,14 @@ import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum'; import { TravelStatus } from '../travel/travel-status.enum';
import { CombatAction } from './combat-action.enum'; import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service'; import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types'; import { CombatEngineState, CombatIntent } from './combat-engine.types';
import { import {
characterNotFound, characterNotFound,
characterTooWounded,
characterTravelling, characterTravelling,
combatAlreadyActive, combatAlreadyActive,
combatAlreadyFinished, combatAlreadyFinished,
combatNoPotionsRemaining,
combatNotFound, combatNotFound,
combatStateInvalid, combatStateInvalid,
huntEncounterAlreadyConsumed, huntEncounterAlreadyConsumed,
@@ -27,12 +30,18 @@ import {
} from './combat.errors'; } from './combat.errors';
import { CombatStatus } from './combat-status.enum'; import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity'; import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity'; import { Combat, CombatPlayerState } from './entities/combat.entity';
// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the
// persistent consumable inventory.
const STARTING_POTION_COUNT = 2;
export interface CombatPlayerDto { export interface CombatPlayerDto {
name: string; name: string;
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
potionsRemaining: number;
potionsMax: number;
} }
export interface CombatMonsterDto { export interface CombatMonsterDto {
@@ -42,6 +51,7 @@ export interface CombatMonsterDto {
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
artworkPath: string; artworkPath: string;
pendingIntent: CombatIntent | null;
} }
export interface CombatEventDto { export interface CombatEventDto {
@@ -69,7 +79,8 @@ export class CombatService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly travelService: TravelService, private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService, private readonly combatEngine: CombatEngineService,
private readonly characterCombatStats: CharacterCombatStatsService, private readonly characterStats: CharacterStatsService,
private readonly characterVitals: CharacterVitalsService,
private readonly combatRewards: CombatRewardService, private readonly combatRewards: CombatRewardService,
) {} ) {}
@@ -126,7 +137,12 @@ export class CombatService {
throw invalidHuntEncounter(); throw invalidHuntEncounter();
} }
const playerStats = this.characterCombatStats.getStats(character); const playerStats = await this.characterStats.calculate(character, manager);
if (playerStats.currentHp < 1) {
throw characterTooWounded();
}
this.characterVitals.pause(character, playerStats.currentHp);
await characters.save(character);
const combat = combats.create({ const combat = combats.create({
characterId, characterId,
@@ -135,13 +151,14 @@ export class CombatService {
status: CombatStatus.ACTIVE, status: CombatStatus.ACTIVE,
round: 1, round: 1,
playerMaxHp: playerStats.maxHp, playerMaxHp: playerStats.maxHp,
playerCurrentHp: playerStats.maxHp, playerCurrentHp: playerStats.currentHp,
monsterMaxHp: monster.maxHp, monsterMaxHp: monster.maxHp,
monsterCurrentHp: monster.maxHp, monsterCurrentHp: monster.maxHp,
playerState: { playerState: {
attack: playerStats.attack, attack: playerStats.attack,
weaponDamage: playerStats.weaponDamage, weaponDamage: playerStats.weaponDamage,
armor: playerStats.armor, armor: playerStats.armor,
potionsRemaining: STARTING_POTION_COUNT,
}, },
monsterState: { attack: monster.attack, armor: monster.armor }, monsterState: { attack: monster.attack, armor: monster.armor },
completedAt: null, completedAt: null,
@@ -208,7 +225,7 @@ export class CombatService {
// no-op re-lock — but locking it first here keeps both code paths // no-op re-lock — but locking it first here keeps both code paths
// consistent and avoids a lock-order inversion that could deadlock two // consistent and avoids a lock-order inversion that could deadlock two
// concurrent requests against the same character. Do not reorder this. // concurrent requests against the same character. Do not reorder this.
await this.lockCharacter(characters, characterId); const character = await this.lockCharacter(characters, characterId);
const combat = await combats.findOne({ const combat = await combats.findOne({
where: { id: combatId, characterId }, where: { id: combatId, characterId },
@@ -220,6 +237,9 @@ export class CombatService {
if (combat.status !== CombatStatus.ACTIVE) { if (combat.status !== CombatStatus.ACTIVE) {
throw combatAlreadyFinished(); throw combatAlreadyFinished();
} }
if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) {
throw combatNoPotionsRemaining();
}
const actionRound = combat.round; const actionRound = combat.round;
const engineState = this.toEngineState(combat); const engineState = this.toEngineState(combat);
@@ -229,14 +249,20 @@ export class CombatService {
combat.status = result.state.status; combat.status = result.state.status;
combat.playerCurrentHp = result.state.player.currentHp; combat.playerCurrentHp = result.state.player.currentHp;
combat.monsterCurrentHp = result.state.monster.currentHp; combat.monsterCurrentHp = result.state.monster.currentHp;
combat.playerState = result.state.player.stats as CombatPlayerState;
combat.monsterState = result.state.monster.stats;
if (combat.status !== CombatStatus.ACTIVE) { if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date(); combat.completedAt = new Date();
this.characterVitals.resume(character, combat.playerCurrentHp);
await this.settleEncounter( await this.settleEncounter(
manager.getRepository(HuntEncounter), manager.getRepository(HuntEncounter),
combat.huntEncounterId, combat.huntEncounterId,
combat.status, combat.status,
); );
} else {
this.characterVitals.pause(character, combat.playerCurrentHp);
} }
await characters.save(character);
await combats.save(combat); await combats.save(combat);
const startingSequence = await combatEvents.count({ const startingSequence = await combatEvents.count({
@@ -264,7 +290,7 @@ export class CombatService {
? await this.combatRewards.grantVictoryRewards(manager, combat) ? await this.combatRewards.grantVictoryRewards(manager, combat)
: null; : null;
const [character, monster, events] = await Promise.all([ const [reloadedCharacter, monster, events] = await Promise.all([
this.loadCharacter( this.loadCharacter(
combat.characterId, combat.characterId,
manager.getRepository(Character), manager.getRepository(Character),
@@ -276,7 +302,7 @@ export class CombatService {
this.loadEvents(combat.id, combatEvents), this.loadEvents(combat.id, combatEvents),
]); ]);
return this.toCombatDto(combat, character.name, monster, events, rewards); return this.toCombatDto(combat, reloadedCharacter.name, monster, events, rewards);
}); });
} }
@@ -386,6 +412,8 @@ export class CombatService {
name: playerName, name: playerName,
maxHp: combat.playerMaxHp, maxHp: combat.playerMaxHp,
currentHp: combat.playerCurrentHp, currentHp: combat.playerCurrentHp,
potionsRemaining: combat.playerState.potionsRemaining,
potionsMax: STARTING_POTION_COUNT,
}, },
monster: { monster: {
key: monster.key, key: monster.key,
@@ -394,6 +422,7 @@ export class CombatService {
maxHp: combat.monsterMaxHp, maxHp: combat.monsterMaxHp,
currentHp: combat.monsterCurrentHp, currentHp: combat.monsterCurrentHp,
artworkPath: monster.artworkPath, artworkPath: monster.artworkPath,
pendingIntent: combat.monsterState.pendingAction ?? null,
}, },
events: events.map((event) => ({ events: events.map((event) => ({
round: event.round, round: event.round,

View File

@@ -11,15 +11,18 @@ import {
import { Character } from '../../characters/entities/character.entity'; import { Character } from '../../characters/entities/character.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity'; import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { CombatIntent } from '../combat-engine.types';
import { CombatStatus } from '../combat-status.enum'; import { CombatStatus } from '../combat-status.enum';
export interface CombatCombatantState { export interface CombatCombatantState {
attack: number; attack: number;
armor: number; armor: number;
pendingAction?: CombatIntent;
} }
export interface CombatPlayerState extends CombatCombatantState { export interface CombatPlayerState extends CombatCombatantState {
weaponDamage: number; weaponDamage: number;
potionsRemaining: number;
} }
@Entity({ name: 'combats' }) @Entity({ name: 'combats' })

View File

@@ -0,0 +1,82 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds the local location view content to `location_definitions`.
*
* The existing `description`/`artwork_path` columns are deliberately left
* alone — the map and hunt screens still render them. Backfill defaults keep
* existing rows valid; the seed replaces them with authored content.
*/
export class CreateLocalLocationView1788700000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "region_tier_label" character varying(50) NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "location_type" character varying(50) NOT NULL DEFAULT \'TRANSITION\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_description" text NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_artwork_path" character varying(255) NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_points_of_interest" jsonb NOT NULL DEFAULT \'[]\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_primary_actions" jsonb NOT NULL DEFAULT \'[]\'',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" ADD COLUMN "local_reward_preview" jsonb NOT NULL DEFAULT \'[]\'',
);
// Existing rows fall back to their map-level content until the seed runs,
// so the view never renders an empty breadcrumb or a missing artwork.
await queryRunner.query(
'UPDATE "location_definitions" SET "region_name" = "region_key", "local_description" = "description", "local_artwork_path" = "artwork_path" WHERE "region_name" = \'\'',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path" character varying(255) NOT NULL DEFAULT \'\'',
);
await queryRunner.query(
'UPDATE "monster_definitions" SET "icon_path" = "artwork_path" WHERE "icon_path" = \'\'',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_reward_preview"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_primary_actions"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_points_of_interest"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_artwork_path"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "local_description"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "location_type"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "region_tier_label"',
);
await queryRunner.query(
'ALTER TABLE "location_definitions" DROP COLUMN "region_name"',
);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateEquipment1789000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Reuses the "equipment_slot_enum" type created by CreateLootAndRewards.
await queryRunner.query(`CREATE TABLE "character_equipment" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"slot" "equipment_slot_enum" NOT NULL,
"character_item_id" uuid NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_equipment" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'DROP INDEX "IDX_character_equipment_character_item"',
);
await queryRunner.query(
'DROP INDEX "IDX_character_equipment_character_slot"',
);
await queryRunner.query('DROP TABLE "character_equipment"');
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ExtendCombatEventTypes1790000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`);
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`);
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`);
await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Postgres has no "DROP VALUE"; rebuild the type from scratch instead.
// This fails if any row already uses one of the new values -- expected
// for a dev rollback, same tradeoff Postgres migrations always make here.
await queryRunner.query(
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`,
);
await queryRunner.query(`DROP TYPE "combat_event_type_enum"`);
await queryRunner.query(
`CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')`,
);
await queryRunner.query(
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`,
);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddHpRegeneration1792000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "hp_regen_since" TIMESTAMP WITH TIME ZONE',
);
// Existing characters start regenerating immediately from their current
// HP. A character whose fight is still ACTIVE keeps regeneration paused
// until that fight resolves, matching the "no combat-time regen" rule
// (persistent-hp-and-regeneration design, R4) -- this migration must not
// gift them free healing mid-fight.
await queryRunner.query('UPDATE "characters" SET "hp_regen_since" = now()');
await queryRunner.query(`UPDATE "characters" AS "character"
SET "hp_regen_since" = NULL
FROM "combats" AS "combat"
WHERE "combat"."character_id" = "character"."id"
AND "combat"."status" = 'ACTIVE'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"');
}
}

View File

@@ -0,0 +1,17 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
describe('characters.hp_regen_since schema', () => {
it('stores the regeneration anchor as a nullable timestamptz', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === Character && candidate.propertyName === 'hpRegenSince',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('timestamptz');
expect(column?.options.nullable).toBe(true);
});
});

View File

@@ -0,0 +1,52 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
describe('character_equipment schema', () => {
it('stores slot as a non-nullable equipment_slot_enum column', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === CharacterEquipment && candidate.propertyName === 'slot',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('enum');
expect(column?.options.enumName).toBe('equipment_slot_enum');
expect(column?.options.nullable).toBeFalsy();
});
it('enforces one equipped item per character per slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.includes('characterId') &&
candidate.columns?.includes('slot'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('forbids one CharacterItem from occupying more than one equipment slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.length === 1 &&
candidate.columns?.includes('characterItemId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { CombatEventType } from '../../combat/combat-event-type.enum';
describe('combat_events.type enum', () => {
it('includes the Playable Slice 0.6 event types', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) => candidate.target === CombatEvent && candidate.propertyName === 'type',
);
expect(column).toBeDefined();
expect(column?.options.enum).toBe(CombatEventType);
expect(Object.values(CombatEventType)).toEqual(
expect.arrayContaining([
'DAMAGE',
'HEAL',
'DEFEND',
'TELEGRAPH',
'INTERRUPT',
'COMBAT_WON',
'COMBAT_LOST',
]),
);
});
});

View File

@@ -0,0 +1,71 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { getMetadataArgsStorage } from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
const MIGRATION_SQL = readFileSync(
join(__dirname, '1788700000000-CreateLocalLocationView.ts'),
'utf8',
);
function columnNames(target: unknown): string[] {
return getMetadataArgsStorage()
.columns.filter((column) => column.target === target)
.map((column) => column.options.name)
.filter((name): name is string => typeof name === 'string');
}
describe('local location view schema', () => {
const newLocationColumns = [
'region_name',
'region_tier_label',
'location_type',
'local_description',
'local_artwork_path',
'local_points_of_interest',
'local_primary_actions',
'local_reward_preview',
];
it.each(newLocationColumns)(
'maps %s on LocationDefinition',
(name: string) => {
expect(columnNames(LocationDefinition)).toContain(name);
},
);
it.each(newLocationColumns)('adds %s in the migration', (name: string) => {
expect(MIGRATION_SQL).toContain(
`ALTER TABLE "location_definitions" ADD COLUMN "${name}"`,
);
expect(MIGRATION_SQL).toContain(
`ALTER TABLE "location_definitions" DROP COLUMN "${name}"`,
);
});
it('adds the monster icon path in both the entity and the migration', () => {
expect(columnNames(MonsterDefinition)).toContain('icon_path');
expect(MIGRATION_SQL).toContain(
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path"',
);
expect(MIGRATION_SQL).toContain(
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
);
});
it('keeps the map-level columns untouched so the world screen is unaffected', () => {
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "description"');
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "artwork_path"');
expect(columnNames(LocationDefinition)).toEqual(
expect.arrayContaining(['description', 'artwork_path', 'region_key']),
);
});
it('backfills existing rows so no location renders an empty breadcrumb', () => {
expect(MIGRATION_SQL).toContain(
'UPDATE "location_definitions" SET "region_name" = "region_key"',
);
});
});

View File

@@ -0,0 +1,210 @@
import type {
LocationPointOfInterestContent,
LocationPrimaryActionContent,
LocationRewardPreviewContent,
LocationType,
} from '../../world/local-location.types';
/**
* Authored local-view content per location (plan §7§11).
*
* Coordinates are percentages of the artwork box, so a hotspot stays on the
* same painted detail at every viewport width. They are tuned against the real
* artwork in `apps/web/public/images/backgrounds/`, not against the
* composition mockup in `docs/references/`.
*/
export interface LocalLocationContent {
regionName: string;
regionTierLabel: string;
locationType: LocationType;
localDescription: string;
localArtworkPath: string;
localPointsOfInterest: LocationPointOfInterestContent[];
localPrimaryActions: LocationPrimaryActionContent[];
localRewardPreview: LocationRewardPreviewContent[];
}
export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = {
regionName: 'Aschenfelder',
regionTierLabel: 'Gebiet 1',
locationType: 'HUNTING_GROUND',
localDescription:
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde. Verbrannte Karren, zerbrochene Waffen und verstummte Schreie säumen den Pfad in die Aschenfelder.',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
// Anchored to painted detail in `Aschestrasse.png`: the burning horizon down
// the road, the standing gravestone on the left verge, the cracked stones in
// the near foreground, and the broken cart wheel on the right.
localPointsOfInterest: [
{
key: 'hunt-area',
title: 'Jagdgebiet',
actionLabel: 'Jagd beginnen',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 57,
yPercent: 33,
enabled: true,
},
{
key: 'inspect-tracks',
title: 'Verdächtige Spuren',
actionLabel: 'Untersuchen',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 40,
yPercent: 82,
enabled: true,
resultTitle: 'Verdächtige Spuren',
resultText:
'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.',
},
{
key: 'search-abandoned-wagon',
title: 'Verlassener Wagen',
actionLabel: 'Durchsuchen',
type: 'SEARCH',
iconKey: 'search',
xPercent: 86,
yPercent: 70,
enabled: true,
resultTitle: 'Verlassener Wagen',
resultText:
'Der Wagen wurde gründlich geplündert. Zwischen verbrannten Brettern findest du nur leere Kisten und Spuren eines hastigen Aufbruchs.',
},
{
key: 'wounded-scout',
title: 'Verwundeter Kundschafter',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 17,
yPercent: 62,
enabled: true,
resultTitle: 'Verwundeter Kundschafter',
resultText:
'„Die Straße ist nicht mehr sicher. Die Plünderer kommen aus Richtung des alten Wachtpostens. Wenn du weitergehst, halte die Augen offen."',
},
],
localPrimaryActions: [
{
key: 'start-hunt',
label: 'Jagd beginnen',
description: 'Im Gebiet jagen',
type: 'HUNT',
iconKey: 'hunt',
enabled: true,
},
{
key: 'investigate-tracks',
label: 'Spuren untersuchen',
description: 'Hinweise finden',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'inspect-tracks',
},
{
key: 'search-surroundings',
label: 'Umgebung durchsuchen',
description: 'Beute finden',
type: 'SEARCH',
iconKey: 'search',
enabled: true,
poiKey: 'search-abandoned-wagon',
},
{
key: 'open-map',
label: 'Zur Karte',
description: 'Gebiet wechseln',
type: 'MAP',
iconKey: 'map',
enabled: true,
},
],
// Only categories the loot tables on this road actually back: silver and
// experience from every kill, gear from the raiders, pelts from the beasts.
// Named items stay out — the view may not promise a drop the roll does not
// guarantee (spec §8, "Mögliche Belohnungen").
localRewardPreview: [
{ key: 'silver', label: 'Silber', iconKey: 'silver' },
{ key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
{ key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' },
{ key: 'material', label: 'Material', iconKey: 'material' },
],
};
export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
regionName: 'Aschenfelder',
regionTierLabel: 'Gebiet 1',
locationType: 'TRANSITION',
localDescription:
'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.',
localArtworkPath: '/images/backgrounds/Suedtor.png',
localPointsOfInterest: [
{
key: 'gate-notice',
title: 'Aushangtafel',
actionLabel: 'Lesen',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 22,
yPercent: 42,
enabled: true,
resultTitle: 'Aushangtafel',
resultText:
'Verwitterte Anschläge flattern im Wind. Ein frischer Zettel warnt vor Plünderern auf der Verbrannten Straße und verspricht Silber für jeden erlegten Räuber.',
},
{
key: 'gate-watch',
title: 'Torwache',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
yPercent: 52,
enabled: true,
resultTitle: 'Torwache',
resultText:
'„Hinter dem Tor endet Graufurts Schutz. Wer nach Süden geht, geht auf eigene Gefahr — und kommt selten so zurück, wie er gegangen ist."',
},
{
key: 'south-road',
title: 'Straße nach Süden',
actionLabel: 'Zur Karte',
type: 'MAP',
iconKey: 'map',
xPercent: 66,
yPercent: 72,
enabled: true,
},
],
localPrimaryActions: [
{
key: 'talk-to-watch',
label: 'Wache ansprechen',
description: 'Lage erfragen',
type: 'NPC',
iconKey: 'speak',
enabled: true,
poiKey: 'gate-watch',
},
{
key: 'read-notice',
label: 'Aushang lesen',
description: 'Hinweise finden',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'gate-notice',
},
{
key: 'open-map',
label: 'Zur Karte',
description: 'Gebiet wechseln',
type: 'MAP',
iconKey: 'map',
enabled: true,
},
],
localRewardPreview: [],
};

View File

@@ -2,3 +2,5 @@ export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002'; export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001'; export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';

View File

@@ -1,5 +1,7 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Character } from '../../characters/entities/character.entity'; import { Character } from '../../characters/entities/character.entity';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity'; import { LootTable } from '../../loot/entities/loot-table.entity';
@@ -7,6 +9,7 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants'; import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants';
import { seedVisibleVerticalSlice } from './vertical-slice.seed'; import { seedVisibleVerticalSlice } from './vertical-slice.seed';
@@ -17,6 +20,8 @@ const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002'; const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001'; const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
class InMemoryRepository { class InMemoryRepository {
readonly rows: Row[] = []; readonly rows: Row[] = [];
@@ -74,6 +79,8 @@ function createDataSource(
itemRepository: InMemoryRepository = new InMemoryRepository(), itemRepository: InMemoryRepository = new InMemoryRepository(),
lootTableRepository: InMemoryRepository = new InMemoryRepository(), lootTableRepository: InMemoryRepository = new InMemoryRepository(),
lootEntryRepository: InMemoryRepository = new InMemoryRepository(), lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
): DataSource { ): DataSource {
return { return {
getRepository: jest.fn((entity: unknown) => { getRepository: jest.fn((entity: unknown) => {
@@ -85,6 +92,8 @@ function createDataSource(
if (entity === ItemDefinition) return itemRepository; if (entity === ItemDefinition) return itemRepository;
if (entity === LootTable) return lootTableRepository; if (entity === LootTable) return lootTableRepository;
if (entity === LootTableEntry) return lootEntryRepository; if (entity === LootTableEntry) return lootEntryRepository;
if (entity === CharacterItem) return characterItemRepository;
if (entity === CharacterEquipment) return characterEquipmentRepository;
throw new Error('Unexpected repository'); throw new Error('Unexpected repository');
}), }),
@@ -153,8 +162,8 @@ describe('seedVisibleVerticalSlice', () => {
}), }),
); );
expect(monsterRepository.insert).toHaveBeenCalledTimes(2); expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
expect(monsterRepository.rows).toHaveLength(2); expect(monsterRepository.rows).toHaveLength(4);
expect(monsterRepository.rows).toEqual( expect(monsterRepository.rows).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -181,6 +190,20 @@ describe('seedVisibleVerticalSlice', () => {
silverMax: 15, silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png', artworkPath: '/images/monsters/road-bandit.png',
}), }),
expect.objectContaining({
key: 'wild-road-dog',
name: 'Verwilderter Straßenhund',
level: 1,
artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png',
}),
expect.objectContaining({
key: 'charred-looter',
name: 'Verkohlter Plünderer',
level: 2,
artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/combat/icons/charred-looter-128.png',
}),
]), ]),
); );
@@ -189,17 +212,114 @@ describe('seedVisibleVerticalSlice', () => {
expect.objectContaining({ expect.objectContaining({
locationId: BURNED_ROAD_ID, locationId: BURNED_ROAD_ID,
monsterId: ASH_RAT_MONSTER_ID, monsterId: ASH_RAT_MONSTER_ID,
weight: 70, weight: 40,
}),
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: WILD_ROAD_DOG_MONSTER_ID,
weight: 30,
}), }),
expect.objectContaining({ expect.objectContaining({
locationId: BURNED_ROAD_ID, locationId: BURNED_ROAD_ID,
monsterId: ROAD_BANDIT_MONSTER_ID, monsterId: ROAD_BANDIT_MONSTER_ID,
weight: 30, weight: 20,
}),
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: CHARRED_LOOTER_MONSTER_ID,
weight: 10,
}), }),
]), ]),
['locationId', 'monsterId'], ['locationId', 'monsterId'],
); );
expect(locationMonsterRepository.rows).toHaveLength(2); expect(locationMonsterRepository.rows).toHaveLength(4);
});
it('seeds the local view content of the Verbrannte Straße with four points of interest', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
);
await seedVisibleVerticalSlice(dataSource);
const burnedRoad = locationRepository.rows.find(
(row) => row.key === 'burned-road',
) as Row;
expect(burnedRoad).toEqual(
expect.objectContaining({
regionName: 'Aschenfelder',
regionTierLabel: 'Gebiet 1',
locationType: 'HUNTING_GROUND',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
}),
);
expect(burnedRoad.localDescription).toContain(
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
);
const pointsOfInterest = burnedRoad.localPointsOfInterest as {
key: string;
type: string;
}[];
expect(pointsOfInterest.map((poi) => poi.key)).toEqual([
'hunt-area',
'inspect-tracks',
'search-abandoned-wagon',
'wounded-scout',
]);
expect(pointsOfInterest.map((poi) => poi.type)).toEqual([
'HUNT',
'INVESTIGATE',
'SEARCH',
'NPC',
]);
const primaryActions = burnedRoad.localPrimaryActions as {
label: string;
}[];
expect(primaryActions.map((action) => action.label)).toEqual([
'Jagd beginnen',
'Spuren untersuchen',
'Umgebung durchsuchen',
'Zur Karte',
]);
});
it('gives the Südtor its own local content so a second location needs no new component', async () => {
const locationRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
);
await seedVisibleVerticalSlice(dataSource);
const southGate = locationRepository.rows.find(
(row) => row.key === 'south-gate',
) as Row;
expect(southGate).toEqual(
expect.objectContaining({
locationType: 'TRANSITION',
localArtworkPath: '/images/backgrounds/Suedtor.png',
}),
);
expect(southGate.localPointsOfInterest).toHaveLength(3);
// A transition location offers no hunt, so no HUNT hotspot may appear.
expect(
(southGate.localPointsOfInterest as { type: string }[]).some(
(poi) => poi.type === 'HUNT',
),
).toBe(false);
}); });
it('preserves existing location IDs and uses them for the directed connections', async () => { it('preserves existing location IDs and uses them for the directed connections', async () => {
@@ -336,4 +456,125 @@ describe('seedVisibleVerticalSlice', () => {
]), ]),
); );
}); });
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(characterItemRepository.rows).toHaveLength(1);
expect(characterItemRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
}),
);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
slot: 'WEAPON',
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
}),
);
});
it('never re-equips the starting sword once the player has equipped different gear', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
await seedVisibleVerticalSlice(dataSource);
// Simulate the player having equipped earned loot instead.
characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id';
await seedVisibleVerticalSlice(dataSource);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe(
'earned-bandit-blade-item-id',
);
expect(characterItemRepository.rows).toHaveLength(1);
});
it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
// Simulate the demo character having already looted a worn-short-sword
// naturally, under a DB-generated id that differs from the seed's
// stable literal constant.
const naturallyLootedItemId = 'naturally-looted-sword-item-id';
characterItemRepository.rows.push({
id: naturallyLootedItemId,
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
});
await seedVisibleVerticalSlice(dataSource);
expect(characterItemRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
slot: 'WEAPON',
characterItemId: naturallyLootedItemId,
}),
);
});
}); });

View File

@@ -1,6 +1,13 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants'; import {
DEMO_CHARACTER_ID,
DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
} from '../../demo/demo-character.constants';
import { Character } from '../../characters/entities/character.entity'; import { Character } from '../../characters/entities/character.entity';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { EquipmentSlot } from '../../items/equipment-slot.enum';
import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity'; import { LootTable } from '../../loot/entities/loot-table.entity';
@@ -9,16 +16,23 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
ITEM_IDS,
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
} from './item.constants'; } from './item.constants';
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content'; import {
BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content';
import { import {
ASH_RAT_MONSTER_ID, ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID, BURNED_ROAD_ID,
CHARRED_LOOTER_MONSTER_ID,
ROAD_BANDIT_MONSTER_ID, ROAD_BANDIT_MONSTER_ID,
SOUTH_GATE_ID, SOUTH_GATE_ID,
WILD_ROAD_DOG_MONSTER_ID,
} from './vertical-slice.constants'; } from './vertical-slice.constants';
export async function seedVisibleVerticalSlice( export async function seedVisibleVerticalSlice(
@@ -47,6 +61,7 @@ export async function seedVisibleVerticalSlice(
isSafe: true, isSafe: true,
huntingEnabled: false, huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png', artworkPath: '/images/backgrounds/Suedtor.png',
...SOUTH_GATE_LOCAL_CONTENT,
}, },
{ {
id: BURNED_ROAD_ID, id: BURNED_ROAD_ID,
@@ -61,17 +76,14 @@ export async function seedVisibleVerticalSlice(
isSafe: false, isSafe: false,
huntingEnabled: true, huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png', artworkPath: '/images/backgrounds/Aschestrasse.png',
...BURNED_ROAD_LOCAL_CONTENT,
}, },
]; ];
let southGateId = SOUTH_GATE_ID;
let burnedRoadId = BURNED_ROAD_ID;
const locationIds = new Map<string, string>();
for (const location of locations) { for (const location of locations) {
const existing = await locationRepository.findOneBy({ const existing = await locationRepository.findOneBy({ key: location.key });
key: location.key,
});
const { id, key, ...definition } = location; const { id, key, ...definition } = location;
const persistedId = existing?.id ?? id;
if (existing) { if (existing) {
await locationRepository.update(existing.id, definition); await locationRepository.update(existing.id, definition);
@@ -79,13 +91,12 @@ export async function seedVisibleVerticalSlice(
await locationRepository.insert(location); await locationRepository.insert(location);
} }
if (key === 'south-gate') { locationIds.set(key, existing?.id ?? id);
southGateId = persistedId;
} else {
burnedRoadId = persistedId;
}
} }
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
await connectionRepository.upsert( await connectionRepository.upsert(
[ [
{ {
@@ -128,6 +139,24 @@ export async function seedVisibleVerticalSlice(
silverMin: 4, silverMin: 4,
silverMax: 7, silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
iconPath: '/images/combat/icons/ash-rat-128.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
},
{
id: WILD_ROAD_DOG_MONSTER_ID,
key: 'wild-road-dog',
name: 'Verwilderter Straßenhund',
level: 1,
maxHp: 55,
attack: 7,
armor: 0,
experienceReward: 11,
silverMin: 5,
silverMax: 9,
artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png',
// Shares the beast table: both are scorched road animals that leave a
// pelt behind. A table of its own waits for content that differs.
lootTableId: ASH_RAT_LOOT_TABLE_ID, lootTableId: ASH_RAT_LOOT_TABLE_ID,
}, },
{ {
@@ -142,18 +171,33 @@ export async function seedVisibleVerticalSlice(
silverMin: 9, silverMin: 9,
silverMax: 15, silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png', artworkPath: '/images/monsters/road-bandit.png',
iconPath: '/images/combat/icons/road-bandit-128.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},
{
id: CHARRED_LOOTER_MONSTER_ID,
key: 'charred-looter',
name: 'Verkohlter Plünderer',
level: 2,
maxHp: 85,
attack: 11,
armor: 6,
experienceReward: 20,
silverMin: 12,
silverMax: 19,
artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/combat/icons/charred-looter-128.png',
// Shares the raider table: same gear, taken from the same caravans.
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
}, },
]; ];
let ashRatId = ASH_RAT_MONSTER_ID;
let roadBanditId = ROAD_BANDIT_MONSTER_ID;
const monsterIds = new Map<string, string>();
for (const monster of monsters) { for (const monster of monsters) {
const existingMonster = await monsterRepository.findOneBy({ const existingMonster = await monsterRepository.findOneBy({
key: monster.key, key: monster.key,
}); });
const { id, key, ...definition } = monster; const { id, key, ...definition } = monster;
const persistedId = existingMonster?.id ?? id;
if (existingMonster) { if (existingMonster) {
await monsterRepository.update(existingMonster.id, definition); await monsterRepository.update(existingMonster.id, definition);
@@ -161,30 +205,27 @@ export async function seedVisibleVerticalSlice(
await monsterRepository.insert(monster); await monsterRepository.insert(monster);
} }
if (key === 'ash-rat') { monsterIds.set(key, existingMonster?.id ?? id);
ashRatId = persistedId;
} else {
roadBanditId = persistedId;
}
} }
// Weights read as "how often you meet this on the road". They also drive the
// location's danger rating, which is computed from the weighted average of
// the pool rather than from its single worst entry.
const encounterWeights: Readonly<Record<string, number>> = {
'ash-rat': 40,
'wild-road-dog': 30,
'road-bandit': 20,
'charred-looter': 10,
};
await locationMonsterRepository.upsert( await locationMonsterRepository.upsert(
[ Object.entries(encounterWeights).map(([key, weight]) => ({
{
locationId: burnedRoadId, locationId: burnedRoadId,
monsterId: ashRatId, monsterId: monsterIds.get(key) as string,
weight: 70, weight,
encounterType: EncounterType.NORMAL, encounterType: EncounterType.NORMAL,
enabled: true, enabled: true,
}, })),
{
locationId: burnedRoadId,
monsterId: roadBanditId,
weight: 30,
encounterType: EncounterType.NORMAL,
enabled: true,
},
],
['locationId', 'monsterId'], ['locationId', 'monsterId'],
); );
@@ -202,7 +243,46 @@ export async function seedVisibleVerticalSlice(
baseHp: 100, baseHp: 100,
baseAttack: 6, baseAttack: 6,
currentHp: 100, currentHp: 100,
hpRegenSince: new Date(),
currentLocationId: southGateId, currentLocationId: southGateId,
}); });
} }
const characterItemRepository = dataSource.getRepository(CharacterItem);
const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment);
// Starting loadout is weapon-only -- no starter armor piece exists in
// content yet -- so the demo character's effective armor (sum of equipped
// bonusArmor) is 0 until the player loots and equips bandit-hood (+3
// armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19
// says to preserve the existing demo balance "as closely as the
// implemented content allows" and explicitly forbids fabricating a full
// starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6.
const existingStartingSword = await characterItemRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
});
const startingSwordItemId =
existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID;
if (!existingStartingSword) {
await characterItemRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
});
}
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
});
if (!existingWeaponEquipment) {
await characterEquipmentRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: startingSwordItemId,
});
}
} }

View File

@@ -1 +1,5 @@
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID =
'10000000-0000-4000-8000-000000000002';
export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID =
'10000000-0000-4000-8000-000000000003';

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class EquipItemDto {
@IsUUID()
characterItemId!: string;
}

View File

@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { EquipmentSlot } from '../../items/equipment-slot.enum';
/**
* One equipped item in one slot for one character (spec §12).
*
* `characterItemId` must belong to `characterId` — enforced by
* `EquipmentService`, never by the client (spec §13).
*/
@Entity({ name: 'character_equipment' })
@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], {
unique: true,
})
@Index('IDX_character_equipment_character_item', ['characterItemId'], {
unique: true,
})
export class CharacterEquipment {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({
name: 'slot',
type: 'enum',
enum: EquipmentSlot,
enumName: 'equipment_slot_enum',
})
slot!: EquipmentSlot;
@Column({ name: 'character_item_id', type: 'uuid' })
characterItemId!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => CharacterItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_item_id' })
characterItem!: CharacterItem;
}

View File

@@ -0,0 +1,19 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { EquipItemDto } from './dto/equip-item.dto';
import { EquipmentResponseDto, EquipmentService } from './equipment.service';
@Controller('equipment')
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
@Get()
getEquipment(): Promise<EquipmentResponseDto> {
return this.equipmentService.getEquipment(DEMO_CHARACTER_ID);
}
@Post()
equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId);
}
}

View File

@@ -0,0 +1,71 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type EquipmentErrorCode =
| 'CHARACTER_ITEM_NOT_FOUND'
| 'ITEM_NOT_OWNED'
| 'ITEM_NOT_EQUIPPABLE'
| 'ITEM_LEVEL_REQUIREMENT_NOT_MET'
| 'INVALID_EQUIPMENT_SLOT'
| 'CHARACTER_IN_COMBAT';
export class EquipmentDomainError extends HttpException {
constructor(
public readonly code: EquipmentErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function characterItemNotFound(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_ITEM_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This item could not be found.',
);
}
export function itemNotOwned(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_OWNED',
HttpStatus.FORBIDDEN,
'This item does not belong to the character.',
);
}
export function itemNotEquippable(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_EQUIPPABLE',
HttpStatus.BAD_REQUEST,
'This item cannot be equipped.',
);
}
export function itemLevelRequirementNotMet(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
HttpStatus.BAD_REQUEST,
"The character does not meet this item's level requirement.",
);
}
// Defensive: slot is always derived from the item definition server-side, so
// this is unreachable in practice (spec §27 still names it explicitly).
export function invalidEquipmentSlot(): EquipmentDomainError {
return new EquipmentDomainError(
'INVALID_EQUIPMENT_SLOT',
HttpStatus.BAD_REQUEST,
'This item does not target a valid equipment slot.',
);
}
export function characterInCombat(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_IN_COMBAT',
HttpStatus.CONFLICT,
'Equipment cannot be changed during an active combat.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersModule } from '../characters/characters.module';
import { Character } from '../characters/entities/character.entity';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]),
CharactersModule,
],
controllers: [EquipmentController],
providers: [EquipmentService],
exports: [EquipmentService],
})
export class EquipmentModule {}

View File

@@ -0,0 +1,475 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentDomainError } from './equipment.errors';
import { EquipmentService } from './equipment.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
const BANDIT_HOOD_ITEM_ID = '70000000-0000-4000-8000-000000000003';
interface State {
characters: Character[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
combats: Combat[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: State,
private readonly target: EntityTarget<T>,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
}
find(options: { where: Partial<T>; relations?: Record<string, unknown> }): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where));
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private withRelations(row: T, relations?: Record<string, unknown>): T {
if (!relations) {
return row;
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
copy['characterItem'] = characterItem
? {
...characterItem,
itemDefinition: this.state.itemDefinitions.find(
(d) => d.id === characterItem.itemDefinitionId,
),
}
: undefined;
}
return copy as T;
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
if (this.target === Combat) return this.state.combats as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
}
}
class FakeDataSource {
private readonly idCounters = new Map<string, number>();
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, this);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
} as unknown as EntityManager);
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
return {
id: 'def-worn-sword',
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: '',
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
rarity: ItemRarity.COMMON,
tier: 1,
requiredLevel: 1,
weaponDamage: 8,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
sellPrice: 0,
iconPath: '/images/items/worn-short-sword.png',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as ItemDefinition;
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
hpRegenSince: null,
...overrides,
} as Character;
}
function createHarness(state: Partial<State> = {}) {
const fullState: State = {
characters: [character()],
itemDefinitions: [],
characterItems: [],
characterEquipment: [],
combats: [],
...state,
};
const dataSource = new FakeDataSource(fullState);
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
});
const characterStats = new CharacterStatsService(
dataSource as unknown as DataSource,
characterVitals,
);
const service = new EquipmentService(
dataSource as unknown as DataSource,
characterStats,
characterVitals,
);
return { state: fullState, service };
}
async function expectEquipmentDomainError(promise: Promise<unknown>, code: string): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(EquipmentDomainError);
if (!(error instanceof EquipmentDomainError)) {
throw new Error('Expected EquipmentDomainError');
}
expect(error.code).toBe(code);
}
describe('EquipmentService', () => {
describe('equip', () => {
it('equips an owned weapon into the WEAPON slot', async () => {
const wornSword = itemDefinition();
const { state, service } = createHarness({
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
});
const result = await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
expect(result.slots.WEAPON).toEqual({
characterItemId: WORN_SWORD_ITEM_ID,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
iconPath: '/images/items/worn-short-sword.png',
},
});
expect(state.characterEquipment).toHaveLength(1);
});
it('replaces the equipped weapon without deleting the old CharacterItem', async () => {
const wornSword = itemDefinition();
const banditBlade = itemDefinition({
id: 'def-bandit-blade',
key: 'bandit-blade',
name: 'Räuberklinge',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
});
const { state, service } = createHarness({
itemDefinitions: [wornSword, banditBlade],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: banditBlade.id,
quantity: 1,
} as CharacterItem,
],
characterEquipment: [
{
id: 'equip-1',
characterId: CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: WORN_SWORD_ITEM_ID,
} as CharacterEquipment,
],
});
const result = await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined();
});
it('rejects equipping an item owned by a different character', async () => {
const wornSword = itemDefinition();
const { service } = createHarness({
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: OTHER_CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
'ITEM_NOT_OWNED',
);
});
it('rejects equipping an unknown CharacterItem id', async () => {
const { service } = createHarness();
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, 'unknown-item'),
'CHARACTER_ITEM_NOT_FOUND',
);
});
it('rejects equipping an item above the character level', async () => {
const highLevelHelm = itemDefinition({
id: BANDIT_HOOD_ITEM_ID,
key: 'bandit-hood',
equipmentSlot: EquipmentSlot.HEAD,
requiredLevel: 5,
});
const { service } = createHarness({
itemDefinitions: [highLevelHelm],
characterItems: [
{
id: BANDIT_HOOD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: highLevelHelm.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID),
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
);
});
it('rejects equipping a non-equippable item', async () => {
const material = itemDefinition({
id: 'def-ash-pelt',
key: 'ash-pelt',
type: ItemType.MATERIAL,
equipmentSlot: null,
});
const { service } = createHarness({
itemDefinitions: [material],
characterItems: [
{
id: 'item-ash-pelt',
characterId: CHARACTER_ID,
itemDefinitionId: material.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, 'item-ash-pelt'),
'ITEM_NOT_EQUIPPABLE',
);
});
it('never produces two equipped weapons when the same slot is equipped repeatedly', async () => {
const wornSword = itemDefinition();
const banditBlade = itemDefinition({
id: 'def-bandit-blade',
key: 'bandit-blade',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
});
const { state, service } = createHarness({
itemDefinitions: [wornSword, banditBlade],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: banditBlade.id,
quantity: 1,
} as CharacterItem,
],
});
// Sequential repeats stand in for the concurrent case here (a real race
// is guarded by the DB's UNIQUE(character_id, slot) constraint from
// Task 1, which a synchronous fake repository cannot exercise).
await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
});
it('rejects equipping while the character has an active combat', async () => {
const wornSword = itemDefinition();
const { service } = createHarness({
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
'CHARACTER_IN_COMBAT',
);
});
it('re-anchors HP regeneration so a later max-HP increase does not gift accumulated overflow', async () => {
const bonusHpHelm = itemDefinition({
id: 'def-bonus-hp-helm',
key: 'bonus-hp-helm',
name: 'Gepolsterter Helm',
equipmentSlot: EquipmentSlot.HEAD,
bonusHp: 20,
weaponDamage: 0,
});
const { state, service } = createHarness({
characters: [
character({
currentHp: 90,
hpRegenSince: new Date('2026-08-18T08:50:00.000Z'),
}),
],
itemDefinitions: [bonusHpHelm],
characterItems: [
{
id: BANDIT_HOOD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: bonusHpHelm.id,
quantity: 1,
} as CharacterItem,
],
});
await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID);
expect(state.characters[0].currentHp).toBe(100);
});
});
describe('getEquipment', () => {
it('returns empty slots and base stats when nothing is equipped', async () => {
const { service } = createHarness();
const result = await service.getEquipment(CHARACTER_ID);
expect(result.slots).toEqual({
WEAPON: null,
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
});
expect(result.stats).toEqual({ maxHp: 100, attack: 6, weaponDamage: 0, armor: 0 });
});
});
});

View File

@@ -0,0 +1,174 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import {
characterInCombat,
characterItemNotFound,
characterNotFound,
itemLevelRequirementNotMet,
itemNotEquippable,
itemNotOwned,
} from './equipment.errors';
export interface EquipmentSlotItemDto {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
}
export type EquipmentSlotsDto = Record<EquipmentSlot, EquipmentSlotItemDto | null>;
export interface EquipmentStatsDto {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
export interface EquipmentResponseDto {
slots: EquipmentSlotsDto;
stats: EquipmentStatsDto;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
@Injectable()
export class EquipmentService {
constructor(
private readonly dataSource: DataSource,
private readonly characterStats: CharacterStatsService,
private readonly characterVitals: CharacterVitalsService,
) {}
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
const character = await this.dataSource
.getRepository(Character)
.findOneBy({ id: characterId });
if (!character) {
throw characterNotFound();
}
return this.buildResponse(character, this.dataSource);
}
/**
* Equips (or replaces) one slot for `characterId` with `characterItemId`
* (spec §14, §28). Runs in one transaction: the old item is unequipped by
* being overwritten, never deleted (spec §15).
*/
async equip(characterId: string, characterItemId: string): Promise<EquipmentResponseDto> {
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const combats = manager.getRepository(Combat);
const characterItems = manager.getRepository(CharacterItem);
const equipmentRepo = manager.getRepository(CharacterEquipment);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const activeCombat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
});
if (activeCombat) {
throw characterInCombat();
}
const characterItem = await characterItems.findOne({
where: { id: characterItemId },
relations: { itemDefinition: true },
});
if (!characterItem) {
throw characterItemNotFound();
}
if (characterItem.characterId !== characterId) {
throw itemNotOwned();
}
const definition = characterItem.itemDefinition;
if (!definition.equipmentSlot) {
throw itemNotEquippable();
}
if (definition.requiredLevel > character.level) {
throw itemLevelRequirementNotMet();
}
const statsBeforeChange = await this.characterStats.calculate(character, manager);
this.characterVitals.settle(character, statsBeforeChange.maxHp);
await characters.save(character);
const existing = await equipmentRepo.findOne({
where: { characterId, slot: definition.equipmentSlot },
lock: { mode: 'pessimistic_write' },
});
if (existing) {
existing.characterItemId = characterItem.id;
await equipmentRepo.save(existing);
} else {
await equipmentRepo.save(
equipmentRepo.create({
characterId,
slot: definition.equipmentSlot,
characterItemId: characterItem.id,
}),
);
}
return this.buildResponse(character, manager);
});
}
private async buildResponse(
character: Character,
scope: RepositoryScope,
): Promise<EquipmentResponseDto> {
const equipped = await scope.getRepository(CharacterEquipment).find({
where: { characterId: character.id },
relations: { characterItem: { itemDefinition: true } },
});
const slots = Object.fromEntries(
Object.values(EquipmentSlot).map((slot) => [slot, null]),
) as EquipmentSlotsDto;
for (const row of equipped) {
const definition = row.characterItem.itemDefinition;
slots[row.slot] = {
characterItemId: row.characterItemId,
item: {
key: definition.key,
name: definition.name,
rarity: definition.rarity,
iconPath: definition.iconPath,
},
};
}
const stats = await this.characterStats.calculate(character, scope);
return {
slots,
stats: {
maxHp: stats.maxHp,
attack: stats.attack,
weaponDamage: stats.weaponDamage,
armor: stats.armor,
},
};
}
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { InventoryResponseDto, InventoryService } from './inventory.service';
@Controller('inventory')
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
@Get()
getInventory(): Promise<InventoryResponseDto> {
return this.inventoryService.getInventory(DEMO_CHARACTER_ID);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
@Module({
imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])],
controllers: [InventoryController],
providers: [InventoryService],
})
export class InventoryModule {}

View File

@@ -0,0 +1,99 @@
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { InventoryService } from './inventory.service';
const CHARACTER_ID = 'character-1';
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
return {
id: 'item-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'def-1',
quantity: 1,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
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.',
rarity: ItemRarity.COMMON,
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
...overrides,
} as CharacterItem;
}
describe('InventoryService', () => {
it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => {
const items = [
characterItem({ id: 'item-1', quantity: 1 }),
characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }),
];
const characterItems = {
find: jest.fn().mockResolvedValue(items),
} as unknown as Repository<CharacterItem>;
const equipment = {
find: jest.fn().mockResolvedValue([
{ characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment,
]),
} as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment);
const result = await service.getInventory(CHARACTER_ID);
expect(characterItems.find).toHaveBeenCalledWith({
where: { characterId: CHARACTER_ID },
relations: { itemDefinition: true },
order: { createdAt: 'ASC' },
});
expect(result.items).toEqual([
{
id: 'item-1',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-2',
quantity: 3,
equipped: false,
item: expect.objectContaining({ key: 'worn-short-sword' }),
},
]);
});
it('returns an empty list when the character owns nothing', async () => {
const characterItems = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<CharacterItem>;
const equipment = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment);
await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] });
});
});

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
export interface InventoryItemDto {
id: string;
quantity: number;
equipped: boolean;
item: {
key: string;
name: string;
description: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;
weaponDamage: number;
bonusAttack: number;
bonusHp: number;
bonusArmor: number;
iconPath: string;
};
}
export interface InventoryResponseDto {
items: InventoryItemDto[];
}
@Injectable()
export class InventoryService {
constructor(
@InjectRepository(CharacterItem)
private readonly characterItems: Repository<CharacterItem>,
@InjectRepository(CharacterEquipment)
private readonly equipment: Repository<CharacterEquipment>,
) {}
async getInventory(characterId: string): Promise<InventoryResponseDto> {
const [items, equipped] = await Promise.all([
this.characterItems.find({
where: { characterId },
relations: { itemDefinition: true },
order: { createdAt: 'ASC' },
}),
this.equipment.find({ where: { characterId } }),
]);
const equippedIds = new Set(equipped.map((row) => row.characterItemId));
return {
items: items.map((characterItem) => ({
id: characterItem.id,
quantity: characterItem.quantity,
equipped: equippedIds.has(characterItem.id),
item: {
key: characterItem.itemDefinition.key,
name: characterItem.itemDefinition.name,
description: characterItem.itemDefinition.description,
rarity: characterItem.itemDefinition.rarity,
equipmentSlot: characterItem.itemDefinition.equipmentSlot,
requiredLevel: characterItem.itemDefinition.requiredLevel,
weaponDamage: characterItem.itemDefinition.weaponDamage,
bonusAttack: characterItem.itemDefinition.bonusAttack,
bonusHp: characterItem.itemDefinition.bonusHp,
bonusArmor: characterItem.itemDefinition.bonusArmor,
iconPath: characterItem.itemDefinition.iconPath,
},
})),
};
}
}

View File

@@ -46,6 +46,12 @@ export class MonsterDefinition {
@Column({ name: 'artwork_path', type: 'varchar', length: 255 }) @Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string; artworkPath!: string;
// Round medallion portrait used wherever a monster appears at icon size
// (encounter preview in the local location view). `artworkPath` stays the
// wide combat/hunt portrait.
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
iconPath!: string;
@Column({ name: 'loot_table_id', type: 'uuid', nullable: true }) @Column({ name: 'loot_table_id', type: 'uuid', nullable: true })
lootTableId!: string | null; lootTableId!: string | null;

View File

@@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity';
import { CLOCK, systemClock } from './clock'; import { CLOCK, systemClock } from '../shared/clock';
import { Travel } from './entities/travel.entity'; import { Travel } from './entities/travel.entity';
import { TravelController } from './travel.controller'; import { TravelController } from './travel.controller';
import { TravelService } from './travel.service'; import { TravelService } from './travel.service';

View File

@@ -6,7 +6,7 @@ import {
} from '../database/seeds/vertical-slice.constants'; } from '../database/seeds/vertical-slice.constants';
import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity';
import { Clock } from './clock'; import { Clock } from '../shared/clock';
import { Travel } from './entities/travel.entity'; import { Travel } from './entities/travel.entity';
import { TravelDomainError } from './travel.errors'; import { TravelDomainError } from './travel.errors';
import { TravelService } from './travel.service'; import { TravelService } from './travel.service';

View File

@@ -3,8 +3,8 @@ import { DataSource, Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity';
import { CLOCK } from './clock'; import { CLOCK } from '../shared/clock';
import type { Clock } from './clock'; import type { Clock } from '../shared/clock';
import { Travel } from './entities/travel.entity'; import { Travel } from './entities/travel.entity';
import { import {
characterNotFound, characterNotFound,

View File

@@ -8,6 +8,12 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../../characters/entities/character.entity'; import { Character } from '../../characters/entities/character.entity';
import type {
LocationPointOfInterestContent,
LocationPrimaryActionContent,
LocationRewardPreviewContent,
LocationType,
} from '../local-location.types';
import { LocationConnection } from './location-connection.entity'; import { LocationConnection } from './location-connection.entity';
@Entity({ name: 'location_definitions' }) @Entity({ name: 'location_definitions' })
@@ -46,6 +52,35 @@ export class LocationDefinition {
@Column({ name: 'artwork_path', type: 'varchar', length: 255 }) @Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string; artworkPath!: string;
// --- Local location view (spec §4, §10) -----------------------------------
// `description`/`artworkPath` above stay untouched: the map and the hunt
// screen keep rendering them. The `local*` columns below feed the local
// location view, which needs a longer scene description and a wide artwork.
@Column({ name: 'region_name', type: 'varchar', length: 150 })
regionName!: string;
@Column({ name: 'region_tier_label', type: 'varchar', length: 50 })
regionTierLabel!: string;
@Column({ name: 'location_type', type: 'varchar', length: 50 })
locationType!: LocationType;
@Column({ name: 'local_description', type: 'text' })
localDescription!: string;
@Column({ name: 'local_artwork_path', type: 'varchar', length: 255 })
localArtworkPath!: string;
@Column({ name: 'local_points_of_interest', type: 'jsonb' })
localPointsOfInterest!: LocationPointOfInterestContent[];
@Column({ name: 'local_primary_actions', type: 'jsonb' })
localPrimaryActions!: LocationPrimaryActionContent[];
@Column({ name: 'local_reward_preview', type: 'jsonb' })
localRewardPreview!: LocationRewardPreviewContent[];
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;

View File

@@ -0,0 +1,160 @@
import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import {
BURNED_ROAD_ID,
SOUTH_GATE_ID,
} from '../database/seeds/vertical-slice.constants';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity';
import type { LocationPointOfInterestContent } from './local-location.types';
import { WorldDomainError } from './world.errors';
import { WorldService } from './world.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
{
key: 'hunt-area',
title: 'Jagdgebiet',
actionLabel: 'Jagd beginnen',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 52,
yPercent: 44,
enabled: true,
},
{
key: 'inspect-tracks',
title: 'Verdächtige Spuren',
actionLabel: 'Untersuchen',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 32,
yPercent: 78,
enabled: true,
resultTitle: 'Verdächtige Spuren',
resultText:
'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.',
},
{
key: 'sealed-crypt',
title: 'Versiegelte Krypta',
type: 'DUNGEON',
iconKey: 'search',
xPercent: 90,
yPercent: 20,
enabled: false,
resultTitle: 'Versiegelte Krypta',
resultText: 'Noch verschlossen.',
},
];
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
{
key: 'gate-watch',
title: 'Torwache',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
yPercent: 52,
enabled: true,
resultTitle: 'Torwache',
resultText: 'Nur am Südtor zu hören.',
},
];
function location(
id: string,
pointsOfInterest: LocationPointOfInterestContent[],
): LocationDefinition {
return {
id,
localPointsOfInterest: pointsOfInterest,
} as LocationDefinition;
}
function createService(
currentLocation: LocationDefinition,
completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' }),
) {
return new WorldService(
{ completeTravelIfDue } as unknown as TravelService,
{
findOne: jest.fn().mockResolvedValue({
id: CHARACTER_ID,
currentLocationId: currentLocation.id,
currentLocation,
}),
} as unknown as Repository<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
);
}
async function expectRejected(promise: Promise<unknown>): Promise<void> {
await expect(promise).rejects.toBeInstanceOf(WorldDomainError);
await expect(promise).rejects.toMatchObject({
code: 'LOCATION_INTERACTION_UNAVAILABLE',
});
}
describe('WorldService.runLocalInteraction', () => {
it('returns the authored result of an enabled interaction at the current location', async () => {
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
await expect(
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.',
});
});
it('settles travel before resolving which location the character stands at', async () => {
const completeTravelIfDue = jest
.fn()
.mockResolvedValue({ status: 'IDLE' });
const service = createService(
location(BURNED_ROAD_ID, BURNED_ROAD_POIS),
completeTravelIfDue,
);
await service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks');
expect(completeTravelIfDue).toHaveBeenCalledWith(CHARACTER_ID);
});
it('rejects an interaction that belongs to a different location', async () => {
const service = createService(location(SOUTH_GATE_ID, SOUTH_GATE_POIS));
await expectRejected(
service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
);
});
it('rejects an unknown interaction key', async () => {
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
await expectRejected(
service.runLocalInteraction(CHARACTER_ID, 'open-vault'),
);
});
it('rejects a disabled interaction', async () => {
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
await expectRejected(
service.runLocalInteraction(CHARACTER_ID, 'sealed-crypt'),
);
});
it('rejects a navigation hotspot that has no result to reveal', async () => {
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
await expectRejected(service.runLocalInteraction(CHARACTER_ID, 'hunt-area'));
});
});

View File

@@ -0,0 +1,126 @@
/**
* Content and transport types for the local location view (spec §10).
*
* A location's local presentation is content, not code: points of interest,
* primary actions and the reward preview are stored as JSONB on
* `LocationDefinition` so a new location renders through the same components
* by supplying different data.
*/
export type LocationInteractionType =
| 'HUNT'
| 'INVESTIGATE'
| 'SEARCH'
| 'NPC'
| 'MAP'
| 'TRAVEL'
| 'SHOP'
| 'QUEST'
| 'BOSS'
| 'DUNGEON';
export type LocationType =
| 'SAFE_HUB'
| 'TRANSITION'
| 'HUNTING_GROUND'
| 'QUEST_LOCATION'
| 'OUTPOST'
| 'ELITE_ZONE'
| 'BOSS_LOCATION'
| 'DUNGEON_ENTRANCE';
/**
* Stored shape of a point of interest. `resultTitle`/`resultText` never leave
* the server through the location payload — they are revealed only by the
* interaction endpoint, which first verifies the character actually stands
* here (plan §5).
*/
export interface LocationPointOfInterestContent {
key: string;
title: string;
actionLabel?: string;
type: LocationInteractionType;
iconKey: string;
xPercent: number;
yPercent: number;
enabled: boolean;
resultTitle?: string;
resultText?: string;
}
/**
* Stored shape of a primary action. Interaction-backed actions carry `poiKey`
* instead of their own result text, so a POI and the action bar entry that
* duplicates it can never drift apart.
*/
export interface LocationPrimaryActionContent {
key: string;
label: string;
description?: string;
type: LocationInteractionType;
iconKey: string;
enabled: boolean;
poiKey?: string;
}
export interface LocationRewardPreviewContent {
key: string;
label: string;
iconKey: string;
}
export interface LocalLocationPointOfInterestDto {
key: string;
title: string;
actionLabel?: string;
type: LocationInteractionType;
iconKey: string;
xPercent: number;
yPercent: number;
enabled: boolean;
}
export interface LocalLocationPrimaryActionDto {
key: string;
label: string;
description?: string;
type: LocationInteractionType;
iconKey: string;
enabled: boolean;
poiKey?: string;
}
export interface EncounterPreviewDto {
key: string;
name: string;
level: number;
iconPath: string;
}
export interface RewardPreviewDto {
key: string;
label: string;
iconKey: string;
}
export interface LocationInteractionResultDto {
interactionKey: string;
title: string;
text: string;
}
/** Strips server-only result text before a POI is sent to the client. */
export function toPointOfInterestDto(
poi: LocationPointOfInterestContent,
): LocalLocationPointOfInterestDto {
return {
key: poi.key,
title: poi.title,
...(poi.actionLabel === undefined ? {} : { actionLabel: poi.actionLabel }),
type: poi.type,
iconKey: poi.iconKey,
xPercent: poi.xPercent,
yPercent: poi.yPercent,
enabled: poi.enabled,
};
}

View File

@@ -0,0 +1,35 @@
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { WorldController } from './world.controller';
import { WorldService } from './world.service';
describe('WorldController', () => {
it('resolves the current location for the acting character', () => {
const getCurrentLocation = jest.fn().mockResolvedValue({ key: 'burned-road' });
const controller = new WorldController({
getCurrentLocation,
} as unknown as WorldService);
void controller.getCurrentLocation();
expect(getCurrentLocation).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
});
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.',
});
const controller = new WorldController({
runLocalInteraction,
} as unknown as WorldService);
void controller.runLocalInteraction('inspect-tracks');
expect(runLocalInteraction).toHaveBeenCalledWith(
DEMO_CHARACTER_ID,
'inspect-tracks',
);
expect(runLocalInteraction).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { LocationInteractionResultDto } from './local-location.types';
import { WorldService } from './world.service'; import { WorldService } from './world.service';
@Controller('world') @Controller('world')
@@ -10,4 +11,19 @@ export class WorldController {
getCurrentLocation() { getCurrentLocation() {
return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID); return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID);
} }
/**
* The interaction is addressed by key alone. There is deliberately no
* location parameter: the server resolves the location from the character,
* so the route cannot be pointed at somewhere the player is not.
*/
@Post('current-location/interactions/:interactionKey')
runLocalInteraction(
@Param('interactionKey') interactionKey: string,
): Promise<LocationInteractionResultDto> {
return this.worldService.runLocalInteraction(
DEMO_CHARACTER_ID,
interactionKey,
);
}
} }

View File

@@ -0,0 +1,25 @@
import { HttpException } from '@nestjs/common';
export class WorldDomainError extends HttpException {
constructor(
public readonly code: string,
status: number,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
/**
* Raised for any interaction key the current location does not offer — an
* unknown key, a key belonging to another location, or one that is disabled.
* They share a code on purpose: the client learns "not here", not which of the
* three it was.
*/
export function locationInteractionUnavailable(): WorldDomainError {
return new WorldDomainError(
'LOCATION_INTERACTION_UNAVAILABLE',
400,
'This interaction is not available at the current location.',
);
}

View File

@@ -9,10 +9,122 @@ import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service'; import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity'; import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity'; import { LocationDefinition } from './entities/location-definition.entity';
import type {
LocationPointOfInterestContent,
LocationPrimaryActionContent,
} from './local-location.types';
import { WorldService } from './world.service'; import { WorldService } from './world.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
{
key: 'gate-watch',
title: 'Torwache',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
yPercent: 52,
enabled: true,
resultTitle: 'Torwache',
resultText: 'Geheimer Servertext.',
},
];
const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
{
key: 'hunt-area',
title: 'Jagdgebiet',
actionLabel: 'Jagd beginnen',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 52,
yPercent: 44,
enabled: true,
},
{
key: 'inspect-tracks',
title: 'Verdächtige Spuren',
actionLabel: 'Untersuchen',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 32,
yPercent: 78,
enabled: true,
resultTitle: 'Verdächtige Spuren',
resultText: 'Frische Stiefelabdrücke führen nach Osten.',
},
{
key: 'sealed-crypt',
title: 'Versiegelte Krypta',
actionLabel: 'Öffnen',
type: 'DUNGEON',
iconKey: 'search',
xPercent: 90,
yPercent: 20,
enabled: false,
resultTitle: 'Versiegelte Krypta',
resultText: 'Noch verschlossen.',
},
];
const BURNED_ROAD_ACTIONS: LocationPrimaryActionContent[] = [
{
key: 'start-hunt',
label: 'Jagd beginnen',
description: 'Im Gebiet jagen',
type: 'HUNT',
iconKey: 'hunt',
enabled: true,
},
{
key: 'investigate-tracks',
label: 'Spuren untersuchen',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'inspect-tracks',
},
];
function character(locationId: string, location: LocationDefinition) {
return {
id: CHARACTER_ID,
baseAttack: 6,
baseHp: 100,
currentLocationId: locationId,
currentLocation: location,
};
}
function poolEntry(
name: string,
weight: number,
stats: { level: number; attack: number; armor: number; maxHp: number },
) {
return {
weight,
monster: {
key: name.toLowerCase(),
name,
level: stats.level,
attack: stats.attack,
armor: stats.armor,
maxHp: stats.maxHp,
iconPath: `/images/monsters/icons/${name.toLowerCase()}-128.png`,
},
};
}
// Ash rats are common and harmless, bandits rare and dangerous. Judged by its
// 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 }),
];
function currentLocation(): LocationDefinition { function currentLocation(): LocationDefinition {
return { return {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
@@ -27,6 +139,14 @@ 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',
regionTierLabel: 'Gebiet 1',
locationType: 'TRANSITION',
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
localArtworkPath: '/images/backgrounds/Suedtor.png',
localPointsOfInterest: SOUTH_GATE_POIS,
localPrimaryActions: [],
localRewardPreview: [],
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: [],
@@ -48,6 +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',
regionTierLabel: 'Gebiet 1',
locationType: 'HUNTING_GROUND',
localDescription: 'Ein alter Handelsweg, in Asche gelegt.',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
localPointsOfInterest: BURNED_ROAD_POIS,
localPrimaryActions: BURNED_ROAD_ACTIONS,
localRewardPreview: [{ key: 'silver', label: 'Silber', 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: [],
@@ -69,11 +197,7 @@ describe('WorldService', () => {
} as unknown as TravelService; } as unknown as TravelService;
const findCharacter = jest.fn().mockImplementation(() => { const findCharacter = jest.fn().mockImplementation(() => {
callOrder.push('findCharacter'); callOrder.push('findCharacter');
return Promise.resolve({ return Promise.resolve(character(SOUTH_GATE_ID, location));
id: CHARACTER_ID,
currentLocationId: SOUTH_GATE_ID,
currentLocation: location,
});
}); });
const characters = { const characters = {
findOne: findCharacter, findOne: findCharacter,
@@ -130,6 +254,28 @@ describe('WorldService', () => {
isSafe: true, isSafe: true,
huntingEnabled: false, huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp', artworkPath: '/assets/locations/south-gate.webp',
regionName: 'Aschenfelder',
regionTierLabel: 'Gebiet 1',
locationType: 'TRANSITION',
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
localArtworkPath: '/images/backgrounds/Suedtor.png',
dangerRating: null,
recommendationLabel: '1',
pointsOfInterest: [
{
key: 'gate-watch',
title: 'Torwache',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
yPercent: 52,
enabled: true,
},
],
primaryActions: [],
encounterPreview: [],
rewardPreview: [],
connections: [ connections: [
{ {
targetLocation: { targetLocation: {
@@ -160,21 +306,12 @@ describe('WorldService', () => {
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
} as unknown as TravelService; } as unknown as TravelService;
const characters = { const characters = {
findOne: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)),
id: CHARACTER_ID,
currentLocationId: BURNED_ROAD_ID,
currentLocation: location,
}),
} as unknown as Repository<Character>; } as unknown as Repository<Character>;
const connections = { const connections = {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>; } as unknown as Repository<LocationConnection>;
const findLocationMonsters = jest const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
.fn()
.mockResolvedValue([
{ monster: { name: 'Aschenratte' } },
{ monster: { name: 'Stra\u00dfenr\u00e4uber' } },
]);
const locationMonsters = { const locationMonsters = {
find: findLocationMonsters, find: findLocationMonsters,
} as unknown as Repository<LocationMonster>; } as unknown as Repository<LocationMonster>;
@@ -230,4 +367,131 @@ describe('WorldService', () => {
expect(findConnections).not.toHaveBeenCalled(); expect(findConnections).not.toHaveBeenCalled();
expect(findLocationMonsters).not.toHaveBeenCalled(); expect(findLocationMonsters).not.toHaveBeenCalled();
}); });
it('exposes the authored local view content of the current location', async () => {
const result = await loadBurnedRoad();
expect(result).toEqual(
expect.objectContaining({
regionName: 'Aschenfelder',
regionTierLabel: 'Gebiet 1',
locationType: 'HUNTING_GROUND',
localDescription: 'Ein alter Handelsweg, in Asche gelegt.',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
recommendationLabel: '12',
rewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }],
}),
);
expect(result.primaryActions).toEqual(BURNED_ROAD_ACTIONS);
});
it('renders a single recommended level without a range', async () => {
const result = await loadSouthGate();
expect(result.recommendationLabel).toBe('1');
});
it('serves every point of interest, including disabled ones, without leaking its result text', async () => {
const result = await loadBurnedRoad();
expect(result.pointsOfInterest).toEqual([
{
key: 'hunt-area',
title: 'Jagdgebiet',
actionLabel: 'Jagd beginnen',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 52,
yPercent: 44,
enabled: true,
},
{
key: 'inspect-tracks',
title: 'Verdächtige Spuren',
actionLabel: 'Untersuchen',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 32,
yPercent: 78,
enabled: true,
},
{
key: 'sealed-crypt',
title: 'Versiegelte Krypta',
actionLabel: 'Öffnen',
type: 'DUNGEON',
iconKey: 'search',
xPercent: 90,
yPercent: 20,
enabled: false,
},
]);
expect(JSON.stringify(result)).not.toContain('Frische Stiefelabdrücke');
expect(JSON.stringify(result)).not.toContain('Noch verschlossen');
});
it('derives the encounter preview from the location monster pool', async () => {
const result = await loadBurnedRoad();
expect(result.encounterPreview).toEqual([
{
key: 'aschenratte',
name: 'Aschenratte',
level: 1,
iconPath: '/images/monsters/icons/aschenratte-128.png',
},
{
key: 'straßenräuber',
name: 'Straßenräuber',
level: 2,
iconPath: '/images/monsters/icons/straßenräuber-128.png',
},
]);
});
it('rates local danger from the weighted pool average rather than its worst entry', async () => {
const result = await loadBurnedRoad();
// The lone bandit rates STRONG against this character; weighted by how
// rarely it appears, the road as a whole is a fair match.
expect(result.dangerRating).toBe('MATCH');
});
it('reports no danger rating where nothing can be hunted', async () => {
const result = await loadSouthGate();
expect(result.dangerRating).toBeNull();
expect(result.encounterPreview).toEqual([]);
});
}); });
async function loadBurnedRoad() {
return loadLocation(burnedRoad(), BURNED_ROAD_ID, BURNED_ROAD_POOL);
}
async function loadSouthGate() {
return loadLocation(currentLocation(), SOUTH_GATE_ID, []);
}
async function loadLocation(
location: LocationDefinition,
locationId: string,
pool: ReturnType<typeof poolEntry>[],
) {
const service = new WorldService(
{
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
} as unknown as TravelService,
{
findOne: jest.fn().mockResolvedValue(character(locationId, location)),
} as unknown as Repository<Character>,
{
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>,
{
find: jest.fn().mockResolvedValue(pool),
} as unknown as Repository<LocationMonster>,
);
return service.getCurrentLocation(CHARACTER_ID);
}

View File

@@ -2,9 +2,24 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import {
calculateDangerRating,
DangerRating,
} from '../hunting/danger-rating';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service'; import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity'; import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity';
import {
EncounterPreviewDto,
LocalLocationPointOfInterestDto,
LocalLocationPrimaryActionDto,
LocationInteractionResultDto,
LocationType,
RewardPreviewDto,
toPointOfInterestDto,
} from './local-location.types';
import { locationInteractionUnavailable } from './world.errors';
export interface LocationSummary { export interface LocationSummary {
id: string; id: string;
@@ -30,6 +45,18 @@ export interface CurrentLocationResponse {
isSafe: boolean; isSafe: boolean;
huntingEnabled: boolean; huntingEnabled: boolean;
artworkPath: string; artworkPath: string;
regionName: string;
regionTierLabel: string;
locationType: LocationType;
localDescription: string;
localArtworkPath: string;
/** `null` where nothing hostile can be met — the view reads that as safe. */
dangerRating: DangerRating | null;
recommendationLabel: string;
pointsOfInterest: LocalLocationPointOfInterestDto[];
primaryActions: LocalLocationPrimaryActionDto[];
encounterPreview: EncounterPreviewDto[];
rewardPreview: RewardPreviewDto[];
connections: CurrentLocationConnection[]; connections: CurrentLocationConnection[];
possibleMonsters: string[]; possibleMonsters: string[];
} }
@@ -49,15 +76,7 @@ export class WorldService {
async getCurrentLocation( async getCurrentLocation(
characterId: string, characterId: string,
): Promise<CurrentLocationResponse> { ): Promise<CurrentLocationResponse> {
await this.travelService.completeTravelIfDue(characterId); const character = await this.loadCharacterAtCurrentLocation(characterId);
const character = await this.characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
throw new NotFoundException('Character not found.');
}
const connections = await this.connections.find({ const connections = await this.connections.find({
where: { fromLocationId: character.currentLocationId, enabled: true }, where: { fromLocationId: character.currentLocationId, enabled: true },
@@ -65,8 +84,8 @@ export class WorldService {
}); });
const location = character.currentLocation; const location = character.currentLocation;
const possibleMonsters = location.huntingEnabled const pool = location.huntingEnabled
? await this.getPossibleMonsters(location.id) ? await this.getEncounterPool(location.id)
: []; : [];
return { return {
@@ -81,6 +100,24 @@ export class WorldService {
isSafe: location.isSafe, isSafe: location.isSafe,
huntingEnabled: location.huntingEnabled, huntingEnabled: location.huntingEnabled,
artworkPath: location.artworkPath, artworkPath: location.artworkPath,
regionName: location.regionName,
regionTierLabel: location.regionTierLabel,
locationType: location.locationType,
localDescription: location.localDescription,
localArtworkPath: location.localArtworkPath,
dangerRating: this.toLocalDangerRating(character, pool),
recommendationLabel: this.toRecommendationLabel(location),
pointsOfInterest: location.localPointsOfInterest.map(
toPointOfInterestDto,
),
primaryActions: location.localPrimaryActions,
encounterPreview: pool.map((entry) => ({
key: entry.monster.key,
name: entry.monster.name,
level: entry.monster.level,
iconPath: entry.monster.iconPath,
})),
rewardPreview: location.localRewardPreview,
connections: connections connections: connections
.filter((connection) => connection.enabled) .filter((connection) => connection.enabled)
.map((connection) => ({ .map((connection) => ({
@@ -92,17 +129,102 @@ export class WorldService {
travelDurationSeconds: connection.travelDurationSeconds, travelDurationSeconds: connection.travelDurationSeconds,
danger: this.toDangerRating(connection.ambushChance), danger: this.toDangerRating(connection.ambushChance),
})), })),
possibleMonsters, possibleMonsters: pool.map((entry) => entry.monster.name),
}; };
} }
private async getPossibleMonsters(locationId: string): Promise<string[]> { /**
const pool = await this.locationMonsters.find({ * Runs a short local interaction (investigate, search, talk) and reveals its
* authored result.
*
* The location is taken from the character, never from the request, so a
* caller cannot reach a hotspot it has not travelled to. Hotspots that only
* navigate (HUNT, MAP) carry no result text and are rejected here — the
* client routes those itself.
*/
async runLocalInteraction(
characterId: string,
interactionKey: string,
): Promise<LocationInteractionResultDto> {
const character = await this.loadCharacterAtCurrentLocation(characterId);
const poi = character.currentLocation.localPointsOfInterest.find(
(candidate) => candidate.key === interactionKey,
);
if (!poi?.enabled || !poi.resultText) {
throw locationInteractionUnavailable();
}
return {
interactionKey: poi.key,
title: poi.resultTitle ?? poi.title,
text: poi.resultText,
};
}
/**
* Loads the character together with the location it actually stands at.
*
* Every local interaction resolves through here, so a request can never name
* the location it wants to act on (plan §5).
*/
async loadCharacterAtCurrentLocation(
characterId: string,
): Promise<Character> {
await this.travelService.completeTravelIfDue(characterId);
const character = await this.characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
throw new NotFoundException('Character not found.');
}
return character;
}
private getEncounterPool(locationId: string): Promise<LocationMonster[]> {
return this.locationMonsters.find({
where: { locationId, enabled: true }, where: { locationId, enabled: true },
relations: { monster: true }, relations: { monster: true },
order: { weight: 'DESC' }, order: { weight: 'DESC' },
}); });
return pool.map((entry) => entry.monster.name); }
/**
* Rates the location by the encounter a traveller can typically expect: the
* pool's weight-averaged stats, not its single worst entry. A rare elite
* would otherwise make a beginner road read as lethal.
*/
private toLocalDangerRating(
character: Character,
pool: LocationMonster[],
): DangerRating | null {
const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0);
if (totalWeight === 0) {
return null;
}
const average = (pick: (entry: LocationMonster) => number) =>
pool.reduce((sum, entry) => sum + entry.weight * pick(entry), 0) /
totalWeight;
return calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{
attack: average((entry) => entry.monster.attack),
armor: average((entry) => entry.monster.armor),
hp: average((entry) => entry.monster.maxHp),
},
);
}
private toRecommendationLabel(location: LocationDefinition): string {
return location.minRecommendedLevel === location.maxRecommendedLevel
? `${location.minRecommendedLevel}`
: `${location.minRecommendedLevel}${location.maxRecommendedLevel}`;
} }
private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' { private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -2,11 +2,18 @@ import { Routes } from '@angular/router';
import { AppShellComponent } from './layout/app-shell/app-shell.component'; import { AppShellComponent } from './layout/app-shell/app-shell.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'world' }, { path: '', pathMatch: 'full', redirectTo: 'location' },
{ {
path: '', path: '',
component: AppShellComponent, component: AppShellComponent,
children: [ children: [
{
path: 'location',
loadComponent: () =>
import('./features/world/location-page/location-page.component').then(
(module) => module.LocationPageComponent,
),
},
{ {
path: 'world', path: 'world',
loadComponent: () => loadComponent: () =>
@@ -28,7 +35,14 @@ export const routes: Routes = [
(module) => module.CombatPageComponent, (module) => module.CombatPageComponent,
), ),
}, },
{
path: 'inventory',
loadComponent: () =>
import('./features/inventory/inventory-page.component').then(
(module) => module.InventoryPageComponent,
),
},
], ],
}, },
{ path: '**', redirectTo: 'world' }, { path: '**', redirectTo: 'location' },
]; ];

View File

@@ -8,11 +8,13 @@ import { routes } from './app.routes';
describe('App', () => { describe('App', () => {
let character: WritableSignal<CharacterResponse | null>; let character: WritableSignal<CharacterResponse | null>;
let displayedCharacter: WritableSignal<CharacterResponse | null>;
let currentLocation: WritableSignal<null>; let currentLocation: WritableSignal<null>;
let selectedConnection: WritableSignal<null>; let selectedConnection: WritableSignal<null>;
beforeEach(async () => { beforeEach(async () => {
character = signal<CharacterResponse | null>(null); character = signal<CharacterResponse | null>(null);
displayedCharacter = signal<CharacterResponse | null>(null);
currentLocation = signal(null); currentLocation = signal(null);
selectedConnection = signal(null); selectedConnection = signal(null);
@@ -20,12 +22,14 @@ describe('App', () => {
imports: [AppShellComponent], imports: [AppShellComponent],
providers: [ providers: [
provideRouter([ provideRouter([
{ path: 'location', children: [] },
{ path: 'world', children: [] }, { path: 'world', children: [] },
{ path: 'hunt', children: [] }, { path: 'hunt', children: [] },
{ path: 'inventory', children: [] },
]), ]),
{ {
provide: WorldStore, provide: WorldStore,
useValue: { character, currentLocation, selectedConnection }, useValue: { character, displayedCharacter, currentLocation, selectedConnection },
}, },
], ],
}).compileComponents(); }).compileComponents();
@@ -56,7 +60,14 @@ describe('App', () => {
expect(huntButton?.getAttribute('aria-current')).toBeNull(); expect(huntButton?.getAttribute('aria-current')).toBeNull();
expect(huntButton?.getAttribute('aria-label')).toBe('Jagd'); expect(huntButton?.getAttribute('aria-label')).toBe('Jagd');
for (const destination of ['quests', 'inventory', 'character']) { const inventoryButton = element.querySelector<HTMLButtonElement>(
'[data-navigation="inventory"]',
);
expect(inventoryButton).not.toBeNull();
expect(inventoryButton?.disabled).toBe(false);
expect(inventoryButton?.getAttribute('aria-label')).toBe('Inventar');
for (const destination of ['quests', 'character']) {
expect( expect(
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled, element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
).toBe(true); ).toBe(true);
@@ -65,6 +76,65 @@ describe('App', () => {
expect(element.textContent).not.toContain('Shop'); expect(element.textContent).not.toContain('Shop');
}); });
it('offers Ort as the first navigation entry, marked active on /location', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/location');
fixture.detectChanges();
await fixture.whenStable();
const element = fixture.nativeElement as HTMLElement;
const entries = element.querySelectorAll<HTMLButtonElement>('[data-navigation]');
expect([...entries].map((entry) => entry.dataset['navigation'])).toEqual([
'location',
'world',
'hunt',
'quests',
'inventory',
'character',
]);
const locationButton = element.querySelector<HTMLButtonElement>(
'[data-navigation="location"]',
);
expect(locationButton?.disabled).toBe(false);
expect(locationButton?.getAttribute('aria-label')).toBe('Ort');
expect(locationButton?.getAttribute('aria-current')).toBe('page');
expect(
element.querySelector('[data-navigation="world"]')?.getAttribute('aria-current'),
).toBeNull();
});
it('drops the shell context rail on /location, where the screen brings its own', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/location');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
});
it('drops the shell context rail on /inventory, which needs all three of its own columns', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/inventory');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
});
it('keeps the shell context rail on the map', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/world');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-context-panel')).not.toBeNull();
});
it('marks Jagd as the active navigation entry while on /hunt', async () => { it('marks Jagd as the active navigation entry while on /hunt', async () => {
const fixture = TestBed.createComponent(AppShellComponent); const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
@@ -81,7 +151,20 @@ describe('App', () => {
}); });
it('renders loaded character values supplied by the WorldStore', () => { it('renders loaded character values supplied by the WorldStore', () => {
character.set({ const decoy: CharacterResponse = {
id: 'stale-id',
name: 'Stale Decoy',
level: 1,
experience: 0,
silver: 0,
currentHp: 1,
maxHp: 1,
attack: 1,
hpRegenPerSecond: 1,
hpRegenSince: null,
currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' },
};
const value: CharacterResponse = {
id: 'character-id', id: 'character-id',
name: 'Mara Ashfall', name: 'Mara Ashfall',
level: 7, level: 7,
@@ -90,8 +173,12 @@ describe('App', () => {
currentHp: 52, currentHp: 52,
maxHp: 80, maxHp: 80,
attack: 12, 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: 'Südtor von Graufurt' },
}); };
character.set(decoy);
displayedCharacter.set(value);
const fixture = TestBed.createComponent(AppShellComponent); const fixture = TestBed.createComponent(AppShellComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -104,11 +191,19 @@ describe('App', () => {
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320'); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320');
}); });
it('redirects root and unknown routes to the world shell', () => { it('lands root and unknown routes on the place the character is standing in', () => {
expect(routes.find((route) => route.path === '')).toMatchObject({ expect(routes.find((route) => route.path === '')).toMatchObject({
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'world', redirectTo: 'location',
}); });
expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'world' }); expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'location' });
});
it('keeps the map and hunt routes where they were', () => {
const shellChildren = routes.find((route) => route.children)?.children ?? [];
expect(shellChildren.map((route) => route.path)).toEqual(
expect.arrayContaining(['location', 'world', 'hunt']),
);
}); });
}); });

View File

@@ -13,6 +13,8 @@ export interface CharacterResponse {
currentHp: number; currentHp: number;
maxHp: number; maxHp: number;
attack: number; attack: number;
hpRegenPerSecond: number;
hpRegenSince: string | null;
currentLocation: LocationSummary; currentLocation: LocationSummary;
} }
@@ -22,6 +24,69 @@ export interface CurrentLocationConnection {
danger: 'LOW' | 'HIGH'; danger: 'LOW' | 'HIGH';
} }
export type LocationInteractionType =
| 'HUNT'
| 'INVESTIGATE'
| 'SEARCH'
| 'NPC'
| 'MAP'
| 'TRAVEL'
| 'SHOP'
| 'QUEST'
| 'BOSS'
| 'DUNGEON';
export type LocationType =
| 'SAFE_HUB'
| 'TRANSITION'
| 'HUNTING_GROUND'
| 'QUEST_LOCATION'
| 'OUTPOST'
| 'ELITE_ZONE'
| 'BOSS_LOCATION'
| 'DUNGEON_ENTRANCE';
export interface LocationPointOfInterest {
key: string;
title: string;
actionLabel?: string;
type: LocationInteractionType;
iconKey: string;
xPercent: number;
yPercent: number;
enabled: boolean;
}
export interface LocationPrimaryAction {
key: string;
label: string;
description?: string;
type: LocationInteractionType;
iconKey: string;
enabled: boolean;
/** Set when the action reveals the same result as a hotspot on the artwork. */
poiKey?: string;
}
export interface EncounterPreview {
key: string;
name: string;
level: number;
iconPath: string;
}
export interface RewardPreview {
key: string;
label: string;
iconKey: string;
}
export interface LocationInteractionResult {
interactionKey: string;
title: string;
text: string;
}
export interface CurrentLocationResponse { export interface CurrentLocationResponse {
id: string; id: string;
key: string; key: string;
@@ -34,6 +99,18 @@ export interface CurrentLocationResponse {
isSafe: boolean; isSafe: boolean;
huntingEnabled: boolean; huntingEnabled: boolean;
artworkPath: string; artworkPath: string;
regionName: string;
regionTierLabel: string;
locationType: LocationType;
localDescription: string;
localArtworkPath: string;
/** `null` where nothing hostile can be met — the view reads that as safe. */
dangerRating: DangerRating | null;
recommendationLabel: string;
pointsOfInterest: LocationPointOfInterest[];
primaryActions: LocationPrimaryAction[];
encounterPreview: EncounterPreview[];
rewardPreview: RewardPreview[];
connections: CurrentLocationConnection[]; connections: CurrentLocationConnection[];
possibleMonsters: string[]; possibleMonsters: string[];
} }
@@ -74,9 +151,10 @@ export interface HuntResult {
} }
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST'; export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
export type CombatEventType = 'DAMAGE' | 'COMBAT_WON' | 'COMBAT_LOST'; export type CombatEventType = 'DAMAGE' | 'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatSide = 'PLAYER' | 'MONSTER'; export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = 'ATTACK'; export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION';
export type CombatMonsterIntent = 'HEAVY_ATTACK';
export interface CombatEvent { export interface CombatEvent {
round: number; round: number;
@@ -91,6 +169,8 @@ export interface CombatPlayer {
name: string; name: string;
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
potionsRemaining: number;
potionsMax: number;
} }
export interface CombatMonster { export interface CombatMonster {
@@ -100,6 +180,7 @@ export interface CombatMonster {
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
artworkPath: string; artworkPath: string;
pendingIntent: CombatMonsterIntent | null;
} }
export interface Combat { export interface Combat {
@@ -132,3 +213,59 @@ export interface CombatReward {
silver: number; silver: number;
items: CombatRewardItem[]; items: CombatRewardItem[];
} }
export type EquipmentSlot =
| 'WEAPON'
| 'HEAD'
| 'CHEST'
| 'HANDS'
| 'LEGS'
| 'FEET'
| 'AMULET';
export interface InventoryItem {
id: string;
quantity: number;
equipped: boolean;
item: {
key: string;
name: string;
description: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;
weaponDamage: number;
bonusAttack: number;
bonusHp: number;
bonusArmor: number;
iconPath: string;
};
}
export interface InventoryResponse {
items: InventoryItem[];
}
export interface EquipmentSlotItem {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
}
export type EquipmentSlots = Record<EquipmentSlot, EquipmentSlotItem | null>;
export interface EquipmentStats {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
export interface EquipmentResponse {
slots: EquipmentSlots;
stats: EquipmentStats;
}

View File

@@ -48,6 +48,24 @@ describe('GameApiService', () => {
request.flush({ status: 'IDLE' }); request.flush({ status: 'IDLE' });
}); });
it('posts a local interaction by key alone, never naming a location', () => {
service.runLocationInteraction('inspect-tracks').subscribe();
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: '…' });
});
it('escapes an interaction key so it cannot break out of its path segment', () => {
service.runLocationInteraction('a/../b').subscribe();
const request = http.expectOne(
'/api/world/current-location/interactions/a%2F..%2Fb',
);
request.flush({ interactionKey: 'a/../b', title: '', text: '' });
});
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => { it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
service.startCombat('encounter-uuid').subscribe(); service.startCombat('encounter-uuid').subscribe();

View File

@@ -7,7 +7,10 @@ import {
CombatAction, CombatAction,
CurrentLocationResponse, CurrentLocationResponse,
CurrentTravel, CurrentTravel,
EquipmentResponse,
HuntResult, HuntResult,
InventoryResponse,
LocationInteractionResult,
} from './game-api.models'; } from './game-api.models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -22,6 +25,17 @@ export class GameApiService {
return this.http.get<CurrentLocationResponse>('/api/world/current-location'); return this.http.get<CurrentLocationResponse>('/api/world/current-location');
} }
/**
* Runs a local hotspot interaction. Only the key travels: the server pairs
* it with the character's actual location.
*/
runLocationInteraction(interactionKey: string): Observable<LocationInteractionResult> {
return this.http.post<LocationInteractionResult>(
`/api/world/current-location/interactions/${encodeURIComponent(interactionKey)}`,
{},
);
}
startTravel(targetLocationId: string): Observable<CurrentTravel> { startTravel(targetLocationId: string): Observable<CurrentTravel> {
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId }); return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
} }
@@ -53,4 +67,16 @@ export class GameApiService {
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> { performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action }); return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
} }
getInventory(): Observable<InventoryResponse> {
return this.http.get<InventoryResponse>('/api/inventory');
}
getEquipment(): Observable<EquipmentResponse> {
return this.http.get<EquipmentResponse>('/api/equipment');
}
equipItem(characterItemId: string): Observable<EquipmentResponse> {
return this.http.post<EquipmentResponse>('/api/equipment', { characterItemId });
}
} }

View File

@@ -9,7 +9,7 @@ const runningCombat: Combat = {
id: 'combat-running', id: 'combat-running',
status: 'ACTIVE', status: 'ACTIVE',
round: 4, round: 4,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62 }, 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: 'Straßenräuber',
@@ -17,6 +17,7 @@ const runningCombat: Combat = {
maxHp: 75, maxHp: 75,
currentHp: 30, currentHp: 30,
artworkPath: '/images/enemies/RoadBandit.png', artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null,
}, },
events: [], events: [],
rewards: null, rewards: null,

View File

@@ -48,6 +48,10 @@
</div> </div>
</header> </header>
@if (monsterIntentLabel(); as intent) {
<p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p>
}
<div class="combat__field"> <div class="combat__field">
<div <div
class="sprite sprite--player" class="sprite sprite--player"
@@ -73,12 +77,56 @@
class="action" class="action"
data-combat-attack data-combat-attack
[disabled]="busy()" [disabled]="busy()"
(click)="attack()" (click)="performAction('ATTACK')"
> >
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" /> <img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Angriff</span> <span class="action__label">Angriff</span>
<span class="action__key">1</span> <span class="action__key">1</span>
</button> </button>
<button
type="button"
class="action"
data-combat-heavy-strike
[disabled]="busy()"
(click)="performAction('HEAVY_STRIKE')"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Schwerer Hieb</span>
<span class="action__key">2</span>
</button>
<button
type="button"
class="action"
data-combat-shield-bash
[disabled]="busy()"
(click)="performAction('SHIELD_BASH')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Schildstoß</span>
<span class="action__key">3</span>
</button>
<button
type="button"
class="action"
data-combat-defend
[disabled]="busy()"
(click)="performAction('DEFEND')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Verteidigen</span>
<span class="action__key">4</span>
</button>
<button
type="button"
class="action"
data-combat-potion
[disabled]="busy() || combat.player.potionsRemaining <= 0"
(click)="performAction('POTION')"
>
<img class="action__icon" src="/images/items/small-healing-potion.png" alt="" />
<span class="action__label">Trank {{ combat.player.potionsRemaining }}/{{ combat.player.potionsMax }}</span>
<span class="action__key">5</span>
</button>
} }
</footer> </footer>
@@ -117,17 +165,45 @@
</section> </section>
} }
<div class="outcome__buttons">
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()"> <button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd Weiter jagen
</button> </button>
<button
type="button"
class="outcome__button outcome__button--secondary"
data-combat-to-location
(click)="goToLocation()"
>
Zum Ort
</button>
<button
type="button"
class="outcome__button outcome__button--secondary"
data-combat-to-inventory
(click)="goToInventory()"
>
Inventar öffnen
</button>
</div>
</div> </div>
} @else if (combat.status === 'LOST') { } @else if (combat.status === 'LOST') {
<div class="outcome outcome--lost" data-combat-result="LOST"> <div class="outcome outcome--lost" data-combat-result="LOST">
<h2 class="outcome__title">Niederlage</h2> <h2 class="outcome__title">Niederlage</h2>
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p> <p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
<div class="outcome__buttons">
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()"> <button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd Weiter jagen
</button> </button>
<button
type="button"
class="outcome__button outcome__button--secondary"
data-combat-to-location
(click)="goToLocation()"
>
Zum Ort
</button>
</div>
</div> </div>
} }
</div> </div>

View File

@@ -363,10 +363,27 @@
position: relative; position: relative;
z-index: 2; z-index: 2;
display: flex; display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--ar-space-3); gap: var(--ar-space-3);
min-block-size: clamp(6.5rem, 11vw, 8.5rem); min-block-size: clamp(6.5rem, 11vw, 8.5rem);
} }
.combat__telegraph {
position: relative;
z-index: 2;
margin: 0;
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-gold);
border-radius: var(--ar-radius-sm);
background: rgb(9 11 13 / 0.85);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1rem, 1.6vw, 1.15rem);
letter-spacing: 0.03em;
text-align: center;
}
.action { .action {
position: relative; position: relative;
inline-size: clamp(6.5rem, 11vw, 8.5rem); inline-size: clamp(6.5rem, 11vw, 8.5rem);
@@ -403,7 +420,7 @@
} }
.action__key { .action__key {
inset-block-start: 90%; inset-block-start: 85%;
color: var(--ar-text-muted); color: var(--ar-text-muted);
font-size: var(--ar-font-sm); font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -490,6 +507,19 @@
margin-block-start: var(--ar-space-2); margin-block-start: var(--ar-space-2);
} }
.outcome__buttons {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-3);
justify-content: center;
}
.outcome__button--secondary {
border-color: var(--ar-border);
color: var(--ar-text-muted);
background: var(--ar-panel);
}
.outcome__button:hover, .outcome__button:hover,
.combat__notice--error button:hover { .combat__notice--error button:hover {
border-color: var(--ar-gold); border-color: var(--ar-gold);

View File

@@ -11,7 +11,7 @@ const activeCombat: Combat = {
id: 'combat-1', id: 'combat-1',
status: 'ACTIVE', status: 'ACTIVE',
round: 2, round: 2,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 }, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 },
monster: { monster: {
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Aschenratte',
@@ -19,6 +19,7 @@ const activeCombat: Combat = {
maxHp: 45, maxHp: 45,
currentHp: 31, currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
}, },
events: [ events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, { round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
@@ -40,7 +41,7 @@ describe('CombatPageComponent', () => {
actionPending: ReturnType<typeof signal<boolean>>; actionPending: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>; error: ReturnType<typeof signal<string | null>>;
loadCombat: ReturnType<typeof vi.fn>; loadCombat: ReturnType<typeof vi.fn>;
attack: ReturnType<typeof vi.fn>; performAction: ReturnType<typeof vi.fn>;
}; };
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> }; let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
let router: Router; let router: Router;
@@ -52,7 +53,7 @@ describe('CombatPageComponent', () => {
actionPending: signal(false), actionPending: signal(false),
error: signal<string | null>(null), error: signal<string | null>(null),
loadCombat: vi.fn(() => Promise.resolve()), loadCombat: vi.fn(() => Promise.resolve()),
attack: vi.fn(() => Promise.resolve()), performAction: vi.fn(() => Promise.resolve()),
}; };
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
@@ -102,6 +103,101 @@ describe('CombatPageComponent', () => {
expect(element.querySelector('[data-combat-attack]')).toBeTruthy(); expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
}); });
it('shows all five combat actions with their German labels', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Angriff');
expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Schwerer Hieb');
expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Schildstoß');
expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Verteidigen');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2');
});
it('sends HEAVY_STRIKE when Schwerer Hieb is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-heavy-strike]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE');
});
it('sends SHIELD_BASH when Schildstoß is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH');
});
it('sends DEFEND when Verteidigen is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND');
});
it('sends POTION when Trank is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('POTION');
});
it('disables the potion button once both potions are used', async () => {
const fixture = await setup({
...activeCombat,
player: { ...activeCombat.player, potionsRemaining: 0 },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.disabled).toBe(true);
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(false);
});
it('shows a prominent telegraph banner when the monster has a pending Heavy Attack', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
});
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain(
'Aschenratte bereitet Schweren Hieb vor.',
);
});
it('shows no telegraph banner when nothing is pending', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
});
it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => {
const fixture = await setup({
...activeCombat,
events: [
{ round: 1, sequence: 1, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 35 },
{ round: 1, sequence: 2, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
{ round: 1, sequence: 3, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
{ round: 1, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
],
});
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 35 Lebenspunkte.');
expect(element.textContent).toContain('Aric Duskwalker geht in die Verteidigung.');
expect(element.textContent).toContain('Aschenratte bereitet Schweren Hieb vor.');
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
});
it('renders the structured events as readable German combat-log entries', async () => { it('renders the structured events as readable German 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;
@@ -110,13 +206,13 @@ describe('CombatPageComponent', () => {
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.'); expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
}); });
it('calls combatStore.attack() when Angriff is clicked', async () => { it('calls combatStore.performAction("ATTACK") when Angriff is clicked', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click(); element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
expect(combatStore.attack).toHaveBeenCalledOnce(); expect(combatStore.performAction).toHaveBeenCalledWith('ATTACK');
}); });
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => { it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
@@ -132,7 +228,7 @@ describe('CombatPageComponent', () => {
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, { round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
], ],
}; };
combatStore.attack.mockImplementation(async () => { combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(resolvedRound); combatStore.combat.set(resolvedRound);
}); });
vi.useFakeTimers(); vi.useFakeTimers();
@@ -162,7 +258,7 @@ describe('CombatPageComponent', () => {
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1); expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
// The monster strikes back after the beat. // The monster strikes back after the beat.
await vi.advanceTimersByTimeAsync(260); await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges(); fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(true); expect(sprite?.classList.contains('sprite--hit')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(true); expect(monster?.classList.contains('sprite--lunge')).toBe(true);
@@ -179,6 +275,115 @@ describe('CombatPageComponent', () => {
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false); expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
}); });
it('reveals the telegraph banner only after the reply beat, and never lunges for it', async () => {
const fixture = await setup(activeCombat);
const telegraphed: Combat = {
...activeCombat,
round: 3,
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' },
{ round: 2, sequence: 4, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' },
],
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(telegraphed);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const monster = element.querySelector('.sprite--monster');
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
fixture.detectChanges();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges();
expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('bereitet Schweren Hieb vor');
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
it('shows the INTERRUPT log line immediately and skips the lunge when SHIELD_BASH interrupts', async () => {
const fixture = await setup({
...activeCombat,
monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' },
});
const interrupted: Combat = {
...activeCombat,
round: 3,
monster: { ...activeCombat.monster, currentHp: 21, pendingIntent: null },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 10 },
{ round: 2, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' },
],
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(interrupted);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const monster = element.querySelector('.sprite--monster');
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.');
expect(element.querySelector('[data-combat-telegraph]')).toBeNull();
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
});
it('shows the potion heal at the first checkpoint instead of waiting for the riposte reveal', async () => {
const fixture = await setup({
...activeCombat,
player: { ...activeCombat.player, currentHp: 70 },
});
const healed: Combat = {
...activeCombat,
round: 3,
player: { ...activeCombat.player, currentHp: 80, potionsRemaining: 1 },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 15 },
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(healed);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-potion]')?.click();
fixture.detectChanges();
// Before the checkpoint: the pre-heal HP and potion count still show.
expect(element.textContent).toContain('70 / 100');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2');
// First checkpoint: the heal already landed from the player's own action,
// so the HP bar and potion count update here -- well before the monster's
// held-back reply resolves.
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.textContent).toContain('85 / 100');
expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 1/2');
expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 15 Lebenspunkte.');
// The monster's reply is still held back at this point.
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
await vi.advanceTimersByTimeAsync(1260);
fixture.detectChanges();
expect(element.textContent).toContain('80 / 100');
});
it('skips the recoil when the round ends without the monster striking back', async () => { it('skips the recoil when the round ends without the monster striking back', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
const won: Combat = { const won: Combat = {
@@ -191,7 +396,7 @@ describe('CombatPageComponent', () => {
{ round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' },
], ],
}; };
combatStore.attack.mockImplementation(async () => { combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set(won); combatStore.combat.set(won);
}); });
vi.useFakeTimers(); vi.useFakeTimers();
@@ -216,7 +421,7 @@ describe('CombatPageComponent', () => {
it('refreshes the character from the server once a combat is won', async () => { it('refreshes the character from the server once a combat is won', async () => {
const fixture = await setup(activeCombat); const fixture = await setup(activeCombat);
combatStore.attack.mockImplementation(async () => { combatStore.performAction.mockImplementation(async () => {
combatStore.combat.set({ combatStore.combat.set({
...activeCombat, ...activeCombat,
status: 'WON', status: 'WON',
@@ -266,7 +471,7 @@ describe('CombatPageComponent', () => {
expect(element.querySelector('[data-combat-attack]')).toBeNull(); expect(element.querySelector('[data-combat-attack]')).toBeNull();
}); });
it('navigates to /hunt from the victory screen', async () => { it('keeps the one-click hunt loop from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' }); const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
@@ -275,6 +480,34 @@ describe('CombatPageComponent', () => {
expect(router.navigate).toHaveBeenCalledWith(['/hunt']); expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
}); });
it('also offers the way back to the location from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-location]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/location']);
});
it('offers the same two ways out after a defeat', async () => {
const fixture = await setup({ ...activeCombat, status: 'LOST' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-to-hunt]')).not.toBeNull();
element.querySelector<HTMLButtonElement>('[data-combat-to-location]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/location']);
});
it('navigates to /inventory from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-inventory]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/inventory']);
});
it('shows an error and retries loading the combat', async () => { it('shows an error and retries loading the combat', async () => {
const fixture = await setup(null); const fixture = await setup(null);
combatStore.error.set('Dieser Kampf wurde nicht gefunden.'); combatStore.error.set('Dieser Kampf wurde nicht gefunden.');

View File

@@ -1,6 +1,6 @@
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core'; import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import type { Combat, CombatEvent } from '../../../core/api/game-api.models'; import type { Combat, CombatAction, CombatEvent } from '../../../core/api/game-api.models';
import { import {
combatMonsterSpriteScale, combatMonsterSpriteScale,
monsterCutoutPath, monsterCutoutPath,
@@ -29,10 +29,15 @@ const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
const SWING_MS = 540; const SWING_MS = 540;
const RECOIL_MS = 540; const RECOIL_MS = 540;
// Beat between the player's blow landing and the monster striking back. // Beat between the player's blow landing and the monster striking back.
const RIPOSTE_DELAY_MS = 260; const RIPOSTE_DELAY_MS = 1260;
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet. // Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
const STAGE_SHAKE_MS = 200; const STAGE_SHAKE_MS = 200;
// Actions that land a blow on the monster this round -- everything else
// (DEFEND, POTION) skips the swing wind-up so the player sprite doesn't
// mime an attack it didn't make.
const DAMAGING_ACTIONS: ReadonlySet<CombatAction> = new Set(['ATTACK', 'HEAVY_STRIKE', 'SHIELD_BASH']);
@Component({ @Component({
selector: 'app-combat-page', selector: 'app-combat-page',
templateUrl: './combat-page.component.html', templateUrl: './combat-page.component.html',
@@ -75,7 +80,7 @@ export class CombatPageComponent implements OnInit {
void this.loadFromRoute(); void this.loadFromRoute();
} }
protected async attack(): Promise<void> { protected async performAction(action: CombatAction): Promise<void> {
const before = this.displayed(); const before = this.displayed();
if (!before || this.busy()) { if (!before || this.busy()) {
return; return;
@@ -83,16 +88,16 @@ export class CombatPageComponent implements OnInit {
this.replaying.set(true); this.replaying.set(true);
try { try {
this.phase.set('attacking'); const damaging = DAMAGING_ACTIONS.has(action);
this.phase.set(damaging ? 'attacking' : 'idle');
this.monsterPhase.set('idle'); this.monsterPhase.set('idle');
const swing = this.wait(SWING_MS); const swing = this.wait(SWING_MS);
await this.combatStore.attack(); await this.combatStore.performAction(action);
await swing; await swing;
if (this.destroyed) { if (this.destroyed) {
return; return;
} }
this.phase.set('idle'); this.phase.set('idle');
this.monsterPhase.set('flinch');
const after = this.combatStore.combat(); const after = this.combatStore.combat();
if (!after) { if (!after) {
@@ -105,21 +110,44 @@ export class CombatPageComponent implements OnInit {
void this.worldStore.refreshCharacter(); void this.worldStore.refreshCharacter();
} }
const riposte = after.events.find( const roundEvents = after.events.filter((event) => event.round === before.round);
(event) => const dealtDamage = roundEvents.some(
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER', (event) => event.source === 'PLAYER' && event.target === 'MONSTER' && event.type === 'DAMAGE',
); );
this.monsterPhase.set(dealtDamage ? 'flinch' : 'idle');
if (!riposte) { const monsterEvent = roundEvents.find((event) => event.source === 'MONSTER');
if (!monsterEvent) {
// No reply this round: either the fight just ended, or SHIELD_BASH
// interrupted the monster's turn outright.
this.displayed.set(after); this.displayed.set(after);
return; return;
} }
// Show the blow the player just landed, holding back the monster's reply. // Show what the player's own action produced, holding back the
// monster's reply -- including whether it just started telegraphing.
// A POTION heal lands from the player's own action, before the
// monster's reply, so it must show up here rather than being folded
// into the delayed riposte reveal.
const healEvent = roundEvents.find(
(event) => event.type === 'HEAL' && event.sequence < monsterEvent.sequence,
);
const intermediatePlayer = healEvent
? {
...before.player,
currentHp: Math.min(before.player.maxHp, before.player.currentHp + (healEvent.amount ?? 0)),
potionsRemaining: after.player.potionsRemaining,
}
: before.player;
this.displayed.set({ this.displayed.set({
...after, ...after,
player: before.player, player: intermediatePlayer,
events: after.events.filter((event) => event.sequence < riposte.sequence), monster: {
...(dealtDamage ? after.monster : before.monster),
pendingIntent: before.monster.pendingIntent,
},
events: after.events.filter((event) => event.sequence < monsterEvent.sequence),
}); });
await this.wait(RIPOSTE_DELAY_MS); await this.wait(RIPOSTE_DELAY_MS);
@@ -127,6 +155,11 @@ export class CombatPageComponent implements OnInit {
return; return;
} }
if (monsterEvent.type === 'TELEGRAPH') {
this.displayed.set(after);
return;
}
this.phase.set('hit'); this.phase.set('hit');
this.monsterPhase.set('lunge'); this.monsterPhase.set('lunge');
this.displayed.set(after); this.displayed.set(after);
@@ -161,6 +194,17 @@ export class CombatPageComponent implements OnInit {
void this.router.navigate(['/hunt']); void this.router.navigate(['/hunt']);
} }
// The location is the screen a fight resolves back into. It sits beside
// "Weiter jagen" rather than replacing it, so the hunt loop keeps its
// one-click rhythm.
protected goToLocation(): void {
void this.router.navigate(['/location']);
}
protected goToInventory(): void {
void this.router.navigate(['/inventory']);
}
protected monsterSprite(monsterKey: string, artworkPath: string): string { protected monsterSprite(monsterKey: string, artworkPath: string): string {
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
} }
@@ -183,6 +227,14 @@ export class CombatPageComponent implements OnInit {
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0; return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
} }
protected monsterIntentLabel(): string | null {
const combat = this.displayed();
if (!combat || combat.monster.pendingIntent !== 'HEAVY_ATTACK') {
return null;
}
return `${combat.monster.name} bereitet Schweren Hieb vor.`;
}
protected logRounds(): CombatLogRound[] { protected logRounds(): CombatLogRound[] {
const combat = this.displayed(); const combat = this.displayed();
if (!combat) { if (!combat) {
@@ -210,6 +262,22 @@ export class CombatPageComponent implements OnInit {
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`; return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
} }
if (event.type === 'HEAL') {
return `${playerName} trinkt einen Trank und heilt ${event.amount} Lebenspunkte.`;
}
if (event.type === 'DEFEND') {
return `${playerName} geht in die Verteidigung.`;
}
if (event.type === 'TELEGRAPH') {
return `${monsterName} bereitet Schweren Hieb vor.`;
}
if (event.type === 'INTERRUPT') {
return `${playerName} unterbricht den vorbereiteten Angriff von ${monsterName}.`;
}
if (event.type === 'COMBAT_WON') { if (event.type === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`; return `${monsterName} wurde besiegt.`;
} }

View File

@@ -10,7 +10,7 @@ const startedCombat: Combat = {
id: 'combat-1', id: 'combat-1',
status: 'ACTIVE', status: 'ACTIVE',
round: 1, round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 }, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 },
monster: { monster: {
key: 'ash-rat', key: 'ash-rat',
name: 'Aschenratte', name: 'Aschenratte',
@@ -18,6 +18,7 @@ const startedCombat: Combat = {
maxHp: 45, maxHp: 45,
currentHp: 45, currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
}, },
events: [], events: [],
rewards: null, rewards: null,
@@ -83,6 +84,22 @@ describe('CombatStore', () => {
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE'); expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
}); });
it('maps CHARACTER_TOO_WOUNDED to its German message', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'CHARACTER_TOO_WOUNDED', message: 'Too wounded.' },
}),
),
);
await store.startCombat('encounter-1');
expect(store.error()).toBe('Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.');
});
it('loads the running combat and clears the error that sent us looking for it', async () => { it('loads the running combat and clears the error that sent us looking for it', async () => {
api.startCombat.mockReturnValue( api.startCombat.mockReturnValue(
throwError( throwError(
@@ -139,14 +156,22 @@ describe('CombatStore', () => {
it('sends only the ATTACK action and replaces combat with the server response', async () => { it('sends only the ATTACK action and replaces combat with the server response', async () => {
await store.startCombat('encounter-1'); await store.startCombat('encounter-1');
await store.attack(); await store.performAction('ATTACK');
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK'); expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK');
expect(store.combat()).toEqual(afterAttack); expect(store.combat()).toEqual(afterAttack);
}); });
it('sends whichever action is requested', async () => {
await store.startCombat('encounter-1');
await store.performAction('HEAVY_STRIKE');
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'HEAVY_STRIKE');
});
it('does nothing when attacking without a loaded combat', async () => { it('does nothing when attacking without a loaded combat', async () => {
await store.attack(); await store.performAction('ATTACK');
expect(api.performCombatAction).not.toHaveBeenCalled(); expect(api.performCombatAction).not.toHaveBeenCalled();
}); });
@@ -162,9 +187,9 @@ describe('CombatStore', () => {
), ),
); );
const first = store.attack(); const first = store.performAction('ATTACK');
expect(store.actionPending()).toBe(true); expect(store.actionPending()).toBe(true);
const second = store.attack(); const second = store.performAction('ATTACK');
resolveAttack(afterAttack); resolveAttack(afterAttack);
await Promise.all([first, second]); await Promise.all([first, second]);
@@ -176,7 +201,7 @@ describe('CombatStore', () => {
await store.startCombat('encounter-1'); await store.startCombat('encounter-1');
api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler'))); api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
await store.attack(); await store.performAction('ATTACK');
expect(store.actionPending()).toBe(false); expect(store.actionPending()).toBe(false);
expect(store.combat()).toEqual(startedCombat); expect(store.combat()).toEqual(startedCombat);

View File

@@ -1,7 +1,7 @@
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { Combat } from '../../core/api/game-api.models'; import { Combat, CombatAction } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service'; import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.'; const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
@@ -13,9 +13,11 @@ const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.', HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.', INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.', CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
CHARACTER_TOO_WOUNDED: 'Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.',
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.', COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.', COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.', COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
COMBAT_NO_POTIONS_REMAINING: 'Du hast keine Tränke mehr.',
}; };
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -83,7 +85,7 @@ export class CombatStore {
} }
} }
async attack(): Promise<void> { async performAction(action: CombatAction): Promise<void> {
const combat = this.combatState(); const combat = this.combatState();
if (!combat || this.actionPendingState()) { if (!combat || this.actionPendingState()) {
return; return;
@@ -93,7 +95,7 @@ export class CombatStore {
this.clearError(); this.clearError();
try { try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK')); const updated = await firstValueFrom(this.api.performCombatAction(combat.id, action));
this.combatState.set(updated); this.combatState.set(updated);
} catch (error) { } catch (error) {
this.setError(error); this.setError(error);

View File

@@ -7,7 +7,7 @@
Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres
Gebiet, um nach Gegnern zu suchen. Gebiet, um nach Gegnern zu suchen.
</p> </p>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button> <button type="button" data-hunt-to-location (click)="goToLocation()">Zurück zum Ort</button>
</section> </section>
} @else if (huntingStore.currentHunt(); as hunt) { } @else if (huntingStore.currentHunt(); as hunt) {
<section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name"> <section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name">
@@ -28,7 +28,7 @@
> >
Neu suchen Neu suchen
</button> </button>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button> <button type="button" data-hunt-to-location (click)="goToLocation()">Zurück zum Ort</button>
</div> </div>
</section> </section>
} @else { } @else {

View File

@@ -5,38 +5,17 @@ import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models'; import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store'; import { CombatStore } from '../../combat/combat.store';
import {
burnedRoadFixture,
southGateFixture,
} from '../../world/current-location.fixture';
import { WorldStore } from '../../world/world.store'; import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store'; import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component'; import { HuntPageComponent } from './hunt-page.component';
const southGate: CurrentLocationResponse = { const southGate = southGateFixture({ connections: [] });
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
possibleMonsters: [],
};
const burnedRoad: CurrentLocationResponse = { const burnedRoad = burnedRoadFixture({ connections: [] });
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
connections: [],
};
const threeEncounterHunt: HuntResult = { const threeEncounterHunt: HuntResult = {
id: 'hunt-id', id: 'hunt-id',
@@ -72,7 +51,7 @@ const startedCombat: Combat = {
id: 'combat-2', id: 'combat-2',
status: 'ACTIVE', status: 'ACTIVE',
round: 1, round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 }, 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: 'Straßenräuber',
@@ -80,6 +59,7 @@ const startedCombat: Combat = {
maxHp: 75, maxHp: 75,
currentHp: 75, currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png', artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null,
}, },
events: [], events: [],
rewards: null, rewards: null,
@@ -150,7 +130,7 @@ describe('HuntPageComponent', () => {
return fixture; return fixture;
} }
it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => { it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen 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;
@@ -162,11 +142,11 @@ describe('HuntPageComponent', () => {
), ),
).toBe(false); ).toBe(false);
const toWorldButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-world]'); const backButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-location]');
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte'); expect(backButton?.textContent?.trim()).toBe('Zurück zum Ort');
toWorldButton?.click(); backButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/world']); expect(router.navigate).toHaveBeenCalledWith(['/location']);
}); });
it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => { it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {

View File

@@ -44,8 +44,10 @@ export class HuntPageComponent implements OnInit {
} }
} }
protected goToWorld(): void { // Back out of the hunt returns to the place the hunt happens in, not to the
void this.router.navigate(['/world']); // map: the location is the screen the player left to get here.
protected goToLocation(): void {
void this.router.navigate(['/location']);
} }
protected async onAttack(encounterId: string): Promise<void> { protected async onAttack(encounterId: string): Promise<void> {

View File

@@ -0,0 +1,85 @@
<section class="ar-panel detail" aria-label="Gewählter Gegenstand">
<h2 class="ar-panel__title">Gewählter Gegenstand</h2>
@if (item(); as item) {
<article
class="detail__body"
[class.detail__body--rare]="item.item.rarity === 'RARE'"
[class.detail__body--epic]="item.item.rarity === 'EPIC'"
>
<header class="detail__head">
<span class="detail__portrait">
<img [src]="item.item.iconPath" [alt]="item.item.name" />
</span>
<div class="detail__ident">
<h3 class="detail__name" data-detail-name>{{ item.item.name }}</h3>
<p class="detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
<p class="detail__slot">{{ slotLabel() ?? typeLabel() }}</p>
</div>
</header>
@if (statRows().length) {
<dl class="detail__stats" data-detail-stats>
@for (row of statRows(); track row.label) {
<div class="detail__stat">
<dt>{{ row.label }}</dt>
<dd>
<span class="detail__value">{{ row.value }}</span>
@if (row.diff !== null && row.diff !== 0) {
<span
class="detail__diff"
[class.detail__diff--positive]="row.diff > 0"
[class.detail__diff--negative]="row.diff < 0"
>
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
</span>
}
</dd>
</div>
}
</dl>
}
@if (isEquippable()) {
<dl class="detail__meta">
<div class="detail__stat">
<dt>Benötigte Stufe</dt>
<dd>
<span class="detail__value" [class.detail__value--unmet]="!meetsLevelRequirement()">
{{ item.item.requiredLevel }}
</span>
</dd>
</div>
</dl>
}
@if (item.item.description) {
<p class="detail__flavour">{{ item.item.description }}</p>
}
<div class="detail__actions">
@if (item.equipped) {
<span class="detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="detail__note">Nicht ausrüstbar</span>
} @else if (!meetsLevelRequirement()) {
<button type="button" class="detail__equip" data-detail-equip disabled>
Benötigt Stufe {{ item.item.requiredLevel }}
</button>
} @else {
<button
type="button"
class="detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
</div>
</article>
} @else {
<p class="detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}
</section>

View File

@@ -0,0 +1,205 @@
:host {
display: block;
}
.detail {
block-size: 100%;
}
.detail__body {
display: grid;
gap: var(--ar-space-3);
/* Rarity tints the name and the icon frame, nothing else — the numbers stay
the loudest thing in the panel. */
--detail-rarity: var(--ar-text);
--detail-rarity-edge: var(--ar-border-highlight);
}
.detail__body--rare {
--detail-rarity: var(--ar-blue);
--detail-rarity-edge: var(--ar-blue);
}
.detail__body--epic {
--detail-rarity: var(--ar-gold);
--detail-rarity-edge: var(--ar-gold);
}
/* ---------- identity ---------- */
.detail__head {
display: flex;
gap: var(--ar-space-3);
align-items: flex-start;
}
.detail__portrait {
display: grid;
flex: 0 0 auto;
place-items: center;
inline-size: 4.25rem;
block-size: 4.25rem;
padding: 0.3rem;
border: 1px solid var(--detail-rarity-edge);
background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.6rem rgb(0 0 0 / 0.8);
}
.detail__portrait img {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}
.detail__ident {
min-inline-size: 0;
padding-block-start: 0.15rem;
}
.detail__name {
margin: 0;
color: var(--detail-rarity);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.1rem;
font-weight: 400;
line-height: 1.2;
}
.detail__rarity {
margin: 0.2rem 0 0;
color: var(--detail-rarity);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.85;
}
.detail__slot {
margin: 0.15rem 0 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
/* ---------- numbers ---------- */
.detail__stats,
.detail__meta {
display: grid;
gap: 0.35rem;
margin: 0;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
}
.detail__stat {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--ar-space-3);
}
.detail__stat dt {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.detail__stat dd {
display: flex;
gap: 0.4rem;
align-items: baseline;
margin: 0;
white-space: nowrap;
}
.detail__value {
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
.detail__value--unmet {
color: var(--ar-danger);
}
/* The sign is carried by the text, never by colour alone (spec §52). */
.detail__diff {
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
}
.detail__diff--positive {
color: var(--ar-success);
}
.detail__diff--negative {
color: var(--ar-danger);
}
/* ---------- flavour ---------- */
.detail__flavour {
margin: 0;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
color: var(--ar-text-muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--ar-font-sm);
font-style: italic;
line-height: 1.5;
}
/* ---------- action ---------- */
.detail__actions {
display: grid;
justify-items: center;
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
}
.detail__equipped {
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
letter-spacing: 0.08em;
}
.detail__note {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic;
}
/* Same steel-blue plate as the travel button: the one committing action. */
.detail__equip {
inline-size: 100%;
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #263b4b, #17232d);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
letter-spacing: 0.06em;
}
.detail__equip:hover:not(:disabled) {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.detail__equip:disabled {
border-color: var(--ar-border);
color: var(--ar-text-muted);
background: #1a1c1d;
}
.detail__empty {
margin: 0;
padding-block: var(--ar-space-5);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic;
text-align: center;
}

View File

@@ -0,0 +1,121 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type { InventoryItem } from '../../core/api/game-api.models';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
const wornSword: InventoryItem = {
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
};
const banditBlade: InventoryItem = {
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
};
async function setup(overrides: {
item?: InventoryItem | null;
equippedItemInSlot?: InventoryItem | null;
characterLevel?: number;
busy?: boolean;
}) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({ imports: [InventoryDetailPanelComponent] }).compileComponents();
const fixture = TestBed.createComponent(InventoryDetailPanelComponent);
fixture.componentRef.setInput('item', overrides.item ?? null);
fixture.componentRef.setInput('equippedItemInSlot', overrides.equippedItemInSlot ?? null);
fixture.componentRef.setInput('characterLevel', overrides.characterLevel ?? 1);
fixture.componentRef.setInput('busy', overrides.busy ?? false);
fixture.detectChanges();
return fixture;
}
describe('InventoryDetailPanelComponent', () => {
it('shows a placeholder when nothing is selected', async () => {
const fixture = await setup({ item: null });
expect((fixture.nativeElement as HTMLElement).querySelector('[data-detail-empty]')).not.toBeNull();
});
it('shows Ausgerüstet for the currently equipped item, with no equip button', async () => {
const fixture = await setup({ item: wornSword });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-detail-equipped]')?.textContent).toContain('Ausgerüstet');
expect(element.querySelector('[data-detail-equip]')).toBeNull();
});
it('shows the item flavour text from the server', async () => {
const fixture = await setup({
item: {
...banditBlade,
item: { ...banditBlade.item, description: 'Eine grob gezahnte Klinge.' },
},
});
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
'Eine grob gezahnte Klinge.',
);
});
it('shows the stat comparison against the equipped item in the same slot', async () => {
const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword });
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? '';
expect(text).toContain('11');
expect(text).toContain('+3');
expect(text).toContain('+1');
});
it('shows a disabled Benötigt Stufe X button when the level requirement is not met', async () => {
const fixture = await setup({ item: { ...banditBlade, item: { ...banditBlade.item, requiredLevel: 5 } }, characterLevel: 1 });
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
expect(button?.textContent).toContain('Benötigt Stufe 5');
expect(button?.disabled).toBe(true);
});
it('emits equip with the characterItemId when Ausrüsten is clicked', async () => {
const fixture = await setup({ item: banditBlade });
const emitted: string[] = [];
fixture.componentInstance.equip.subscribe((id: string) => emitted.push(id));
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]')?.click();
expect(emitted).toEqual(['item-blade']);
});
it('disables the equip button while busy', async () => {
const fixture = await setup({ item: banditBlade, busy: true });
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
expect(button?.disabled).toBe(true);
});
});

View File

@@ -0,0 +1,74 @@
import { Component, computed, input, output } from '@angular/core';
import type { InventoryItem } from '../../core/api/game-api.models';
import { RARITY_LABELS } from '../../shared/item-card/item-card.component';
import { SLOT_LABELS } from './inventory.labels';
interface StatRow {
label: string;
value: number;
diff: number | null;
}
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' },
];
/** Selected-item details and equip comparison (spec §3237). */
@Component({
selector: 'app-inventory-detail-panel',
templateUrl: './inventory-detail-panel.component.html',
styleUrl: './inventory-detail-panel.component.scss',
})
export class InventoryDetailPanelComponent {
readonly item = input<InventoryItem | null>(null);
readonly equippedItemInSlot = input<InventoryItem | null>(null);
readonly characterLevel = input(1);
readonly busy = input(false);
readonly equip = output<string>();
protected readonly rarityLabel = computed(() => {
const item = this.item();
return item ? RARITY_LABELS[item.item.rarity] : '';
});
protected readonly slotLabel = computed(() => {
const slot = this.item()?.item.equipmentSlot;
return slot ? SLOT_LABELS[slot] : null;
});
/** Stand-in for items with no slot: the API does not expose an item type. */
protected readonly typeLabel = computed(() => 'Gegenstand');
protected readonly statRows = computed<StatRow[]>(() => {
const item = this.item();
if (!item) {
return [];
}
const compareTo = this.equippedItemInSlot();
const comparable = compareTo && compareTo.id !== item.id ? compareTo.item : null;
return STAT_LABELS.map(({ label, key }) => ({
label,
value: item.item[key],
diff: comparable ? item.item[key] - comparable[key] : null,
})).filter((row) => row.value > 0 || (row.diff ?? 0) !== 0);
});
protected readonly isEquippable = computed(() => !!this.item()?.item.equipmentSlot);
protected readonly meetsLevelRequirement = computed(() => {
const item = this.item();
return item ? item.item.requiredLevel <= this.characterLevel() : true;
});
protected onEquip(): void {
const item = this.item();
if (item) {
this.equip.emit(item.id);
}
}
}

View File

@@ -0,0 +1,155 @@
<section class="inventory-page" aria-label="Inventar">
<h1 class="inventory-page__title">Inventar</h1>
@if (inventoryStore.loading() && !inventoryStore.inventory()) {
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
} @else if (inventoryStore.inventory(); as inventory) {
<div class="inventory-page__columns">
<!-- ---------- paper doll + derived stats ---------- -->
<div class="inventory-page__rail">
<section class="ar-panel panel--doll" aria-label="Ausrüstung">
<h2 class="ar-panel__title">Ausrüstung</h2>
@if (inventoryStore.equipment(); as equipment) {
<div class="doll inventory-page__equipment-list">
<div class="doll__column">
@for (slot of dollLeft; track slot) {
<ng-container
*ngTemplateOutlet="dollSlot; context: { label: slotLabels[slot], entry: equipment.slots[slot] }"
/>
}
</div>
<div class="doll__figure" role="img" aria-label="Ausgerüsteter Charakter"></div>
<div class="doll__column">
@for (slot of dollRight; track slot) {
<ng-container
*ngTemplateOutlet="dollSlot; context: { label: slotLabels[slot], entry: equipment.slots[slot] }"
/>
}
</div>
<div class="doll__below">
<ng-container
*ngTemplateOutlet="
dollSlot;
context: { label: slotLabels[dollBelow], entry: equipment.slots[dollBelow] }
"
/>
</div>
</div>
}
</section>
@if (inventoryStore.equipment(); as equipment) {
<section class="ar-panel panel--values" aria-label="Werte">
<h2 class="ar-panel__title">Werte</h2>
<dl class="values" data-inventory-stats>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Leben</dt>
<dd>{{ equipment.stats.maxHp }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Angriff</dt>
<dd>{{ equipment.stats.attack }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Waffenschaden</dt>
<dd>{{ equipment.stats.weaponDamage }}</dd>
</div>
<div class="values__row">
<dt><span class="values__glyph" aria-hidden="true"></span>Rüstung</dt>
<dd>{{ equipment.stats.armor }}</dd>
</div>
</dl>
</section>
}
</div>
<!-- ---------- the bag ---------- -->
<section class="ar-panel panel--bag" aria-label="Gegenstände">
<h2 class="ar-panel__title">Beutel</h2>
<div class="bag">
@for (entry of bagCells(); track $index) {
@if (entry) {
<button
type="button"
class="cell inventory-page__slot"
[class.cell--selected]="inventoryStore.selectedItemId() === entry.id"
[class.cell--rare]="entry.item.rarity === 'RARE'"
[class.cell--epic]="entry.item.rarity === 'EPIC'"
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
[attr.aria-label]="entry.item.name"
(click)="selectItem(entry.id)"
>
<img class="cell__icon" [src]="entry.item.iconPath" alt="" />
@if (entry.quantity > 1) {
<span class="cell__quantity">{{ entry.quantity }}</span>
}
@if (entry.equipped) {
<span class="cell__equipped" data-slot-equipped>
<span class="visually-hidden">Ausgerüstet</span>
</span>
}
</button>
} @else {
<div class="cell cell--empty" aria-hidden="true"></div>
}
}
</div>
<footer class="bag__footer">
<span class="bag__count">{{ bagUsed() }} / {{ bagCapacity() }} Plätze belegt</span>
@if (bagUsed() === 0) {
<span class="bag__hint" data-inventory-empty>Noch keine Gegenstände gefunden.</span>
}
</footer>
</section>
<!-- ---------- selected item ---------- -->
<app-inventory-detail-panel
class="inventory-page__detail"
[item]="inventoryStore.selectedItem()"
[equippedItemInSlot]="equippedItemInSelectedSlot()"
[characterLevel]="characterLevel()"
[busy]="inventoryStore.equipping()"
(equip)="equipSelected($event)"
/>
</div>
}
@if (inventoryStore.error(); as error) {
<section class="inventory-page__notice inventory-page__notice--error" role="alert">
<p>{{ error }}</p>
<button type="button" data-inventory-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>
<!-- One equipment socket: label above, framed icon below. Filled sockets are
buttons so the equipped piece can be inspected like any other item. -->
<ng-template #dollSlot let-label="label" let-entry="entry">
<div class="socket">
<span class="socket__label">{{ label }}</span>
@if (entry) {
<button
type="button"
class="socket__frame socket__frame--filled"
[class.socket__frame--rare]="entry.item.rarity === 'RARE'"
[class.socket__frame--epic]="entry.item.rarity === 'EPIC'"
[class.socket__frame--selected]="inventoryStore.selectedItemId() === entry.characterItemId"
[attr.aria-label]="label + ': ' + entry.item.name"
(click)="selectItem(entry.characterItemId)"
>
<img [src]="entry.item.iconPath" alt="" />
</button>
<span class="visually-hidden">{{ entry.item.name }}</span>
} @else {
<span class="socket__frame socket__frame--empty">
<span class="visually-hidden">Leer</span>
</span>
}
</div>
</ng-template>

View File

@@ -0,0 +1,397 @@
:host {
display: block;
block-size: 100%;
}
/* Fits the viewport instead of growing it, so the bag scrolls inside its own
panel rather than dragging the whole screen down. Same measure the combat
stage uses for the shell chrome above and below. */
.inventory-page {
display: flex;
flex-direction: column;
block-size: max(34rem, calc(100dvh - 12rem));
min-block-size: 0;
overflow: hidden;
}
.inventory-page__title {
margin: 0 0 var(--ar-space-4);
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.5rem, 2.6vw, 2rem);
font-weight: 400;
letter-spacing: 0.04em;
text-shadow: 0 0.1rem 0.6rem rgb(0 0 0 / 0.8);
}
/* Three rails: what you wear, what you carry, what you are looking at. */
.inventory-page__columns {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(18rem, 21rem) minmax(0, 1fr) minmax(16rem, 19rem);
gap: var(--ar-space-4);
align-items: stretch;
min-block-size: 0;
}
/* The doll takes the slack; the derived values sit under it like a plate. */
.inventory-page__rail {
display: grid;
grid-template-rows: 1fr auto;
gap: var(--ar-space-4);
min-block-size: 0;
}
/* Panel chrome (.ar-panel) lives in styles.scss — every screen shares it. */
.panel--doll {
display: grid;
grid-template-rows: auto 1fr;
min-block-size: 0;
}
/* Title, cells, then the count pinned to the bottom edge. */
.panel--bag {
display: grid;
grid-template-rows: auto auto 1fr;
min-block-size: 0;
}
/* ---------- paper doll ---------- */
.doll {
display: grid;
grid-template-columns: auto minmax(3.5rem, 1fr) auto;
grid-template-rows: minmax(0, 1fr) auto;
gap: var(--ar-space-2) var(--ar-space-3);
min-block-size: 0;
}
.doll__column {
display: grid;
grid-row: 1;
gap: var(--ar-space-3);
align-content: center;
}
/* The same warrior that fights on the combat screen stands here wearing the
result. Frame 0 of the six-frame attack sheet is the resting stance; the box
is kept square so that frame lands at its own proportions and never smears. */
.doll__figure {
grid-row: 1;
grid-column: 2;
z-index: 0;
place-self: center;
aspect-ratio: 1;
/* Wider than its column: the character stands behind the sockets rather than
squeezed between them, as in the concept. */
inline-size: 165%;
max-block-size: 100%;
pointer-events: none;
background-image: url('/images/character/female-320.png');
background-repeat: no-repeat;
background-position: center;
background-size: contain;
filter: drop-shadow(0 0.4rem 0.9rem rgb(0 0 0 / 0.75));
height: 100%;
}
.doll__below {
grid-row: 2;
grid-column: 2;
justify-self: center;
}
.socket {
position: relative;
z-index: 1;
display: grid;
gap: 0.15rem;
justify-items: center;
}
.socket__label {
color: var(--ar-text-muted);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.socket__frame {
display: grid;
place-items: center;
inline-size: 3.35rem;
block-size: 3.35rem;
padding: 0.2rem;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.75);
}
.socket__frame img {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}
/* An empty socket is a recess, not a pattern — quiet enough that a filled
one is what the eye lands on. */
.socket__frame--empty {
border-color: rgb(85 74 57 / 0.55);
background: linear-gradient(180deg, #141719, #0a0d0f);
}
.socket__frame--filled {
border-color: var(--ar-border-highlight);
}
.socket__frame--filled:hover {
border-color: var(--ar-gold);
}
.socket__frame--rare {
border-color: var(--ar-blue);
}
.socket__frame--epic {
border-color: var(--ar-gold);
}
.socket__frame--selected {
outline: 1px solid var(--ar-blue);
outline-offset: 1px;
}
/* ---------- values ---------- */
.values {
display: grid;
gap: 0.1rem;
margin: 0;
}
.values__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--ar-space-3);
padding-block: 0.3rem;
}
.values__row + .values__row {
border-block-start: 1px solid rgb(85 74 57 / 0.32);
}
.values dt {
display: flex;
align-items: baseline;
gap: var(--ar-space-2);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.values__glyph {
color: var(--ar-border-highlight);
font-size: 0.8rem;
}
.values dd {
margin: 0;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
font-variant-numeric: tabular-nums;
}
/* ---------- the bag ---------- */
.bag {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: var(--ar-space-2);
align-content: start;
min-block-size: 0;
overflow-y: auto;
scrollbar-color: var(--ar-border) transparent;
scrollbar-width: thin;
/* Room for the scrollbar so cells never sit under it. */
padding-inline-end: var(--ar-space-2);
}
.cell {
position: relative;
aspect-ratio: 1;
padding: 0.42rem;
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, #1b1f22, #0d1012);
box-shadow: inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.7);
}
.cell--empty {
border-color: rgb(85 74 57 / 0.45);
background: linear-gradient(180deg, #141719, #0a0d0f);
}
.cell.inventory-page__slot:hover {
border-color: var(--ar-border-highlight);
}
.cell--rare {
border-color: rgb(92 169 216 / 0.75);
}
.cell--epic {
border-color: rgb(201 164 95 / 0.8);
}
.cell--selected {
border-color: var(--ar-blue);
box-shadow:
inset 0 0.15rem 0.5rem rgb(0 0 0 / 0.7),
0 0 0 1px var(--ar-blue),
0 0 0.7rem rgb(92 169 216 / 0.45);
}
.cell__icon {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
}
.cell__quantity {
position: absolute;
inset-block-end: 0.1rem;
inset-inline-end: 0.25rem;
color: var(--ar-text);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 0.3rem #000, 0 0 0.2rem #000;
}
/* Equipped pieces live on the doll; in the bag they carry a quiet gold notch. */
.cell__equipped {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 0;
block-size: 0;
border-block-start: 0.55rem solid var(--ar-gold);
border-inline-end: 0.55rem solid transparent;
}
.bag__footer {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-3);
align-items: end;
justify-content: space-between;
align-self: end;
inline-size: 100%;
margin-block-start: var(--ar-space-4);
padding-block-start: var(--ar-space-3);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.bag__count {
font-variant-numeric: tabular-nums;
}
.bag__hint {
font-style: italic;
}
/* ---------- notices ---------- */
.inventory-page__notice {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
text-align: center;
}
.inventory-page__notice--error {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-4);
align-items: center;
justify-content: space-between;
margin-block-start: var(--ar-space-4);
border: 1px solid var(--ar-danger);
background: var(--ar-panel);
color: var(--ar-danger);
text-align: start;
}
.inventory-page__notice--error p {
margin: 0;
}
.inventory-page__notice--error button {
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #23282c, #14181b);
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-page__notice--error button:hover {
border-color: var(--ar-gold);
color: var(--ar-gold);
}
/* ---------- responsive ---------- */
/* Once the columns start stacking the screen is taller than the viewport, so
the page goes back to scrolling as a whole and the bag stops scrolling
inside itself — two nested scrollers would fight each other. */
@media (width < 76rem) {
.inventory-page {
block-size: auto;
overflow: visible;
}
.inventory-page__columns {
grid-template-columns: minmax(16rem, 18rem) minmax(0, 1fr);
align-items: start;
}
.inventory-page__detail {
grid-column: 1 / -1;
}
.bag {
grid-template-columns: repeat(6, minmax(0, 1fr));
overflow-y: visible;
}
.panel--doll,
.panel--bag {
grid-template-rows: none;
}
.doll {
grid-template-rows: none;
}
.doll__figure {
inline-size: 100%;
min-block-size: 12rem;
}
}
@media (width < 52rem) {
.inventory-page {
padding: var(--ar-space-3);
}
.inventory-page__columns {
grid-template-columns: minmax(0, 1fr);
}
.bag {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,210 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { vi } from 'vitest';
import type { CharacterResponse, EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
import { WorldStore } from '../world/world.store';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
import { InventoryPageComponent } from './inventory-page.component';
import { InventoryStore } from './inventory.store';
const inventory: InventoryResponse = {
items: [
{
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
},
],
};
const equipment: EquipmentResponse = {
slots: {
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
},
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
};
const character: CharacterResponse = {
id: 'character-1',
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
hpRegenPerSecond: 1,
hpRegenSince: null,
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' },
};
interface SetupOptions {
inventoryData?: InventoryResponse;
selectedItemId?: string | null;
selectedItem?: InventoryItem | null;
character?: CharacterResponse | null;
}
async function setup(options: SetupOptions = {}) {
const inventoryStore = {
inventory: signal(options.inventoryData ?? inventory),
equipment: signal(equipment),
selectedItemId: signal<string | null>(options.selectedItemId ?? null),
loading: signal(false),
equipping: signal(false),
error: signal<string | null>(null),
load: vi.fn(() => Promise.resolve()),
selectItem: vi.fn(),
selectedItem: vi.fn(() => options.selectedItem ?? null),
equip: vi.fn(() => Promise.resolve()),
};
const worldStore = {
character: signal(options.character === undefined ? character : options.character),
load: vi.fn(() => Promise.resolve()),
};
await TestBed.configureTestingModule({
imports: [InventoryPageComponent],
providers: [
{ provide: InventoryStore, useValue: inventoryStore },
{ provide: WorldStore, useValue: worldStore },
],
}).compileComponents();
const fixture = TestBed.createComponent(InventoryPageComponent);
fixture.detectChanges();
return { fixture, inventoryStore, worldStore };
}
describe('InventoryPageComponent', () => {
it('loads the inventory on init', async () => {
const { inventoryStore } = await setup();
expect(inventoryStore.load).toHaveBeenCalledOnce();
});
it('loads the world state on init when no character has been loaded yet (direct navigation/hard refresh)', async () => {
const { worldStore } = await setup({ character: null });
expect(worldStore.load).toHaveBeenCalledOnce();
});
it('does not call world load again when a character is already present', async () => {
const { worldStore } = await setup();
expect(worldStore.load).not.toHaveBeenCalled();
});
it('renders one tile per owned item', async () => {
const { fixture } = await setup();
const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot');
expect(tiles.length).toBe(2);
});
it('marks the equipped item with a badge', async () => {
const { fixture } = await setup();
expect((fixture.nativeElement as HTMLElement).querySelector('[data-slot-equipped]')).not.toBeNull();
});
it('selects an item when its tile is clicked', async () => {
const { fixture, inventoryStore } = await setup();
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('.inventory-page__slot')[1].click();
expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade');
});
it('shows the equipment overview with all seven slots and empty ones as Leer', async () => {
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? '';
expect(text).toContain('Waffe');
expect(text).toContain('Abgenutztes Kurzschwert');
expect(text).toContain('Kopf');
expect(text).toContain('Leer');
});
it('shows the effective stats summary from the equipment response', async () => {
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? '';
expect(text).toContain('100');
expect(text).toContain('6');
expect(text).toContain('8');
});
it('passes the equipped item from the SAME slot as the selection — not just any equipped item — to the detail panel', async () => {
// item-helm (HEAD, equipped) is placed before item-sword (WEAPON, equipped) so that a
// regression which drops the equipmentSlot match (i.e. "find the first equipped item")
// would surface item-helm instead of item-sword, and this test would fail.
const threeItemInventory: InventoryResponse = {
items: [
{
id: 'item-helm',
quantity: 1,
equipped: true,
item: {
key: 'iron-helm',
name: 'Eiserner Helm',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'HEAD',
requiredLevel: 1,
weaponDamage: 0,
bonusAttack: 0,
bonusHp: 5,
bonusArmor: 2,
iconPath: '/images/items/iron-helm.png',
},
},
inventory.items[0], // item-sword, WEAPON, equipped
inventory.items[1], // item-blade, WEAPON, not equipped — this is the selection
],
};
const { fixture } = await setup({
inventoryData: threeItemInventory,
selectedItemId: 'item-blade',
selectedItem: threeItemInventory.items[2],
});
const panel = fixture.debugElement.query(By.directive(InventoryDetailPanelComponent))
.componentInstance as InventoryDetailPanelComponent;
expect(panel.equippedItemInSlot()?.id).toBe('item-sword');
});
});

View File

@@ -0,0 +1,89 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, OnInit, computed, inject } from '@angular/core';
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
import { WorldStore } from '../world/world.store';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
import { SLOT_LABELS } from './inventory.labels';
import { InventoryStore } from './inventory.store';
/**
* The paper doll reads as a body: weapon hand and worn trinkets down the left,
* armour down the right, legs beneath the figure. Only the seven slots the
* game actually models appear — no decorative rings or offhand (spec §38).
*/
const DOLL_LEFT: readonly EquipmentSlot[] = ['WEAPON', 'AMULET', 'HANDS'];
const DOLL_RIGHT: readonly EquipmentSlot[] = ['HEAD', 'CHEST', 'FEET'];
const DOLL_BELOW: EquipmentSlot = 'LEGS';
// Every slot, in the order the equipment list announces them.
const SLOT_ORDER: readonly EquipmentSlot[] = [...DOLL_LEFT, ...DOLL_RIGHT, DOLL_BELOW];
// The bag is drawn as a fixed grid so it reads as a container with room left,
// not as a list that happens to be short. Capacity is not enforced yet
// (spec §31): the empty cells are structure, not a limit.
const BAG_COLUMNS = 8;
const BAG_MIN_CELLS = 64;
@Component({
selector: 'app-inventory-page',
imports: [NgTemplateOutlet, InventoryDetailPanelComponent],
templateUrl: './inventory-page.component.html',
styleUrl: './inventory-page.component.scss',
})
export class InventoryPageComponent implements OnInit {
protected readonly inventoryStore = inject(InventoryStore);
private readonly worldStore = inject(WorldStore);
protected readonly dollLeft = DOLL_LEFT;
protected readonly dollRight = DOLL_RIGHT;
protected readonly dollBelow = DOLL_BELOW;
protected readonly slotOrder = SLOT_ORDER;
protected readonly slotLabels = SLOT_LABELS;
protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1);
/**
* Pads the owned items out to a full rectangle of cells. `null` is an empty
* slot; the grid never ends on a ragged row.
*/
protected readonly bagCells = computed<(InventoryItem | null)[]>(() => {
const items = this.inventoryStore.inventory()?.items ?? [];
const filled = Math.ceil(items.length / BAG_COLUMNS) * BAG_COLUMNS;
const total = Math.max(BAG_MIN_CELLS, filled);
return Array.from({ length: total }, (_, index) => items[index] ?? null);
});
protected readonly bagUsed = computed(() => this.inventoryStore.inventory()?.items.length ?? 0);
protected readonly bagCapacity = computed(() => this.bagCells().length);
protected readonly equippedItemInSelectedSlot = computed(() => {
const selected = this.inventoryStore.selectedItem();
if (!selected?.item.equipmentSlot) {
return null;
}
return (
this.inventoryStore.inventory()?.items.find(
(item) => item.equipped && item.item.equipmentSlot === selected.item.equipmentSlot,
) ?? null
);
});
ngOnInit(): void {
if (this.worldStore.character() === null) {
void this.worldStore.load();
}
void this.inventoryStore.load();
}
protected selectItem(itemId: string): void {
this.inventoryStore.selectItem(itemId);
}
protected retry(): void {
void this.inventoryStore.load();
}
protected async equipSelected(characterItemId: string): Promise<void> {
await this.inventoryStore.equip(characterItemId);
}
}

View File

@@ -0,0 +1,12 @@
import type { EquipmentSlot } from '../../core/api/game-api.models';
/** German slot names, shared by the paper doll and the item detail panel. */
export const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};

View File

@@ -0,0 +1,147 @@
import { TestBed } from '@angular/core/testing';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type { EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
import { InventoryStore } from './inventory.store';
const inventory: InventoryResponse = {
items: [
{
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
description: 'Beschreibung des Gegenstands.',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
},
],
};
const equipment: EquipmentResponse = {
slots: {
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
},
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
};
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' } } },
stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 },
};
const inventoryAfterEquip: InventoryResponse = {
items: [
{ ...inventory.items[0], equipped: false },
{ ...inventory.items[1], equipped: true },
],
};
describe('InventoryStore', () => {
let api: {
getInventory: ReturnType<typeof vi.fn>;
getEquipment: ReturnType<typeof vi.fn>;
equipItem: ReturnType<typeof vi.fn>;
};
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
let store: InventoryStore;
beforeEach(() => {
api = {
getInventory: vi.fn(() => of(inventory)),
getEquipment: vi.fn(() => of(equipment)),
equipItem: vi.fn(() => of(equippedAfter)),
};
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
TestBed.configureTestingModule({
providers: [
InventoryStore,
{ provide: GameApiService, useValue: api },
{ provide: WorldStore, useValue: worldStore },
],
});
store = TestBed.inject(InventoryStore);
});
it('loads inventory and equipment together', async () => {
await store.load();
expect(store.inventory()).toEqual(inventory);
expect(store.equipment()).toEqual(equipment);
});
it('selects an item by id', async () => {
await store.load();
store.selectItem('item-blade');
expect(store.selectedItemId()).toBe('item-blade');
expect(store.selectedItem()).toEqual(inventory.items[1]);
});
it('equips the selected item, refreshes inventory/equipment, and refreshes the character HUD', async () => {
api.getInventory
.mockReturnValueOnce(of(inventory)) // initial load()
.mockReturnValueOnce(of(inventoryAfterEquip)); // post-equip refetch
await store.load();
await store.equip('item-blade');
expect(api.equipItem).toHaveBeenCalledWith('item-blade');
expect(store.equipment()).toEqual(equippedAfter);
expect(store.inventory()).toEqual(inventoryAfterEquip);
expect(store.inventory()?.items.find((item) => item.id === 'item-sword')?.equipped).toBe(false);
expect(store.inventory()?.items.find((item) => item.id === 'item-blade')?.equipped).toBe(true);
expect(worldStore.refreshCharacter).toHaveBeenCalledOnce();
});
it('surfaces a German message for a known equip error', async () => {
await store.load();
api.equipItem.mockReturnValue(
throwError(() => new HttpErrorResponse({ error: { code: 'ITEM_LEVEL_REQUIREMENT_NOT_MET' }, status: 400 })),
);
await store.equip('item-blade');
expect(store.error()).toBe('Du erfüllst die Stufenanforderung nicht.');
});
});

View File

@@ -0,0 +1,96 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom, forkJoin } from 'rxjs';
import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.';
// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`.
const EQUIPMENT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
CHARACTER_ITEM_NOT_FOUND: 'Dieser Gegenstand konnte nicht gefunden werden.',
ITEM_NOT_OWNED: 'Dieser Gegenstand gehört dir nicht.',
ITEM_NOT_EQUIPPABLE: 'Dieser Gegenstand kann nicht ausgerüstet werden.',
ITEM_LEVEL_REQUIREMENT_NOT_MET: 'Du erfüllst die Stufenanforderung nicht.',
INVALID_EQUIPMENT_SLOT: 'Dieser Ausrüstungsplatz ist ungültig.',
CHARACTER_IN_COMBAT: 'Ausrüstung kann während eines Kampfes nicht geändert werden.',
};
@Injectable({ providedIn: 'root' })
export class InventoryStore {
private readonly inventoryState = signal<InventoryResponse | null>(null);
private readonly equipmentState = signal<EquipmentResponse | null>(null);
private readonly selectedItemIdState = signal<string | null>(null);
private readonly loadingState = signal(false);
private readonly equippingState = signal(false);
private readonly errorState = signal<string | null>(null);
readonly inventory = this.inventoryState.asReadonly();
readonly equipment = this.equipmentState.asReadonly();
readonly selectedItemId = this.selectedItemIdState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly equipping = this.equippingState.asReadonly();
readonly error = this.errorState.asReadonly();
constructor(
private readonly api: GameApiService,
private readonly worldStore: WorldStore,
) {}
async load(): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const { inventory, equipment } = await firstValueFrom(
forkJoin({ inventory: this.api.getInventory(), equipment: this.api.getEquipment() }),
);
this.inventoryState.set(inventory);
this.equipmentState.set(equipment);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
selectItem(characterItemId: string | null): void {
this.selectedItemIdState.set(characterItemId);
}
selectedItem(): InventoryItem | null {
const id = this.selectedItemIdState();
if (!id) {
return null;
}
return this.inventoryState()?.items.find((item) => item.id === id) ?? null;
}
/** Equips an item, then refreshes inventory/equipment and the character HUD (spec §35, §40). */
async equip(characterItemId: string): Promise<void> {
if (this.equippingState()) {
return;
}
this.equippingState.set(true);
this.errorState.set(null);
try {
const equipment = await firstValueFrom(this.api.equipItem(characterItemId));
this.equipmentState.set(equipment);
const inventory = await firstValueFrom(this.api.getInventory());
this.inventoryState.set(inventory);
await this.worldStore.refreshCharacter();
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.equippingState.set(false);
}
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && EQUIPMENT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
}
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
}
}

View File

@@ -0,0 +1,192 @@
import type {
CurrentLocationResponse,
LocationPointOfInterest,
LocationPrimaryAction,
} from '../../core/api/game-api.models';
/**
* Test fixtures for `GET /api/world/current-location`.
*
* Shared rather than re-declared per spec: the payload backs the map, the
* hunt screen and the local location view, so a field added to the contract
* needs to be answered in exactly one place.
*/
export const BURNED_ROAD_POIS: LocationPointOfInterest[] = [
{
key: 'hunt-area',
title: 'Jagdgebiet',
actionLabel: 'Jagd beginnen',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 52,
yPercent: 44,
enabled: true,
},
{
key: 'inspect-tracks',
title: 'Verdächtige Spuren',
actionLabel: 'Untersuchen',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 32,
yPercent: 78,
enabled: true,
},
{
key: 'search-abandoned-wagon',
title: 'Verlassener Wagen',
actionLabel: 'Durchsuchen',
type: 'SEARCH',
iconKey: 'search',
xPercent: 80,
yPercent: 68,
enabled: true,
},
{
key: 'wounded-scout',
title: 'Verwundeter Kundschafter',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 20,
yPercent: 60,
enabled: true,
},
];
export const BURNED_ROAD_ACTIONS: LocationPrimaryAction[] = [
{
key: 'start-hunt',
label: 'Jagd beginnen',
description: 'Im Gebiet jagen',
type: 'HUNT',
iconKey: 'hunt',
enabled: true,
},
{
key: 'investigate-tracks',
label: 'Spuren untersuchen',
description: 'Hinweise finden',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'inspect-tracks',
},
{
key: 'search-surroundings',
label: 'Umgebung durchsuchen',
description: 'Beute finden',
type: 'SEARCH',
iconKey: 'search',
enabled: true,
poiKey: 'search-abandoned-wagon',
},
{
key: 'open-map',
label: 'Zur Karte',
description: 'Gebiet wechseln',
type: 'MAP',
iconKey: 'map',
enabled: true,
},
];
export function southGateFixture(
overrides: Partial<CurrentLocationResponse> = {},
): CurrentLocationResponse {
return {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
regionName: 'Aschenfelder',
regionTierLabel: 'Gebiet 1',
locationType: 'TRANSITION',
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
localArtworkPath: '/images/backgrounds/Suedtor.png',
dangerRating: null,
recommendationLabel: '1',
pointsOfInterest: [],
primaryActions: [],
encounterPreview: [],
rewardPreview: [],
connections: [
{
targetLocation: {
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
},
travelDurationSeconds: 10,
danger: 'LOW',
},
],
possibleMonsters: [],
...overrides,
};
}
export function burnedRoadFixture(
overrides: Partial<CurrentLocationResponse> = {},
): CurrentLocationResponse {
return southGateFixture({
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
maxRecommendedLevel: 2,
dangerLevel: 1,
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
locationType: 'HUNTING_GROUND',
localDescription:
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
dangerRating: 'MATCH',
recommendationLabel: '12',
pointsOfInterest: BURNED_ROAD_POIS,
primaryActions: BURNED_ROAD_ACTIONS,
encounterPreview: [
{
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
iconPath: '/images/combat/icons/ash-rat-128.png',
},
{
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
iconPath: '/images/combat/icons/road-bandit-128.png',
},
],
rewardPreview: [
{ key: 'silver', label: 'Silber', iconKey: 'silver' },
{ key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
{ key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' },
{ key: 'material', label: 'Material', iconKey: 'material' },
],
connections: [
{
targetLocation: {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
},
travelDurationSeconds: 10,
danger: 'LOW',
},
],
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
...overrides,
});
}

Some files were not shown because too many files have changed in this diff Show More