diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a853fd4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1175 @@ +# AGENTS.md — Ashen Realms + +## Purpose + +This file defines the default working rules for AI coding agents contributing to **Ashen Realms**. + +Ashen Realms is a modern, browser-based dark-fantasy PvE RPG inspired by the structure and long-term progression of classic browser MMORPGs, while using a modern UI, server-authoritative game logic, and a data-driven architecture. + +Agents must treat the existing project documentation and implemented code as the source of truth. Do not redesign core systems, introduce new architecture, or generalize systems beyond current requirements unless explicitly requested. + +--- + +# 1. Project Priorities + +When making implementation decisions, use this priority order: + +1. Preserve the core gameplay loop. +2. Preserve server authority and data integrity. +3. Keep systems simple enough for the current development stage. +4. Prefer reusable domain systems over content-specific special cases. +5. Maintain UI consistency with the existing visual language. +6. Keep implementation testable and understandable. +7. Avoid speculative architecture for future MMO-scale requirements. + +Core gameplay loop: + +```text +Explore +→ Travel +→ Hunt / Search +→ Choose encounter +→ Fight +→ Receive loot / progression +→ Improve character +→ Defeat stronger challenges +→ Discover new locations +``` + +The project should first be a good RPG and only later become a larger MMO. + +--- + +# 2. Required Reading Before Major Changes + +Before implementing or changing a larger gameplay, architecture, persistence, or UI feature, inspect the relevant documentation in `docs/`. + +Important project documents include: + +```text +docs/design-manifest.md +docs/vertical-slice-world-content-design.md +docs/balancing-items-loot-design.md +docs/ui-visual-design-specification.md +``` + +Additional feature specifications may exist in `docs/` and override older assumptions where they explicitly redefine a system. + +Do not rely only on this file when a more specific feature specification exists. + +--- + +# 3. Source-of-Truth Order + +If multiple sources conflict, use the following precedence unless the task explicitly says otherwise: + +1. Explicit requirements in the current task. +2. Newer dedicated feature specification. +3. Newer project documentation. +4. Existing implemented behavior and tests. +5. `AGENTS.md`. +6. Older design documents. + +Do not silently reconcile conflicting requirements. + +If a conflict affects behavior or data design, point it out before making a broad architectural reinterpretation. + +--- + +# 4. Current Technical Architecture + +Ashen Realms is implemented as a **modular monolith**. + +## Stack + +```text +Frontend: Angular +Backend: NestJS +Language: TypeScript +Database: PostgreSQL +ORM: TypeORM +Monorepo: npm Workspaces +API: REST +Deployment: one Ashen Realms application container +Database: separate persistent PostgreSQL service +``` + +Typical repository structure: + +```text +apps/ + web/ + api/ + +packages/ + shared/ + game-content/ + +docs/ +``` + +Production behavior: + +```text +Browser + ↓ +NestJS + ├── /api/* → REST API + └── /* → built Angular application + ↓ +PostgreSQL +``` + +NestJS is the only runtime process in the application container. + +Do not introduce a second production web server for Angular. + +--- + +# 5. Server Authority Is Mandatory + +Critical gameplay logic is server-authoritative. + +The client may display state and send intentions, but must not decide authoritative gameplay results. + +The server owns at least: + +- character state +- current HP +- effective stats +- combat state +- combat results +- damage +- enemy actions +- loot rolls +- inventory +- equipment +- currencies +- reputation / progression +- travel state +- travel completion +- encounter generation +- quest progress +- item ownership +- regeneration state + +Client requests should express actions, not results. + +Good: + +```json +{ + "action": "ATTACK" +} +``` + +Bad: + +```json +{ + "action": "ATTACK", + "damage": 42 +} +``` + +Never trust client-provided values that the server can calculate or validate itself. + +--- + +# 6. API Rules + +All API routes use the prefix: + +```text +/api +``` + +Frontend requests must use relative URLs. + +Good: + +```ts +this.http.get('/api/characters/me'); +``` + +Bad: + +```ts +this.http.get('http://localhost:3000/api/characters/me'); +``` + +Use consistent domain errors. + +Example: + +```json +{ + "statusCode": 400, + "code": "INVALID_TRAVEL_TARGET", + "message": "The selected location is not connected to the current location." +} +``` + +Prefer stable machine-readable error codes over UI-dependent text matching. + +--- + +# 7. Data and Persistence Rules + +## TypeORM + +Use TypeORM migrations for schema changes. + +Production must not use: + +```ts +synchronize: true +``` + +Schema workflow: + +```text +change entity +→ create/generate migration +→ inspect migration +→ run migration +→ run tests +``` + +Never accept unexpected destructive migration output without reviewing it. + +## Content vs Player State + +Keep static or semi-static game content separate from player-specific persistent state. + +Examples of content definitions: + +```text +LocationDefinition +LocationConnection +MonsterDefinition +ItemDefinition +LootTable +NPC definition +Quest definition +Shop definition +``` + +Examples of player state: + +```text +User +Character +CharacterItem +CharacterEquipment +Travel +Hunt +HuntEncounter +Combat +CombatEvent +QuestProgress +Reputation +``` + +Do not duplicate content definitions into every player record. + +--- + +# 8. Stable Content Keys + +Content should have stable human-readable keys in addition to database IDs where appropriate. + +Examples: + +```text +south-gate +burned-road +ash-rat +road-bandit +worn-short-sword +``` + +Use stable keys for seeds, cross-content references, configuration, and tests where this improves maintainability. + +Do not hard-code random UUIDs across fixtures or content definitions. + +Seeds must be idempotent whenever practical. + +Running a seed repeatedly must not create duplicate content. + +--- + +# 9. Gameplay Systems Should Be Data-Driven + +Repeated game content should be modeled as data rather than one-off code. + +This applies especially to: + +- locations +- travel connections +- monsters +- encounter pools +- items +- loot +- NPCs +- shops +- abilities +- quests +- reputation requirements +- enemy categories +- drop categories + +Prefer: + +```text +shared mechanic + content configuration +``` + +over: + +```text +if monster == "special-monster-x" then custom branch +``` + +However, do not over-generalize before multiple real use cases exist. + +A special case is acceptable when the abstraction would be more complex than the current requirement. + +--- + +# 10. Combat Design Rules + +Combat is round-based. + +The player chooses one main action per turn. + +The combat system should remain deterministic where the current rules define deterministic behavior. + +Core values currently revolve around: + +```text +HP +Attack +Weapon Damage +Armor +``` + +The original V1 combat model uses: + +```text +Raw Damage = Weapon Damage + Attack +``` + +and armor mitigation based on: + +```text +Damage = Raw Damage × 60 / (60 + Armor) +``` + +Minimum successful damage: + +```text +1 +``` + +Do not add systems such as critical hits, dodge, accuracy, random damage ranges, elemental resistance, mana, or complex action points unless a newer dedicated specification introduces them. + +Enemy difficulty should come primarily from: + +- meaningful stats +- telegraphed actions +- status effects +- defensive states +- interrupts +- phase behavior +- encounter composition + +not hidden randomness. + +--- + +# 11. Combat Engine Separation + +Pure combat rules should be kept separate from persistence and HTTP concerns. + +Preferred structure: + +```text +CombatController + ↓ +CombatService + ↓ +CombatEngineService +``` + +`CombatEngineService` should ideally: + +- receive game state +- receive an action +- return resulting state/events +- avoid direct database access +- be easy to unit test + +`CombatService` should: + +- load persistent state +- validate ownership and turn rules +- invoke the engine +- persist events/state +- handle victory/defeat +- trigger loot/progression +- commit atomically where necessary + +Do not bury combat formulas inside controllers or Angular components. + +--- + +# 12. Realtime and Event Delivery + +Realtime communication is an event transport layer, not the authoritative game state itself. + +The project is moving toward a central game-event connection suitable for systems such as: + +- multiplayer combat +- delayed NPC actions +- pets / companions +- turn notifications +- combat state changes +- selected character-state updates + +Prefer one authenticated realtime connection with typed event channels/messages rather than one independent socket connection per feature. + +Examples of event families: + +```text +combat.* +character.* +travel.* +system.* +``` + +The server remains authoritative. + +The client must still be able to recover state through normal APIs after reconnecting. + +Do not make correctness depend solely on receiving every realtime event. + +--- + +# 13. Travel Rules + +Travel is server-authoritative. + +The server calculates: + +```text +startedAt +arrivesAt +origin +target +travel state +possible encounter +``` + +The client may render a countdown using the server-provided timestamp. + +Never move the character merely because a browser timer reached zero. + +Travel completion must be validated or finalized by the server. + +Travel duration is part of the game-world feeling, not just an arbitrary cooldown. + +--- + +# 14. Hunting and Encounters + +The player does not directly request arbitrary monsters to fight. + +Preferred flow: + +```text +start hunt/search +→ server creates valid encounter choices +→ player selects one encounter +→ combat starts from that persisted encounter +``` + +Use encounter IDs or equivalent server-issued references. + +Do not allow: + +```text +POST /combat +{ + "monsterId": "anything-the-client-wants" +} +``` + +without server-side validation that the encounter is actually available to that character. + +--- + +# 15. Progression Direction + +Ashen Realms is evolving away from a classic: + +```text +kill monster +→ receive XP + money +→ level up +``` + +model. + +Current project direction emphasizes: + +- reputation / renown +- region reputation +- world-level progression +- monster materials +- exchanging materials through NPCs / merchants +- reputation-gated offers +- meaningful inventory/bag constraints +- loot and equipment as primary combat progression + +When touching XP, direct monster currency rewards, level gating, merchants, drops, or progression, inspect the newest progression/reputation specifications before using older V1 assumptions. + +Do not reintroduce old XP-based progression just because older design documents still contain it. + +--- + +# 16. Item and Loot Philosophy + +Items must be understandable and meaningful. + +Prefer handcrafted items with fixed identity over random-affix chaos. + +A good item should create a visible upgrade or gameplay decision. + +Loot should be targeted enough that the player can understand why a specific enemy is worth fighting. + +General principle: + +```text +Drops create excitement. +Deterministic progression prevents frustration. +``` + +Bosses and important content should not regularly produce meaningless rewards. + +Avoid huge undifferentiated loot tables. + +--- + +# 17. Bags and Material Categories + +The project uses or plans constrained bags for certain material categories. + +This system is intended to make carrying capacity part of progression without turning the full inventory into weight micromanagement. + +When implementing drops and inventory: + +- distinguish normal inventory from specialized material storage where specified +- support monster/drop categories as data +- do not hard-code category behavior into individual monsters +- keep quest/story items separate from normal inventory capacity where appropriate + +Inspect the dedicated bag-system specification before implementing or modifying this system. + +--- + +# 18. NPC Model + +NPCs may combine multiple capabilities. + +Avoid rigid inheritance such as: + +```text +BaseNpc + ├── MerchantNpc + └── QuestGiverNpc +``` + +when an NPC can logically be both. + +Prefer composition/capabilities, for example: + +```text +NPC ++ dialogue ++ merchant capability ++ quest capability ++ reputation relationship ++ location presence +``` + +Shared NPC data may include: + +- stable key +- name +- location +- portrait/artwork +- dialogue +- availability +- reputation relationship +- interaction capabilities + +Use the dedicated NPC specification where available. + +--- + +# 19. UI Design Direction + +Ashen Realms must not look like a generic web dashboard or mobile game. + +The intended style is: + +```text +classic browser RPG structure ++ +modern premium dark-fantasy presentation +``` + +Key characteristics: + +- desktop-first +- persistent top bar +- left-side navigation +- large central artwork/content area +- contextual right-side panel +- restrained footer/status area +- dark metal / stone / leather materials +- muted colors +- limited functional accents +- strong fantasy artwork +- readable information density +- clear interaction states + +Avoid: + +- SaaS dashboard visuals +- white cards +- glassmorphism +- neon cyberpunk styling +- generic Angular Material appearance +- excessive rounded mobile cards +- stacked mobile-game popups +- arbitrary component-specific visual languages + +--- + +# 20. UI Reuse Rules + +Before creating a new screen or component: + +1. inspect existing shared layout/components +2. inspect similar screens +3. inspect design tokens/styles +4. reuse existing UI primitives where appropriate + +Likely reusable components include concepts such as: + +```text +AppShell +TopBar +SideNavigation +Footer +Panel +PanelHeader +Button variants +HealthBar +CharacterHeader +DangerBadge +EncounterCard +ItemIcon +ItemTooltip +CombatActionButton +CombatLog +PotionSlot +StatusEffectIcon +``` + +Do not duplicate the same visual pattern independently in multiple feature folders. + +--- + +# 21. Artwork Is Part of the Product + +Large artworks are a primary part of the game experience. + +UI layout should preserve visual space for: + +- locations +- monsters +- characters +- NPCs +- combat scenes +- items + +Do not convert major game screens into dense tables or grids merely because that is easier to implement. + +Gameplay information must be clear, but the world should remain visually dominant. + +--- + +# 22. Frontend Responsibilities + +Angular is responsible for: + +- rendering server state +- user input +- local UI state +- routing +- animations +- countdown display +- displaying realtime events +- presenting combat events +- accessibility and interaction states + +Angular is not authoritative for: + +- damage +- loot +- inventory ownership +- travel completion +- combat results +- stat calculation +- progression rewards +- encounter validity + +Prefer typed API contracts. + +Do not leak TypeORM entities directly into frontend assumptions. + +--- + +# 23. Shared Package Rules + +`packages/shared` should contain only genuine cross-boundary contracts and shared enums/types. + +Good examples: + +```text +DTO contracts +shared enums +event message contracts +API-facing types +``` + +Do not place: + +- NestJS services +- Angular components +- TypeORM entities +- repository implementations +- backend-only business logic + +inside `packages/shared`. + +`packages/game-content` may contain shared content schemas/enums where this is genuinely useful. + +--- + +# 24. Testing Expectations + +Changes to domain logic require tests. + +High-value test targets include: + +- combat calculations +- character effective stats +- travel validation +- regeneration logic +- encounter generation +- loot rolls +- inventory/equipment rules +- reputation changes +- bag capacity rules +- quest progression +- realtime event handling + +Use deterministic random sources in tests for random game systems. + +Do not write tests that rely on uncontrolled `Math.random()` behavior. + +For API flows, prefer integration tests around real validation boundaries. + +For frontend features, test behavior and state handling rather than fragile implementation details. + +--- + +# 25. Bug-Fix Workflow + +When fixing a bug: + +1. understand the actual failure +2. identify the authoritative layer +3. reproduce with a focused test where practical +4. implement the smallest correct fix +5. run relevant tests +6. run lint/build where appropriate +7. verify no adjacent behavior regressed + +Do not treat symptoms in Angular if the actual bug is an invalid backend state transition. + +Do not disable validation merely to make an API call pass. + +--- + +# 26. Feature Workflow + +For non-trivial features: + +1. read the relevant spec +2. inspect the current implementation +3. identify affected domain boundaries +4. define the smallest complete slice +5. add/adjust tests +6. implement backend/domain behavior +7. add persistence/migration if needed +8. expose API/realtime contract +9. implement frontend behavior +10. verify end-to-end behavior +11. update documentation if behavior or architecture changed + +Prefer vertical slices over large disconnected infrastructure work. + +--- + +# 27. Scope Discipline + +Do not add systems merely because they may be useful later. + +For V1 and early development, avoid introducing without explicit need: + +- microservices +- Kafka +- RabbitMQ +- Redis +- event sourcing +- CQRS frameworks +- GraphQL +- Kubernetes-specific architecture +- generic plugin systems +- unnecessary abstraction layers +- premature distributed locking +- generic workflow engines +- complex state machines when simple domain state is enough + +The default question is: + +```text +What is the smallest correct design for the current feature? +``` + +--- + +# 28. Avoid Premature MMO Architecture + +Future features may include: + +- parties +- multiplayer combat +- companions +- pets +- chat +- guilds +- trading +- PvP +- auctions +- crafting +- admin balancing tools + +Current code should avoid blocking those ideas, but should not fully implement infrastructure for them before needed. + +Design extensible domain boundaries, not speculative subsystems. + +--- + +# 29. Transaction Boundaries + +Use database transactions for operations that must succeed atomically. + +Examples: + +```text +combat victory +→ reward generation +→ inventory grant +→ reputation/material changes +→ combat completion +``` + +or: + +```text +equip item +→ validate ownership +→ replace current slot +→ persist equipment state +``` + +Do not leave the character in partially updated gameplay states. + +--- + +# 30. Concurrency and Idempotency + +Assume clients may retry requests or send duplicate requests. + +Important state-changing actions should reject or safely handle duplicates. + +Examples: + +- completing the same travel twice +- resolving the same combat turn twice +- claiming the same reward twice +- submitting the same quest completion twice +- buying the same transaction twice because of retry + +Where useful, enforce correctness with: + +- explicit status transitions +- database constraints +- version checks +- unique keys +- transaction locking + +Do not rely solely on the UI disabling a button. + +--- + +# 31. Character Stats + +Effective character stats must have a clear authoritative calculation path. + +Prefer a dedicated service such as: + +```text +CharacterStatsService +``` + +It should combine: + +```text +base character values ++ equipment ++ item bonuses ++ active effects ++ future set bonuses/buffs where applicable +``` + +Do not independently calculate effective stats in combat, profile, inventory, and UI code. + +Use one domain source of truth. + +--- + +# 32. HP and Regeneration + +Persistent HP and regeneration are server-owned state. + +If regeneration is timestamp-based, calculate authoritative HP using server timestamps and persisted regeneration anchors/state. + +Realtime updates may improve the UI, but must not be required for correctness. + +A reconnect or normal API refresh must be able to reconstruct the correct current HP. + +Do not implement HP regeneration as a browser-only interval. + +--- + +# 33. Naming and Language + +Code, API contracts, identifiers, database names, and new game-content source text should default to **English** unless an existing subsystem explicitly uses another convention. + +Prefer clear domain names over abbreviations. + +Good: + +```text +currentLocationId +reputationRequirement +travelDurationSeconds +monsterCategory +``` + +Avoid unclear names such as: + +```text +loc +repReq +dur +mc +``` + +Public-facing gameplay text should move toward English consistently as the project is migrated. + +--- + +# 34. TypeScript Rules + +Prefer: + +- strict types +- explicit domain types +- discriminated unions where useful +- enums/unions for finite domain states +- immutable inputs for pure engines where practical +- dependency injection for external/random/time sources when testing benefits + +Avoid: + +- `any` +- magic strings scattered across services +- duplicated status literals +- deeply nested untyped JSON blobs for core domain state + +JSONB is acceptable for flexible snapshots/events when the schema is still clearly typed in TypeScript. + +--- + +# 35. Time Handling + +Use server timestamps for authoritative gameplay timing. + +Examples: + +- travel +- cooldowns +- HP regeneration +- timed encounter state +- buffs/debuffs +- scheduled NPC/combat actions + +Prefer storing absolute timestamps such as: + +```text +startedAt +arrivesAt +expiresAt +lastRegeneratedAt +``` + +The frontend may derive display countdowns from those values. + +Do not persist countdown seconds that decrement every second unless there is a strong domain reason. + +--- + +# 36. Logging + +Use structured backend logging for meaningful domain and infrastructure failures. + +Useful context may include: + +- character ID +- combat ID +- travel ID +- encounter ID +- action +- domain error code + +Do not log secrets, tokens, passwords, or full sensitive authentication payloads. + +Avoid noisy per-frame or per-second logs. + +--- + +# 37. Security Basics + +Never trust client ownership claims. + +Always verify: + +- the authenticated user owns the character +- the character owns the item +- the encounter belongs to the character +- the combat belongs to the character +- the requested transition is currently legal + +Secrets belong in environment variables. + +Never commit real secrets. + +Do not expose internal stack traces or database details as user-facing API errors. + +--- + +# 38. Documentation Updates + +Update documentation when a change: + +- alters architecture +- changes a core gameplay rule +- replaces an older system +- introduces a reusable domain pattern +- creates a new persistent data model +- changes API/realtime conventions +- invalidates an existing implementation spec + +Do not update documentation for trivial refactors that do not change behavior. + +When replacing an old system, prefer clearly marking the old assumption obsolete rather than leaving contradictory active docs. + +--- + +# 39. Do Not Silently Change Game Design + +Coding agents may identify possible improvements, but they should not silently: + +- rebalance items +- change travel times +- change drop chances +- change combat formulas +- alter progression rules +- replace reputation requirements +- redesign UI flows +- add/remove player capabilities + +unless the requested task includes that design change. + +If implementation requires choosing an unspecified behavior, choose the smallest reversible option and make the assumption explicit. + +--- + +# 40. Definition of Done + +A feature is complete when applicable: + +- requirements from the relevant spec are implemented +- authoritative logic is on the server +- persistence is correct +- migrations exist and were reviewed +- ownership and transition validation exists +- tests cover important domain behavior +- frontend uses the authoritative API/state +- realtime is recoverable after reconnect where used +- build passes +- lint passes +- relevant tests pass +- no unrelated architecture was introduced +- documentation is updated when behavior changed + +Do not claim completion if relevant tests or builds are failing. + +--- + +# 41. Final Decision Filter + +Before adding code, ask: + +1. Does this improve the current core loop? +2. Is this required by the current specification? +3. Is the server still authoritative? +4. Can this be modeled as reusable data instead of a one-off? +5. Am I solving a current problem rather than a hypothetical future problem? +6. Does this fit the existing architecture? +7. Does the UI still feel like Ashen Realms rather than a generic web app? +8. Can the implementation be tested cleanly? +9. Does this preserve player progress and data integrity? +10. Is there a smaller correct implementation? + +When in doubt: + +> Prefer the smallest server-authoritative, data-driven solution that fits the current specification. diff --git a/apps/api/src/characters/character-stats.service.spec.ts b/apps/api/src/characters/character-stats.service.spec.ts index 7528c24..6fd1038 100644 --- a/apps/api/src/characters/character-stats.service.spec.ts +++ b/apps/api/src/characters/character-stats.service.spec.ts @@ -4,6 +4,7 @@ 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; @@ -36,12 +37,16 @@ function character(overrides: Partial = {}): Character { baseHp: 100, baseAttack: 6, currentHp: 90, + hpRegenSince: null, ...overrides, } as Character; } describe('CharacterStatsService', () => { - const service = new CharacterStatsService({} as DataSource); + 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([ @@ -117,11 +122,46 @@ describe('CharacterStatsService', () => { expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5); }); - it('passes currentHp through unchanged from the character', async () => { + it('returns the raw current HP unchanged while regeneration is paused', async () => { const scope = fakeScope([]); - const stats = await service.calculate(character({ currentHp: 42 }), scope); + 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); + }); }); diff --git a/apps/api/src/characters/character-stats.service.ts b/apps/api/src/characters/character-stats.service.ts index 5d0e0a1..b6dc859 100644 --- a/apps/api/src/characters/character-stats.service.ts +++ b/apps/api/src/characters/character-stats.service.ts @@ -2,6 +2,8 @@ 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 { @@ -11,6 +13,8 @@ export interface EffectiveCharacterStats { weaponDamage: number; armor: number; combatPower: number; + hpRegenPerSecond: number; + hpRegenSince: Date | null; } type RepositoryScope = Pick; @@ -21,7 +25,10 @@ type RepositoryScope = Pick; */ @Injectable() export class CharacterStatsService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly characterVitals: CharacterVitalsService, + ) {} async calculate( character: Character, @@ -54,11 +61,13 @@ export class CharacterStatsService { return { maxHp, - currentHp: character.currentHp, + 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, }; } } diff --git a/apps/api/src/characters/character-vitals.constants.ts b/apps/api/src/characters/character-vitals.constants.ts new file mode 100644 index 0000000..88d8d8a --- /dev/null +++ b/apps/api/src/characters/character-vitals.constants.ts @@ -0,0 +1 @@ +export const HP_REGEN_PER_SECOND = 1; diff --git a/apps/api/src/characters/character-vitals.service.spec.ts b/apps/api/src/characters/character-vitals.service.spec.ts new file mode 100644 index 0000000..06d697c --- /dev/null +++ b/apps/api/src/characters/character-vitals.service.spec.ts @@ -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 { + 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); + }); + }); +}); diff --git a/apps/api/src/characters/character-vitals.service.ts b/apps/api/src/characters/character-vitals.service.ts new file mode 100644 index 0000000..ee45abc --- /dev/null +++ b/apps/api/src/characters/character-vitals.service.ts @@ -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, + 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)); + } +} diff --git a/apps/api/src/characters/characters.module.ts b/apps/api/src/characters/characters.module.ts index af53989..9349554 100644 --- a/apps/api/src/characters/characters.module.ts +++ b/apps/api/src/characters/characters.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { CLOCK, systemClock } from '../shared/clock'; import { CharacterStatsService } from './character-stats.service'; +import { CharacterVitalsService } from './character-vitals.service'; import { CharactersController } from './characters.controller'; import { CharactersService } from './characters.service'; import { Character } from './entities/character.entity'; @@ -8,7 +10,12 @@ import { Character } from './entities/character.entity'; @Module({ imports: [TypeOrmModule.forFeature([Character])], controllers: [CharactersController], - providers: [CharactersService, CharacterStatsService], - exports: [CharacterStatsService], + providers: [ + CharactersService, + CharacterStatsService, + CharacterVitalsService, + { provide: CLOCK, useValue: systemClock }, + ], + exports: [CharacterStatsService, CharacterVitalsService], }) export class CharactersModule {} diff --git a/apps/api/src/characters/characters.service.spec.ts b/apps/api/src/characters/characters.service.spec.ts index 2451047..f5fee6a 100644 --- a/apps/api/src/characters/characters.service.spec.ts +++ b/apps/api/src/characters/characters.service.spec.ts @@ -17,6 +17,8 @@ function fakeCharacterStats( weaponDamage: 8, armor: 0, combatPower: 0, + hpRegenPerSecond: 1, + hpRegenSince: new Date('2026-08-18T09:00:00.000Z'), }), } as unknown as CharacterStatsService; } @@ -50,6 +52,8 @@ describe('CharactersService', () => { currentHp: 100, maxHp: 115, attack: 7, + hpRegenPerSecond: 1, + hpRegenSince: '2026-08-18T09:00:00.000Z', currentLocation: { id: SOUTH_GATE_ID, key: 'south-gate', diff --git a/apps/api/src/characters/characters.service.ts b/apps/api/src/characters/characters.service.ts index 3b4b146..57afd73 100644 --- a/apps/api/src/characters/characters.service.ts +++ b/apps/api/src/characters/characters.service.ts @@ -30,9 +30,11 @@ export class CharactersService { name: character.name, renown: character.renown, silver: character.silver, - currentHp: character.currentHp, + currentHp: stats.currentHp, maxHp: stats.maxHp, attack: stats.attack, + hpRegenPerSecond: stats.hpRegenPerSecond, + hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null, currentLocation: { id: character.currentLocation.id, key: character.currentLocation.key, diff --git a/apps/api/src/characters/entities/character.entity.ts b/apps/api/src/characters/entities/character.entity.ts index 97d74ae..c3de17f 100644 --- a/apps/api/src/characters/entities/character.entity.ts +++ b/apps/api/src/characters/entities/character.entity.ts @@ -32,6 +32,12 @@ export class Character { @Column({ name: 'current_hp', type: 'integer' }) 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' }) currentLocationId!: string; diff --git a/apps/api/src/combat/combat-equipment-integration.spec.ts b/apps/api/src/combat/combat-equipment-integration.spec.ts index 874f7e4..f40f766 100644 --- a/apps/api/src/combat/combat-equipment-integration.spec.ts +++ b/apps/api/src/combat/combat-equipment-integration.spec.ts @@ -1,5 +1,6 @@ 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'; @@ -194,6 +195,7 @@ function character(overrides: Partial = {}): Character { 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'), @@ -335,18 +337,24 @@ function createHarness() { ], }; 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 }; diff --git a/apps/api/src/combat/combat.errors.ts b/apps/api/src/combat/combat.errors.ts index 2028e4b..9033c46 100644 --- a/apps/api/src/combat/combat.errors.ts +++ b/apps/api/src/combat/combat.errors.ts @@ -5,6 +5,7 @@ export type CombatErrorCode = | 'HUNT_ENCOUNTER_ALREADY_CONSUMED' | 'INVALID_HUNT_ENCOUNTER' | 'CHARACTER_TRAVELLING' + | 'CHARACTER_TOO_WOUNDED' | 'COMBAT_ALREADY_ACTIVE' | 'COMBAT_NOT_FOUND' | 'COMBAT_ALREADY_FINISHED' @@ -53,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 { return new CombatDomainError( 'COMBAT_ALREADY_ACTIVE', diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index ecd749c..c6b339f 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -1,5 +1,6 @@ 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 { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; @@ -178,6 +179,7 @@ function character(overrides: Partial = {}): Character { 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'), @@ -283,11 +285,15 @@ function createService( const travelService = options.travelService ?? fakeTravelService(); const combatEngine = new CombatEngineService(); const characterCombatStats = fakeCharacterStats(); + const characterVitals = new CharacterVitalsService({ + now: () => new Date('2026-08-18T09:00:00.000Z'), + }); const service = new CombatService( dataSource as unknown as DataSource, travelService, combatEngine, characterCombatStats, + characterVitals, fakeRewardService(), ); return { dataSource, service, travelService }; @@ -347,6 +353,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 () => { const { dataSource, service } = createService(); @@ -528,6 +577,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 () => { const state = createState({ monsters: [monster({ maxHp: 10 })] }); const { dataSource, service, combatId } = await startedCombat(state); @@ -548,7 +632,7 @@ describe('CombatService', () => { it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => { const state = createState({ - characters: [character({ baseHp: 1 })], + characters: [character({ baseHp: 1, currentHp: 1 })], }); const { dataSource, service, combatId } = await startedCombat(state); @@ -579,7 +663,7 @@ describe('CombatService', () => { }); 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); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); @@ -599,11 +683,19 @@ describe('CombatService', () => { ); }); - it('lets a lost encounter be fought again as a fresh combat', async () => { - const state = createState({ characters: [character({ baseHp: 1 })] }); + it('lets a lost encounter be fought again once the character has recovered HP', 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); + // 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); expect(retry.id).not.toBe(combatId); @@ -787,7 +879,7 @@ describe('CombatService', () => { it('keeps returning LOST after the combat has ended', async () => { const state = createState({ - characters: [character({ baseHp: 1 })], + characters: [character({ baseHp: 1, currentHp: 1 })], }); const context = createService({ state }); const started = await context.service.startCombat( @@ -844,6 +936,9 @@ describe('CombatService', () => { fakeTravelService(), new CombatEngineService(), fakeCharacterStats(), + new CharacterVitalsService({ + now: () => new Date('2026-08-18T09:00:00.000Z'), + }), rewards, ); @@ -896,6 +991,9 @@ describe('CombatService', () => { fakeTravelService(), new CombatEngineService(), fakeCharacterStats(), + new CharacterVitalsService({ + now: () => new Date('2026-08-18T09:00:00.000Z'), + }), rewards, ); @@ -956,6 +1054,9 @@ describe('CombatService', () => { fakeTravelService(), new CombatEngineService(), fakeCharacterStats(), + new CharacterVitalsService({ + now: () => new Date('2026-08-18T09:00:00.000Z'), + }), rewards, ); @@ -993,6 +1094,9 @@ describe('CombatService', () => { fakeTravelService(), new CombatEngineService(), fakeCharacterStats(), + new CharacterVitalsService({ + now: () => new Date('2026-08-18T09:00:00.000Z'), + }), fakeRewardService({ // Genuinely write renown/silver through the transaction's manager // before failing, so the assertions below prove the rollback diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 59ba8f7..7bfbb43 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; import { CharacterStatsService } from '../characters/character-stats.service'; +import { CharacterVitalsService } from '../characters/character-vitals.service'; import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; @@ -16,6 +17,7 @@ import { CombatEngineService } from './combat-engine.service'; import { CombatEngineState, CombatIntent } from './combat-engine.types'; import { characterNotFound, + characterTooWounded, characterTravelling, combatAlreadyActive, combatAlreadyFinished, @@ -78,6 +80,7 @@ export class CombatService { private readonly travelService: TravelService, private readonly combatEngine: CombatEngineService, private readonly characterStats: CharacterStatsService, + private readonly characterVitals: CharacterVitalsService, private readonly combatRewards: CombatRewardService, ) {} @@ -138,6 +141,11 @@ export class CombatService { character, manager, ); + if (playerStats.currentHp < 1) { + throw characterTooWounded(); + } + this.characterVitals.pause(character, playerStats.currentHp); + await characters.save(character); const combat = combats.create({ characterId, @@ -146,7 +154,7 @@ export class CombatService { status: CombatStatus.ACTIVE, round: 1, playerMaxHp: playerStats.maxHp, - playerCurrentHp: playerStats.maxHp, + playerCurrentHp: playerStats.currentHp, monsterMaxHp: monster.maxHp, monsterCurrentHp: monster.maxHp, playerState: { @@ -220,7 +228,7 @@ export class CombatService { // no-op re-lock — but locking it first here keeps both code paths // consistent and avoids a lock-order inversion that could deadlock two // 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({ where: { id: combatId, characterId }, @@ -251,12 +259,16 @@ export class CombatService { combat.monsterState = result.state.monster.stats; if (combat.status !== CombatStatus.ACTIVE) { combat.completedAt = new Date(); + this.characterVitals.resume(character, combat.playerCurrentHp); await this.settleEncounter( manager.getRepository(HuntEncounter), combat.huntEncounterId, combat.status, ); + } else { + this.characterVitals.pause(character, combat.playerCurrentHp); } + await characters.save(character); await combats.save(combat); const startingSequence = await combatEvents.count({ @@ -284,7 +296,7 @@ export class CombatService { ? await this.combatRewards.grantVictoryRewards(manager, combat) : null; - const [character, monster, events] = await Promise.all([ + const [reloadedCharacter, monster, events] = await Promise.all([ this.loadCharacter( combat.characterId, manager.getRepository(Character), @@ -296,7 +308,7 @@ export class CombatService { this.loadEvents(combat.id, combatEvents), ]); - return this.toCombatDto(combat, character.name, monster, events, rewards); + return this.toCombatDto(combat, reloadedCharacter.name, monster, events, rewards); }); } diff --git a/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts b/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts new file mode 100644 index 0000000..65890b6 --- /dev/null +++ b/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddHpRegeneration1792000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"'); + } +} diff --git a/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts b/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts new file mode 100644 index 0000000..109fbbe --- /dev/null +++ b/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 2244609..63d9e12 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -251,6 +251,7 @@ export async function seedVisibleVerticalSlice( baseHp: 100, baseAttack: 6, currentHp: 100, + hpRegenSince: new Date(), currentLocationId: southGateId, }); } diff --git a/apps/api/src/equipment/equipment.service.spec.ts b/apps/api/src/equipment/equipment.service.spec.ts index e24d601..1d8f681 100644 --- a/apps/api/src/equipment/equipment.service.spec.ts +++ b/apps/api/src/equipment/equipment.service.spec.ts @@ -1,5 +1,6 @@ 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'; @@ -191,6 +192,7 @@ function character(overrides: Partial = {}): Character { baseHp: 100, baseAttack: 6, currentHp: 100, + hpRegenSince: null, ...overrides, } as Character; } @@ -205,12 +207,17 @@ function createHarness(state: Partial = {}) { ...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 }; } @@ -452,6 +459,38 @@ describe('EquipmentService', () => { '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', () => { diff --git a/apps/api/src/equipment/equipment.service.ts b/apps/api/src/equipment/equipment.service.ts index c60543f..98c87e5 100644 --- a/apps/api/src/equipment/equipment.service.ts +++ b/apps/api/src/equipment/equipment.service.ts @@ -1,6 +1,7 @@ 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'; @@ -50,6 +51,7 @@ export class EquipmentService { constructor( private readonly dataSource: DataSource, private readonly characterStats: CharacterStatsService, + private readonly characterVitals: CharacterVitalsService, ) {} async getEquipment(characterId: string): Promise { @@ -108,6 +110,10 @@ export class EquipmentService { throw itemNotEquippable(); } + 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' }, diff --git a/apps/api/src/inventory/inventory.service.spec.ts b/apps/api/src/inventory/inventory.service.spec.ts index 15fa969..a8e968f 100644 --- a/apps/api/src/inventory/inventory.service.spec.ts +++ b/apps/api/src/inventory/inventory.service.spec.ts @@ -19,6 +19,7 @@ function characterItem(overrides: Partial = {}): CharacterItem { itemDefinition: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', + description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.', rarity: ItemRarity.COMMON, type: ItemType.EQUIPMENT, equipmentSlot: EquipmentSlot.WEAPON, @@ -66,6 +67,7 @@ describe('InventoryService', () => { item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', + description: 'Die Klinge eines Rekruten, öfter geschliffen als geführt.', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 8, diff --git a/apps/api/src/inventory/inventory.service.ts b/apps/api/src/inventory/inventory.service.ts index 6c13c89..a592ede 100644 --- a/apps/api/src/inventory/inventory.service.ts +++ b/apps/api/src/inventory/inventory.service.ts @@ -13,6 +13,7 @@ export interface InventoryItemDto { item: { key: string; name: string; + description: string; rarity: ItemRarity; equipmentSlot: EquipmentSlot | null; weaponDamage: number; @@ -55,6 +56,7 @@ export class InventoryService { item: { key: characterItem.itemDefinition.key, name: characterItem.itemDefinition.name, + description: characterItem.itemDefinition.description, rarity: characterItem.itemDefinition.rarity, equipmentSlot: characterItem.itemDefinition.equipmentSlot, weaponDamage: characterItem.itemDefinition.weaponDamage, diff --git a/apps/api/src/travel/clock.ts b/apps/api/src/shared/clock.ts similarity index 100% rename from apps/api/src/travel/clock.ts rename to apps/api/src/shared/clock.ts diff --git a/apps/api/src/travel/travel.module.ts b/apps/api/src/travel/travel.module.ts index 94053fc..eca8a2b 100644 --- a/apps/api/src/travel/travel.module.ts +++ b/apps/api/src/travel/travel.module.ts @@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Character } from '../characters/entities/character.entity'; import { LocationConnection } from '../world/entities/location-connection.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 { TravelController } from './travel.controller'; import { TravelService } from './travel.service'; diff --git a/apps/api/src/travel/travel.service.spec.ts b/apps/api/src/travel/travel.service.spec.ts index 9cb28ac..78e9e52 100644 --- a/apps/api/src/travel/travel.service.spec.ts +++ b/apps/api/src/travel/travel.service.spec.ts @@ -6,7 +6,7 @@ import { } from '../database/seeds/vertical-slice.constants'; import { LocationConnection } from '../world/entities/location-connection.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 { TravelDomainError } from './travel.errors'; import { TravelService } from './travel.service'; diff --git a/apps/api/src/travel/travel.service.ts b/apps/api/src/travel/travel.service.ts index f7e046a..2c99dc4 100644 --- a/apps/api/src/travel/travel.service.ts +++ b/apps/api/src/travel/travel.service.ts @@ -3,8 +3,8 @@ import { DataSource, Repository } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity'; -import { CLOCK } from './clock'; -import type { Clock } from './clock'; +import { CLOCK } from '../shared/clock'; +import type { Clock } from '../shared/clock'; import { Travel } from './entities/travel.entity'; import { characterNotFound, diff --git a/apps/web/public/assets/hud-elements/panel-frame.png b/apps/web/public/assets/hud-elements/panel-frame.png new file mode 100644 index 0000000..9c9d21e Binary files /dev/null and b/apps/web/public/assets/hud-elements/panel-frame.png differ diff --git a/apps/web/public/assets/hud-elements/panel-ornament.png b/apps/web/public/assets/hud-elements/panel-ornament.png new file mode 100644 index 0000000..2ffcac6 Binary files /dev/null and b/apps/web/public/assets/hud-elements/panel-ornament.png differ diff --git a/apps/web/public/images/character/female-320.png b/apps/web/public/images/character/female-320.png new file mode 100644 index 0000000..855880b Binary files /dev/null and b/apps/web/public/images/character/female-320.png differ diff --git a/apps/web/public/images/character/female-portrait-256.png b/apps/web/public/images/character/female-portrait-256.png new file mode 100644 index 0000000..5725068 Binary files /dev/null and b/apps/web/public/images/character/female-portrait-256.png differ diff --git a/apps/web/public/images/character/male-320.png b/apps/web/public/images/character/male-320.png new file mode 100644 index 0000000..68a84af Binary files /dev/null and b/apps/web/public/images/character/male-320.png differ diff --git a/apps/web/public/images/character/male-portrait-256.png b/apps/web/public/images/character/male-portrait-256.png new file mode 100644 index 0000000..1ef6683 Binary files /dev/null and b/apps/web/public/images/character/male-portrait-256.png differ diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 57015eb..8ce44a2 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -8,11 +8,13 @@ import { routes } from './app.routes'; describe('App', () => { let character: WritableSignal; + let displayedCharacter: WritableSignal; let currentLocation: WritableSignal; let selectedConnection: WritableSignal; beforeEach(async () => { character = signal(null); + displayedCharacter = signal(null); currentLocation = signal(null); selectedConnection = signal(null); @@ -23,10 +25,11 @@ describe('App', () => { { path: 'location', children: [] }, { path: 'world', children: [] }, { path: 'hunt', children: [] }, + { path: 'inventory', children: [] }, ]), { provide: WorldStore, - useValue: { character, currentLocation, selectedConnection }, + useValue: { character, displayedCharacter, currentLocation, selectedConnection }, }, ], }).compileComponents(); @@ -112,6 +115,16 @@ describe('App', () => { 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); @@ -138,7 +151,19 @@ describe('App', () => { }); it('renders loaded character values supplied by the WorldStore', () => { - character.set({ + const decoy: CharacterResponse = { + id: 'stale-id', + name: 'Stale Decoy', + renown: 1, + 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', name: 'Mara Ashfall', renown: 7, @@ -146,8 +171,12 @@ describe('App', () => { currentHp: 52, maxHp: 80, attack: 12, + hpRegenPerSecond: 1, + hpRegenSince: null, currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' }, - }); + }; + character.set(decoy); + displayedCharacter.set(value); const fixture = TestBed.createComponent(AppShellComponent); fixture.detectChanges(); diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index 82cc915..a62b4c5 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -12,6 +12,8 @@ export interface CharacterResponse { currentHp: number; maxHp: number; attack: number; + hpRegenPerSecond: number; + hpRegenSince: string | null; currentLocation: LocationSummary; } @@ -226,6 +228,7 @@ export interface InventoryItem { item: { key: string; name: string; + description: string; rarity: ItemRarity; equipmentSlot: EquipmentSlot | null; weaponDamage: number; diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts index 5c0acda..69a2336 100644 --- a/apps/web/src/app/features/combat/combat.store.spec.ts +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -84,6 +84,22 @@ describe('CombatStore', () => { 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 () => { api.startCombat.mockReturnValue( throwError( diff --git a/apps/web/src/app/features/combat/combat.store.ts b/apps/web/src/app/features/combat/combat.store.ts index db18682..a9e45b5 100644 --- a/apps/web/src/app/features/combat/combat.store.ts +++ b/apps/web/src/app/features/combat/combat.store.ts @@ -13,6 +13,7 @@ const COMBAT_ERROR_MESSAGES: Readonly> = { HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.', INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.', 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_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.', COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.', diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.html b/apps/web/src/app/features/inventory/inventory-detail-panel.component.html index 0f040f1..71b257b 100644 --- a/apps/web/src/app/features/inventory/inventory-detail-panel.component.html +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.html @@ -1,56 +1,68 @@ -@if (item(); as item) { -
-
- -
-

{{ item.item.name }}

-

{{ rarityLabel() }}

- @if (slotLabel(); as slot) { -

{{ slot }}

+
+

Gewählter Gegenstand

+ + @if (item(); as item) { +
+
+ + + +
+

{{ item.item.name }}

+

{{ rarityLabel() }}

+

{{ slotLabel() ?? typeLabel() }}

+
+
+ + @if (statRows().length) { +
+ @for (row of statRows(); track row.label) { +
+
{{ row.label }}
+
+ {{ row.value }} + @if (row.diff !== null && row.diff !== 0) { + + ({{ row.diff > 0 ? '+' : '' }}{{ row.diff }}) + + } +
+
+ } +
+ } + + @if (item.item.description) { +

{{ item.item.description }}

+ } + +
+ @if (item.equipped) { + Ausgerüstet + } @else if (!isEquippable()) { + Nicht ausrüstbar + } @else { + }
-
- - @if (statRows().length) { -
- @for (row of statRows(); track row.label) { -
-
{{ row.label }}
-
- {{ row.value }} - @if (row.diff !== null && row.diff !== 0) { - - ({{ row.diff > 0 ? '+' : '' }}{{ row.diff }}) - - } -
-
- } -
- } - -
- @if (item.equipped) { - Ausgerüstet - } @else if (!isEquippable()) { - Nicht ausrüstbar - } @else { - - } -
-
-} @else { -

Wähle einen Gegenstand aus deinem Inventar.

-} + + } @else { +

Wähle einen Gegenstand aus deinem Inventar.

+ } + diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss b/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss index 49fb865..8ed37ce 100644 --- a/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss @@ -2,99 +2,174 @@ display: block; } -.inventory-detail { - padding: var(--ar-space-4); - border: 1px solid var(--ar-border-highlight); - border-radius: var(--ar-radius-sm); - background: - linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96); - box-shadow: var(--ar-shadow-raised); +.detail { + block-size: 100%; } -.inventory-detail__header { +.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: center; - margin-block-end: var(--ar-space-3); + align-items: flex-start; } -.inventory-detail__icon { - inline-size: 4rem; - block-size: 4rem; - padding: var(--ar-space-1); - border: 1px solid var(--ar-border); +.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; } -.inventory-detail__name { - margin: 0; - color: var(--ar-text); - font-family: Georgia, 'Times New Roman', serif; - font-size: 1.15rem; - font-weight: 400; +.detail__ident { + min-inline-size: 0; + padding-block-start: 0.15rem; } -.inventory-detail__rarity { +.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); - letter-spacing: 0.08em; - text-transform: uppercase; } -.inventory-detail__meta { - margin: 0.25rem 0 0; +/* ---------- 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); } -.inventory-detail__stats { - display: grid; - gap: var(--ar-space-2); - margin: 0 0 var(--ar-space-3); - padding-block: var(--ar-space-2); - border-block: 1px solid rgb(155 122 66 / 0.45); -} - -.inventory-detail__stat { +.detail__stat dd { display: flex; - justify-content: space-between; -} - -.inventory-detail__stat dt { - color: var(--ar-text-muted); -} - -.inventory-detail__stat dd { + gap: 0.4rem; + align-items: baseline; margin: 0; - font-family: Georgia, 'Times New Roman', serif; + white-space: nowrap; } -.inventory-detail__diff--positive { +.detail__value { + color: var(--ar-text); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1rem; + font-variant-numeric: tabular-nums; +} + + +/* 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); } -.inventory-detail__diff--negative { +.detail__diff--negative { color: var(--ar-danger); } -.inventory-detail__actions { - display: flex; - justify-content: center; +/* ---------- 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; } -.inventory-detail__equipped { +/* ---------- 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; } -.inventory-detail__note { +.detail__note { color: var(--ar-text-muted); + font-size: var(--ar-font-sm); font-style: italic; } -.inventory-detail__equip { +/* 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); @@ -103,22 +178,25 @@ background: linear-gradient(180deg, #263b4b, #17232d); font-family: Georgia, 'Times New Roman', serif; font-size: 1rem; + letter-spacing: 0.06em; } -.inventory-detail__equip:hover:not(:disabled) { +.detail__equip:hover:not(:disabled) { border-color: #d6b26b; background: linear-gradient(180deg, #315067, #1a2c3a); } -.inventory-detail__equip:disabled { +.detail__equip:disabled { border-color: var(--ar-border); color: var(--ar-text-muted); background: #1a1c1d; } -.inventory-detail__empty { - padding: var(--ar-space-4); +.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; } diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts b/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts index 451296e..1efe13f 100644 --- a/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts @@ -10,6 +10,7 @@ const wornSword: InventoryItem = { item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', + description: 'Beschreibung des Gegenstands.', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 8, @@ -27,6 +28,7 @@ const banditBlade: InventoryItem = { item: { key: 'bandit-blade', name: 'Räuberklinge', + description: 'Beschreibung des Gegenstands.', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 11, @@ -44,6 +46,7 @@ const ashPelt: InventoryItem = { item: { key: 'ash-pelt', name: 'Aschenfell', + description: 'Beschreibung des Gegenstands.', rarity: 'COMMON', equipmentSlot: null, weaponDamage: 0, @@ -92,6 +95,19 @@ describe('InventoryDetailPanelComponent', () => { expect(element.querySelector('[data-detail-equipped]')).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 ?? ''; diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts b/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts index 830e122..dd7f5f2 100644 --- a/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts @@ -1,16 +1,7 @@ import { Component, computed, input, output } from '@angular/core'; -import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models'; +import type { InventoryItem } from '../../core/api/game-api.models'; import { RARITY_LABELS } from '../../shared/item-card/item-card.component'; - -const SLOT_LABELS: Readonly> = { - WEAPON: 'Waffe', - HEAD: 'Kopf', - CHEST: 'Brust', - HANDS: 'Handschuhe', - LEGS: 'Beine', - FEET: 'Stiefel', - AMULET: 'Amulett', -}; +import { SLOT_LABELS } from './inventory.labels'; interface StatRow { label: string; @@ -48,6 +39,9 @@ export class InventoryDetailPanelComponent { 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(() => { const item = this.item(); if (!item) { diff --git a/apps/web/src/app/features/inventory/inventory-page.component.html b/apps/web/src/app/features/inventory/inventory-page.component.html index d772cac..b82c877 100644 --- a/apps/web/src/app/features/inventory/inventory-page.component.html +++ b/apps/web/src/app/features/inventory/inventory-page.component.html @@ -1,58 +1,122 @@ -
+

Inventar

+ @if (inventoryStore.loading() && !inventoryStore.inventory()) {

Inventar wird geladen…

} @else if (inventoryStore.inventory(); as inventory) { -
- @for (entry of inventory.items; track entry.id) { - - } @empty { -

Noch keine Gegenstände gefunden.

- } -
+
+ +
+
+

Ausrüstung

-
+ + @if (inventoryStore.equipment(); as equipment) { +
+

Werte

+
+
+
Leben
+
{{ equipment.stats.maxHp }}
+
+
+
Angriff
+
{{ equipment.stats.attack }}
+
+
+
Waffenschaden
+
{{ equipment.stats.weaponDamage }}
+
+
+
Rüstung
+
{{ equipment.stats.armor }}
+
+
+
+ } +
+ + +
+

Beutel

+ +
+ @for (entry of bagCells(); track $index) { + @if (entry) { + + } @else { + + } + } +
+ +
+ {{ bagUsed() }} / {{ bagCapacity() }} Plätze belegt + @if (bagUsed() === 0) { + Noch keine Gegenstände gefunden. + } +
+
+ + - - @if (inventoryStore.equipment(); as equipment) { -
-

Ausrüstung

-
    - @for (slot of slotOrder; track slot) { -
  • - {{ slotLabels[slot] }} - - {{ equipment.slots[slot]?.item?.name ?? 'Leer' }} - -
  • - } -
- -
-
Leben
{{ equipment.stats.maxHp }}
-
Angriff
{{ equipment.stats.attack }}
-
Waffenschaden
{{ equipment.stats.weaponDamage }}
-
Rüstung
{{ equipment.stats.armor }}
-
-
- } - +
} @if (inventoryStore.error(); as error) { @@ -62,3 +126,29 @@
} + + + +
+ {{ label }} + @if (entry) { + + {{ entry.item.name }} + } @else { + + Leer + + } +
+
diff --git a/apps/web/src/app/features/inventory/inventory-page.component.scss b/apps/web/src/app/features/inventory/inventory-page.component.scss index bc83851..d04ad02 100644 --- a/apps/web/src/app/features/inventory/inventory-page.component.scss +++ b/apps/web/src/app/features/inventory/inventory-page.component.scss @@ -1,121 +1,310 @@ -// apps/web/src/app/features/inventory/inventory-page.component.scss :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: grid; - grid-template-columns: 1fr 20rem; - gap: var(--ar-space-5); - align-items: start; - padding: var(--ar-space-5); -} - -.inventory-page__grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); - gap: var(--ar-space-3); -} - -.inventory-page__slot { - position: relative; - padding: var(--ar-space-2); - border: 1px solid transparent; - border-radius: var(--ar-radius-sm); - background: transparent; -} - -.inventory-page__slot--selected { - border-color: var(--ar-border-highlight); - background: rgb(155 122 66 / 0.12); -} - -.inventory-page__equipped-badge { - position: absolute; - inset-block-start: 0.1rem; - inset-inline-start: 50%; - translate: -50% 0; - padding: 0.05rem 0.4rem; - border: 1px solid var(--ar-border-highlight); - border-radius: var(--ar-radius-sm); - color: var(--ar-gold); - background: rgb(9 11 13 / 0.9); - font-size: 0.65rem; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.inventory-page__empty { - grid-column: 1 / -1; - padding: var(--ar-space-4); - color: var(--ar-text-muted); - font-style: italic; -} - -.inventory-page__side { - display: grid; - gap: var(--ar-space-4); -} - -.inventory-page__equipment { - padding: var(--ar-space-4); - border: 1px solid var(--ar-border); - border-radius: var(--ar-radius-sm); - background: var(--ar-panel); -} - -.inventory-page__equipment h3 { - margin: 0 0 var(--ar-space-2); - color: var(--ar-gold); - font-family: Georgia, 'Times New Roman', serif; - font-size: 0.95rem; - font-weight: 400; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.inventory-page__equipment-list { - display: grid; - gap: var(--ar-space-1); - margin: 0 0 var(--ar-space-3); - padding: 0; - list-style: none; -} - -.inventory-page__equipment-list li { 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; - padding-block: var(--ar-space-1); - border-block-end: 1px solid rgb(85 74 57 / 0.4); + 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); } -.inventory-page__equipment-slot-label { - color: var(--ar-text-muted); +.values__glyph { + color: var(--ar-border-highlight); + font-size: 0.8rem; } -.inventory-page__stats { - display: grid; - gap: var(--ar-space-1); - margin: 0; - padding-block-start: var(--ar-space-2); - border-block-start: 1px solid rgb(155 122 66 / 0.45); -} - -.inventory-page__stats div { - display: flex; - justify-content: space-between; -} - -.inventory-page__stats dt { - color: var(--ar-text-muted); -} - -.inventory-page__stats dd { +.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); @@ -123,11 +312,86 @@ } .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; } -@media (width < 960px) { +.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 { - grid-template-columns: 1fr; + 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)); } } diff --git a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts index 058db2a..d3d0daa 100644 --- a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts +++ b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts @@ -17,6 +17,7 @@ const inventory: InventoryResponse = { item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', + description: 'Beschreibung des Gegenstands.', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 8, @@ -33,6 +34,7 @@ const inventory: InventoryResponse = { item: { key: 'bandit-blade', name: 'Räuberklinge', + description: 'Beschreibung des Gegenstands.', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 11, @@ -66,6 +68,8 @@ const character: CharacterResponse = { currentHp: 100, maxHp: 100, attack: 6, + hpRegenPerSecond: 1, + hpRegenSince: null, currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' }, }; @@ -173,6 +177,7 @@ describe('InventoryPageComponent', () => { item: { key: 'iron-helm', name: 'Eiserner Helm', + description: 'Beschreibung des Gegenstands.', rarity: 'COMMON', equipmentSlot: 'HEAD', weaponDamage: 0, diff --git a/apps/web/src/app/features/inventory/inventory-page.component.ts b/apps/web/src/app/features/inventory/inventory-page.component.ts index 7814e0b..46b95f3 100644 --- a/apps/web/src/app/features/inventory/inventory-page.component.ts +++ b/apps/web/src/app/features/inventory/inventory-page.component.ts @@ -1,42 +1,59 @@ +import { NgTemplateOutlet } from '@angular/common'; import { Component, OnInit, computed, inject } from '@angular/core'; -import type { EquipmentSlot } from '../../core/api/game-api.models'; -import { ItemCardComponent } from '../../shared/item-card/item-card.component'; +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'; -const SLOT_ORDER: readonly EquipmentSlot[] = [ - 'WEAPON', - 'HEAD', - 'CHEST', - 'HANDS', - 'LEGS', - 'FEET', - 'AMULET', -]; +/** + * 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'; -const SLOT_LABELS: Readonly> = { - WEAPON: 'Waffe', - HEAD: 'Kopf', - CHEST: 'Brust', - HANDS: 'Handschuhe', - LEGS: 'Beine', - FEET: 'Stiefel', - AMULET: 'Amulett', -}; +// 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: [ItemCardComponent, InventoryDetailPanelComponent], + 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; + /** + * 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) { @@ -60,11 +77,11 @@ export class InventoryPageComponent implements OnInit { this.inventoryStore.selectItem(itemId); } - protected async equipSelected(characterItemId: string): Promise { - await this.inventoryStore.equip(characterItemId); - } - protected retry(): void { void this.inventoryStore.load(); } + + protected async equipSelected(characterItemId: string): Promise { + await this.inventoryStore.equip(characterItemId); + } } diff --git a/apps/web/src/app/features/inventory/inventory.labels.ts b/apps/web/src/app/features/inventory/inventory.labels.ts new file mode 100644 index 0000000..f356260 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory.labels.ts @@ -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> = { + WEAPON: 'Waffe', + HEAD: 'Kopf', + CHEST: 'Brust', + HANDS: 'Handschuhe', + LEGS: 'Beine', + FEET: 'Stiefel', + AMULET: 'Amulett', +}; diff --git a/apps/web/src/app/features/inventory/inventory.store.spec.ts b/apps/web/src/app/features/inventory/inventory.store.spec.ts index 11dedbd..1e543d1 100644 --- a/apps/web/src/app/features/inventory/inventory.store.spec.ts +++ b/apps/web/src/app/features/inventory/inventory.store.spec.ts @@ -16,6 +16,7 @@ const inventory: InventoryResponse = { item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', + description: 'Beschreibung des Gegenstands.', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 8, @@ -32,6 +33,7 @@ const inventory: InventoryResponse = { item: { key: 'bandit-blade', name: 'Räuberklinge', + description: 'Beschreibung des Gegenstands.', rarity: 'COMMON', equipmentSlot: 'WEAPON', weaponDamage: 11, diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 71247d1..8a30353 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -19,6 +19,8 @@ const character: CharacterResponse = { currentHp: 100, maxHp: 100, attack: 6, + hpRegenPerSecond: 1, + hpRegenSince: null, currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, }; @@ -371,4 +373,71 @@ describe('WorldStore', () => { expect(store.character()?.silver).toBe(0); expect(store.error()).toBeNull(); }); + + describe('HP regeneration display', () => { + it('counts displayedCharacter up once per second while an anchor is set', async () => { + const wounded: CharacterResponse = { + ...character, + currentHp: 40, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(wounded)); + + await store.load(); + expect(store.displayedCharacter()?.currentHp).toBe(40); + + await vi.advanceTimersByTimeAsync(3_000); + + expect(store.displayedCharacter()?.currentHp).toBe(43); + }); + + it('stops at maxHp instead of counting past it', async () => { + const almostHealed: CharacterResponse = { + ...character, + currentHp: 99, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(almostHealed)); + + await store.load(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(store.displayedCharacter()?.currentHp).toBe(100); + }); + + it('does not tick while regeneration is paused', async () => { + const paused: CharacterResponse = { + ...character, + currentHp: 40, + maxHp: 100, + hpRegenSince: null, + }; + api.getCharacter.mockReturnValue(of(paused)); + + await store.load(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(store.displayedCharacter()?.currentHp).toBe(40); + }); + + it('resyncs the ticker to a freshly loaded anchor on refreshCharacter', async () => { + api.getCharacter.mockReturnValue(of(character)); + await store.load(); + + const stillWounded: CharacterResponse = { + ...character, + currentHp: 10, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(stillWounded)); + await store.refreshCharacter(); + + await vi.advanceTimersByTimeAsync(4_000); + + expect(store.displayedCharacter()?.currentHp).toBe(14); + }); + }); }); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index e40e775..5bb0588 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -24,6 +24,7 @@ const TRAVEL_ERROR_MESSAGES: Readonly> = { @Injectable({ providedIn: 'root' }) export class WorldStore implements OnDestroy { private readonly characterState = signal(null); + private readonly displayedCharacterState = signal(null); private readonly currentLocationState = signal(null); private readonly selectedConnectionState = signal(null); private readonly currentTravelState = signal(null); @@ -32,11 +33,13 @@ export class WorldStore implements OnDestroy { private readonly loadingState = signal(false); private readonly errorState = signal(null); private countdownTimer: ReturnType | undefined; + private regenTimer: ReturnType | undefined; private travelRetryTimer: ReturnType | undefined; private travelPollInFlight = false; private destroyed = false; readonly character = this.characterState.asReadonly(); + readonly displayedCharacter = this.displayedCharacterState.asReadonly(); readonly currentLocation = this.currentLocationState.asReadonly(); readonly selectedConnection = this.selectedConnectionState.asReadonly(); readonly currentTravel = this.currentTravelState.asReadonly(); @@ -62,7 +65,7 @@ export class WorldStore implements OnDestroy { return; } - this.characterState.set(character); + this.applyCharacter(character); this.currentLocationState.set(location); this.selectedConnectionState.set(null); await this.setCurrentTravel(travel); @@ -102,7 +105,7 @@ export class WorldStore implements OnDestroy { try { const character = await firstValueFrom(this.api.getCharacter()); if (!this.destroyed) { - this.characterState.set(character); + this.applyCharacter(character); } } catch { // Keep the previous character; the next load() will resync. @@ -144,6 +147,7 @@ export class WorldStore implements OnDestroy { ngOnDestroy(): void { this.destroyed = true; this.stopCountdown(); + this.stopRegenTicker(); this.clearTravelRetry(); } @@ -259,11 +263,49 @@ export class WorldStore implements OnDestroy { return; } - this.characterState.set(character); + this.applyCharacter(character); this.currentLocationState.set(location); this.selectedConnectionState.set(null); } + private applyCharacter(character: CharacterResponse): void { + this.characterState.set(character); + this.stopRegenTicker(); + this.refreshDisplayedCharacter(character); + if (character.hpRegenSince !== null) { + this.regenTimer = setInterval(() => this.refreshDisplayedCharacter(character), 1_000); + } + } + + private refreshDisplayedCharacter(character: CharacterResponse): void { + if (this.destroyed) { + return; + } + + const currentHp = this.computeDisplayedHp(character); + this.displayedCharacterState.set({ ...character, currentHp }); + if (currentHp >= character.maxHp) { + this.stopRegenTicker(); + } + } + + private computeDisplayedHp(character: CharacterResponse): number { + if (character.hpRegenSince === null) { + return character.currentHp; + } + + const elapsedSeconds = Math.max(0, (Date.now() - Date.parse(character.hpRegenSince)) / 1000); + const regenerated = Math.floor(elapsedSeconds * character.hpRegenPerSecond); + return Math.min(character.maxHp, character.currentHp + regenerated); + } + + private stopRegenTicker(): void { + if (this.regenTimer !== undefined) { + clearInterval(this.regenTimer); + this.regenTimer = undefined; + } + } + private stopCountdown(): void { if (this.countdownTimer !== undefined) { clearInterval(this.countdownTimer); diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.html b/apps/web/src/app/layout/app-shell/app-shell.component.html index b8ae48c..d0dd1be 100644 --- a/apps/web/src/app/layout/app-shell/app-shell.component.html +++ b/apps/web/src/app/layout/app-shell/app-shell.component.html @@ -1,5 +1,5 @@
- +
diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.ts b/apps/web/src/app/layout/app-shell/app-shell.component.ts index 726e1f5..da31819 100644 --- a/apps/web/src/app/layout/app-shell/app-shell.component.ts +++ b/apps/web/src/app/layout/app-shell/app-shell.component.ts @@ -32,5 +32,10 @@ export class AppShellComponent { // squeeze the artwork the screen is built around. private readonly atLocation = isActive('/location', this.router); - protected readonly showContextPanel = () => !this.inCombat() && !this.atLocation(); + // The inventory is itself three columns wide — doll, bag, selected item — + // and the area panel would push the bag down to a couple of cells a row. + private readonly atInventory = isActive('/inventory', this.router); + + protected readonly showContextPanel = () => + !this.inCombat() && !this.atLocation() && !this.atInventory(); } diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.scss b/apps/web/src/app/layout/side-navigation/side-navigation.component.scss index 5c09c0a..6c6f349 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.scss +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.scss @@ -1,38 +1,57 @@ +/* The rail is the painted `sidepanel-left.png`: an ornate frame with six button + plates down the top and a dragon crest at the foot. The buttons below are + laid out in percentages so they land on those painted plates at any height — + the artwork stretches to the rail, and the tracks stretch with it. + Plate geometry measured from the art (see the ratios in the grid below). */ :host { display: block; - background: var(--ar-panel); + background: var(--ar-bg) url('/assets/hud-elements/sidepanel-left.png') center / 100% 100% + no-repeat; } .side-navigation { display: grid; - align-content: start; - padding-block: var(--ar-space-3); + /* A lead-in, then one track per painted plate. */ + grid-template-rows: 5.9% repeat(6, 8%); + row-gap: 1.3%; + block-size: 100%; + /* Percentage padding resolves against width — which is what the side insets + want anyway. */ + padding-inline: 10.7% 15.1%; +} + +/* Occupies the lead-in track. Without it the first button auto-places there and + the whole stack sits one plate too high. */ +.side-navigation::before { + grid-row: 1; + content: ''; } .side-navigation__item { display: flex; + grid-row: span 1; + gap: var(--ar-space-2); align-items: center; - gap: var(--ar-space-3); - inline-size: 100%; - min-block-size: 4.3rem; - padding: var(--ar-space-3) var(--ar-space-4); + justify-content: center; + min-inline-size: 0; + padding: 0 var(--ar-space-2); border: 0; - border-block-end: 1px solid color-mix(in srgb, var(--ar-border) 65%, transparent); - border-inline-start: 3px solid transparent; color: var(--ar-text-muted); + /* The plate underneath is the button's frame; nothing is drawn on top. */ background: transparent; font: inherit; text-align: start; text-decoration: none; transition: color var(--ar-motion-fast), - background var(--ar-motion-fast), - border-color var(--ar-motion-fast); + text-shadow var(--ar-motion-fast), + filter var(--ar-motion-fast); } .side-navigation__item img { - inline-size: 2.25rem; - block-size: 2.25rem; + flex: none; + inline-size: 1.9rem; + block-size: 1.9rem; object-fit: cover; border-radius: 50%; } @@ -42,8 +61,8 @@ display: grid; flex: none; place-items: center; - inline-size: 2.25rem; - block-size: 2.25rem; + inline-size: 1.9rem; + block-size: 1.9rem; border: 1px solid var(--ar-border-highlight); border-radius: 50%; color: var(--ar-gold); @@ -51,37 +70,58 @@ } .side-navigation__glyph app-location-icon { - inline-size: 1.25rem; - block-size: 1.25rem; + inline-size: 1.1rem; + block-size: 1.1rem; } +.side-navigation__item span { + overflow: hidden; + font-size: 0.95rem; + letter-spacing: 0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Active reads as the plate catching light, not as a bar bolted beside it. */ .side-navigation__item--active { - border-inline-start-color: var(--ar-blue); - color: var(--ar-text); - background: linear-gradient(90deg, rgb(92 169 216 / 0.2), transparent); - box-shadow: inset 0 0 1.25rem rgb(92 169 216 / 0.08); + color: var(--ar-gold); + filter: drop-shadow(0 0 0.5rem rgb(201 164 95 / 0.45)); + text-shadow: 0 0 0.6rem rgb(201 164 95 / 0.5); } .side-navigation__item:not(:disabled):hover, .side-navigation__item:not(:disabled):focus-visible { color: var(--ar-text); - background: rgb(255 255 255 / 0.04); } .side-navigation__item:disabled { - opacity: 0.52; + opacity: 0.42; } +/* Narrow: the tall painted rail no longer fits, so the bar goes horizontal and + drops the artwork rather than squashing it. */ @media (width < 620px) { + :host { + background: var(--ar-panel); + } + .side-navigation { - grid-template-columns: repeat(5, minmax(0, 1fr)); - padding: 0; + grid-template-rows: none; + grid-template-columns: repeat(6, minmax(0, 1fr)); + row-gap: 0; + block-size: auto; + padding-inline: 0; + } + + .side-navigation::before { + display: none; } .side-navigation__item { justify-content: center; min-block-size: 3.5rem; padding: var(--ar-space-2); + border-block-end: 1px solid color-mix(in srgb, var(--ar-border) 65%, transparent); } .side-navigation__item span { diff --git a/apps/web/src/app/layout/top-bar/top-bar.component.html b/apps/web/src/app/layout/top-bar/top-bar.component.html index 055a500..521b514 100644 --- a/apps/web/src/app/layout/top-bar/top-bar.component.html +++ b/apps/web/src/app/layout/top-bar/top-bar.component.html @@ -1,6 +1,6 @@
- + @if (character(); as character) {
{{ character.name }} diff --git a/apps/web/src/app/layout/top-bar/top-bar.component.spec.ts b/apps/web/src/app/layout/top-bar/top-bar.component.spec.ts index fb1d48c..4e28e31 100644 --- a/apps/web/src/app/layout/top-bar/top-bar.component.spec.ts +++ b/apps/web/src/app/layout/top-bar/top-bar.component.spec.ts @@ -11,6 +11,8 @@ function characterFixture(overrides: Partial = {}): Character currentHp: 80, maxHp: 100, attack: 10, + hpRegenPerSecond: 1, + hpRegenSince: null, currentLocation: { id: 'location-1', key: 'aschenfelder', name: 'Aschenfelder' }, ...overrides, }; diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 3bf3dae..ce25f68 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -83,6 +83,85 @@ img { max-inline-size: 100%; } +/* Announced to screen readers, invisible on screen. */ +.visually-hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + border: 0; + white-space: nowrap; +} + +/* ---------- panel chrome ---------- + The forged frame every game panel sits in. The art is `panel-frame.png`, + derived from the hand-made `panel-bg.png` by `tools/asset-gen.mjs`: the + centre-edge gems are painted out there so the frame can be nine-sliced to + any panel size, and put back below as real ornaments. */ +.ar-panel { + position: relative; + padding: var(--ar-space-3) var(--ar-space-4); + /* Fallback if the art fails to load — the frame is transparent border. */ + border: 1.6rem solid transparent; + border-image: url('/assets/hud-elements/panel-frame.png') 96 fill stretch; + background-clip: padding-box; + filter: drop-shadow(0 0.4rem 1rem rgb(0 0 0 / 0.55)); +} + +/* The gem the nine-slice cannot carry, restored at the true centre of the + top and bottom edge. */ +.ar-panel::before, +.ar-panel::after { + position: absolute; + inset-inline-start: 50%; + inline-size: 2.6rem; + block-size: 2.35rem; + content: ''; + background: url('/assets/hud-elements/panel-ornament.png') center / contain no-repeat; + pointer-events: none; + translate: -50% 0; +} + +.ar-panel::before { + inset-block-start: -1.5rem; +} + +.ar-panel::after { + inset-block-end: -1.5rem; + rotate: 180deg; +} + +.ar-panel__title { + position: relative; + margin: 0 0 var(--ar-space-4); + padding-block-end: var(--ar-space-2); + color: var(--ar-text-muted); + font-family: Georgia, 'Times New Roman', serif; + font-size: 0.82rem; + font-weight: 400; + letter-spacing: 0.22em; + text-align: center; + text-transform: uppercase; +} + +.ar-panel__title::after { + position: absolute; + inset-block-end: 0; + inset-inline: 0; + block-size: 1px; + content: ''; + background: linear-gradient( + 90deg, + transparent, + rgb(155 122 66 / 0.75) 22%, + rgb(155 122 66 / 0.75) 78%, + transparent + ); +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/art/sprites/player/female/female-portrait.png b/art/sprites/player/female/female-portrait.png new file mode 100644 index 0000000..2d27cb8 Binary files /dev/null and b/art/sprites/player/female/female-portrait.png differ diff --git a/art/sprites/player/female/female.png b/art/sprites/player/female/female.png new file mode 100644 index 0000000..c1c1038 Binary files /dev/null and b/art/sprites/player/female/female.png differ diff --git a/art/sprites/player/male/male-portrait.png b/art/sprites/player/male/male-portrait.png new file mode 100644 index 0000000..38a3b59 Binary files /dev/null and b/art/sprites/player/male/male-portrait.png differ diff --git a/art/sprites/player/male/male.png b/art/sprites/player/male/male.png new file mode 100644 index 0000000..308938c Binary files /dev/null and b/art/sprites/player/male/male.png differ diff --git a/docs/playable-slices/0.10-Abandoned-Watchpost.md b/docs/playable-slices/0.10-Abandoned-Watchpost.md new file mode 100644 index 0000000..a98ba35 --- /dev/null +++ b/docs/playable-slices/0.10-Abandoned-Watchpost.md @@ -0,0 +1,251 @@ +# Ashen Realms – Playable Slice 0.10 + +## Abandoned Watchpost + +**Status:** Implementation Specification +**Depends on:** Slice 0.9 +**Purpose:** Expand the first region beyond the tutorial road and introduce a stronger hunt location, story investigation and the first meaningful gear/reputation check. + +--- + +## 1. Goal + +The player now has enough knowledge and basic equipment to leave the Burned Road loop and push deeper into the Ashen Fields. + +The Abandoned Watchpost should feel like the first place where the world opens slightly: + +- new location artwork +- stronger encounter pool +- a surviving/remaining NPC or investigation point +- new trade goods +- stronger equipment opportunities +- route toward the Ash Pit + +The player should feel that the Burned Road prepared them for this place. + +--- + +## 2. World Connection + +Add or activate: + +```text +Burned Road ↔ Abandoned Watchpost +``` + +Initial travel target: + +```text +Duration: ~15 seconds +Ambush chance: ~10% +``` + +Values remain balancing data. + +The player is not blocked by character level. + +If progression gating is needed, prefer world discovery / quest state / actual combat danger rather than a numeric level requirement. + +--- + +## 3. Local View + +The Abandoned Watchpost should use the generic local-location screen pattern. + +Minimum interactions: + +- Hunt +- Inspect the Watchpost +- Speak with the remaining guard/NPC if present +- Travel back to Burned Road +- discover or unlock route toward Ash Pit + +The location must not be a world-map node with no local identity. + +--- + +## 4. Encounter Pool + +Recommended initial enemies: + +- Road Bandit +- Raider Scout +- Raider Veteran +- Ash Hound / Burned Hound depending on final content naming +- optional rare Raider Captain as first elite target + +Reuse existing enemy mechanics where possible. + +This slice should deepen combinations, not add several new subsystems. + +--- + +## 5. New Enemy Roles + +### Raider Scout + +- moderate damage +- simple fast attack pattern +- primarily a loot/reputation farming target + +### Raider Veteran + +- higher armor/HP +- telegraphed Heavy Strike +- can enter a defensive stance +- encourages the player to react rather than spam Attack + +### Burned Hound + +- Bleeding +- becomes more aggressive at low HP +- reinforces previously learned status behavior + +### Raider Captain – Optional Elite + +If included in this slice: + +- combines telegraphed attack + defensive behavior +- should be dangerous on first arrival +- becomes reliably beatable after several Tier-1 upgrades +- has a focused desirable equipment drop + +Do not confuse this elite with the final **Captain of the Ashen Band** area boss in Slice 0.11. + +--- + +## 6. Loot and Bags + +The location should make both current loot categories relevant. + +Examples: + +- beast enemies → HIDE goods +- raiders → RAIDER_TROPHY goods + +This gives a reason to own both: + +- Basic Hide Bag +- Basic Trophy Pouch + +If the player does not own a Trophy Pouch yet, the default capacity of 1 still applies and should naturally point them back toward the merchant. + +No direct Silver or reputation from normal kills. + +--- + +## 7. Equipment Progression + +The Watchpost should increase the chance of meaningful Tier-1 upgrades. + +Relevant items may include: + +- Bandit Blade +- Bandit Hood +- Plunderer Gloves +- Reinforced Leather Jacket +- Watchman's Leggings + +The player does not need Best-in-Slot gear to proceed. + +The intended feeling is: + +> "The enemies here are tougher, but the drops here are also the things that make me ready for the Ash Pit." + +--- + +## 8. Story / Investigation + +A simple investigation should reveal that the road attacks are organized and point toward the Ash Pit. + +This may be implemented through: + +- NPC dialogue +- inspectable environment interaction +- quest step +- persistent discovery flag + +Keep text concise. + +Example clue: + +> "The raiders weren't using the watchpost as shelter. They were using it to watch the road. Fresh tracks lead east, toward the old ash excavation." + +--- + +## 9. Unlocking the Ash Pit Route + +The route toward the Ash Pit should become visible through world progress. + +Recommended rule: + +```text +Inspect Watchpost OR complete related objective +→ discover ash-pit connection +``` + +Avoid `requiredLevel`. + +The player may be allowed to travel there even if still undergeared once the route is discovered. + +Actual danger is the soft gate. + +--- + +## 10. Merchant/Reputation Integration + +New trade goods from the Watchpost must be accepted by the existing merchant exchange system. + +The player should now have a reason to make repeated cycles: + +```text +Graufurt +→ Burned Road +→ Watchpost +→ bags fill +→ Graufurt merchant +→ Silver + reputation + Renown +→ better supplies/bags/gear +→ return +``` + +--- + +## 11. Tests + +- travel connection works both directions as configured +- Watchpost hunt uses only its own encounter pool +- Raider Veteran defensive/telegraph mechanics resolve correctly +- trade goods map to correct loot categories +- no normal enemy grants direct money/reputation +- Watchpost investigation persists +- Ash Pit route is hidden before discovery if that rule is used +- Ash Pit route becomes available after discovery + +--- + +## 12. Acceptance Criteria + +- [ ] Abandoned Watchpost exists as a full playable location. +- [ ] Travel from Burned Road works with server-authoritative timing. +- [ ] Location has a stronger, distinct encounter pool. +- [ ] At least one stronger enemy combines previously learned mechanics. +- [ ] Both HIDE and RAIDER_TROPHY carrying systems matter. +- [ ] Tier-1 equipment progression is meaningfully improved here. +- [ ] Story/investigation points toward the Ash Pit. +- [ ] Ash Pit route can be discovered without a level gate. +- [ ] Existing merchant/reputation loop continues to work. +- [ ] All player-facing content is English. + +--- + +## 13. Out of Scope + +Do not add: + +- second region +- crafting +- procedural watchpost events +- complex stealth mechanics +- large dialogue trees +- final area boss diff --git a/docs/playable-slices/0.11-Ash-Pit-and-Ashen-Band-Captain.md b/docs/playable-slices/0.11-Ash-Pit-and-Ashen-Band-Captain.md new file mode 100644 index 0000000..3d72a68 --- /dev/null +++ b/docs/playable-slices/0.11-Ash-Pit-and-Ashen-Band-Captain.md @@ -0,0 +1,261 @@ +# Ashen Realms – Playable Slice 0.11 + +## Ash Pit & Captain of the Ashen Band + +**Status:** Implementation Specification +**Depends on:** Slice 0.10 +**Purpose:** Deliver the first region climax: dangerous regular encounters, the first real area boss and guaranteed progression loot. + +--- + +## 1. Goal + +The Ash Pit is the first clear test of whether the player has understood the Ashen Fields progression loop. + +The player should arrive with: + +- some reputation progression +- useful bag capacity +- several Tier-1 equipment upgrades +- experience with Bleeding +- experience with Telegraphing +- experience with Shield Bash / interrupt +- experience with Defend + +The area culminates in the **Captain of the Ashen Band**. + +--- + +## 2. World Connection + +Activate/discover: + +```text +Abandoned Watchpost ↔ Ash Pit +``` + +Suggested travel duration: + +```text +15–20 seconds +``` + +Travel risk can be higher than the Burned Road connection. + +The route should remain a soft gate: the player can enter once discovered, even if the enemies are still too dangerous. + +--- + +## 3. Ash Pit Local View + +The Ash Pit should immediately feel more dangerous. + +Minimum interactions: + +- Hunt +- Enter / challenge the band captain when available +- Inspect the excavation / camp +- Travel back to Abandoned Watchpost + +Artwork and UI should communicate that this is the region's current endpoint. + +--- + +## 4. Regular Encounter Pool + +Recommended enemies: + +- Ash Burrower +- Raider Veteran +- Burned Hound +- optional rare/strong raider variant + +The regular pool should be harder than the Watchpost without simply multiplying HP. + +--- + +## 5. Enemy Mechanics + +### Ash Burrower + +- durable melee enemy +- higher armor than early beasts +- teaches that some enemies are naturally slower to kill + +### Raider Veteran + +- reused known mechanics +- Heavy Strike telegraph +- defensive stance + +### Burned Hound + +- Bleeding +- increased aggression at low HP + +The Ash Pit should feel like a consolidation of the region's mechanics before the boss. + +--- + +## 6. Boss – Captain of the Ashen Band + +English display name: + +**Captain of the Ashen Band** + +The boss should combine known mechanics instead of introducing a new combat resource. + +### Phase / behavior concept + +#### Normal state + +- standard melee attack +- telegraphed Heavy Strike + +#### Defensive state + +At a configured trigger: + +- increases armor for a short duration +- player can choose to Defend/heal/manage the round rather than waste damage + +#### Low-health aggression + +Below approximately 30% HP: + +- increased pressure +- faster/more frequent dangerous action +- no unfair hidden one-shot + +--- + +## 7. Boss Reward Philosophy + +The boss must never feel unrewarding. + +On victory: + +### Guaranteed + +- one relevant high-quality Tier-1 equipment item from a configured pool +- boss trade good / trophy +- boss defeated world-state flag + +### Additional independent roll + +- chance for Ashen Blade +- chance for Charred Captain's Pendant / prestige item if already part of content + +Normal repeat kills should not directly print large amounts of Silver or reputation. + +The boss trophy can be exchanged through the merchant system for meaningful value. + +--- + +## 8. World Renown Milestone + +A first-time area-boss victory is a valid **major achievement** and may grant a one-time World Renown reward directly. + +Rules: + +- first victory only +- persisted milestone flag +- repeat kills do not repeat the milestone Renown reward + +This keeps the distinction: + +- normal kills → goods +- trade-in → routine Silver/reputation/Renown progression +- major achievements → optional one-time direct World Renown + +--- + +## 9. Boss Availability + +The boss challenge should be visible as a location action once the relevant Ash Pit discovery/story condition is met. + +Avoid a hard level requirement. + +If the player attacks too early, the boss should simply be very dangerous. + +A danger label should clearly communicate this. + +--- + +## 10. Defeat + +A boss defeat follows the general Ashen Realms defeat philosophy: + +- no item loss +- no reputation loss +- no permanent progression loss +- return to a safe location according to the existing defeat flow + +The player should be encouraged to upgrade and return. + +--- + +## 11. Region Completion State + +First boss victory should set a persisted region-progress milestone such as: + +```text +ashen-fields-boss-defeated +``` + +This can later unlock/discover the route toward the next region. + +Slice 0.12 validates and polishes the entire region; it does not need to fully implement the next region. + +--- + +## 12. Tests + +### Boss combat + +- Heavy Strike telegraphs correctly +- interrupt works where intended +- defensive phase modifies armor for correct duration +- low-health behavior triggers once/according to rules +- boss victory persists + +### Rewards + +- guaranteed Tier-1 item always grants +- special drop is independent roll +- trophy grants once per victory as configured +- first victory World Renown milestone is granted once +- repeat boss victory does not duplicate first-clear milestone reward + +### Regression + +- regular Ash Pit enemies still grant no direct XP/Silver/reputation + +--- + +## 13. Acceptance Criteria + +- [ ] Ash Pit exists as the region's hardest current location. +- [ ] Regular encounter pool combines learned mechanics. +- [ ] Captain of the Ashen Band is a dedicated boss encounter. +- [ ] Boss has telegraph, defensive behavior and low-HP pressure. +- [ ] Boss is beatable with appropriate Tier-1 progression. +- [ ] Boss always grants one relevant Tier-1 equipment item. +- [ ] Special boss loot uses a separate roll. +- [ ] First clear can grant one-time World Renown. +- [ ] Repeat clears cannot duplicate the first-clear milestone reward. +- [ ] Region completion state is persisted. +- [ ] All player-facing text is English. + +--- + +## 14. Out of Scope + +Do not implement: + +- Dusk/Gloam forest region content +- raids +- group combat +- boss matchmaking +- weekly lockouts +- complex multi-phase cinematic boss logic diff --git a/docs/playable-slices/0.12-Ashen-Fields-Complete-and-Polish.md b/docs/playable-slices/0.12-Ashen-Fields-Complete-and-Polish.md new file mode 100644 index 0000000..ecd78a6 --- /dev/null +++ b/docs/playable-slices/0.12-Ashen-Fields-Complete-and-Polish.md @@ -0,0 +1,384 @@ +# Ashen Realms – Playable Slice 0.12 + +## Ashen Fields Complete – Integration, Balancing & Polish + +**Status:** Implementation Specification +**Depends on:** Slice 0.11 +**Purpose:** Turn all systems built through 0.11 into one coherent, testable first-region experience before expanding to the next region. + +--- + +## 1. Goal + +Slice 0.12 is not primarily a new feature slice. + +It is the point where the Ashen Fields must work as **one game**. + +A fresh character should be able to play through the complete first-region loop without developer intervention: + +**Graufurt → South Gate → Burned Road → hunt → bag limit → merchant → reputation → upgrades → Abandoned Watchpost → Ash Pit → Captain of the Ashen Band.** + +If this loop is not fun and understandable, do not move on to the next region yet. + +--- + +## 2. Full Region Scope + +The complete first-region content should include at least: + +### Safe / transition locations + +- Graufurt +- Graufurt South Gate + +### Progression locations + +- Burned Road +- Abandoned Watchpost +- Ash Pit + +### Core systems + +- travel +- local location view +- hunt +- encounter choice +- turn-based combat +- Bleeding +- Telegraphing +- interrupt +- Defend +- potions/combat bag as already implemented +- trade goods +- monster categories +- loot categories +- loot bags +- merchant trade-in +- Silver +- regional reputation +- World Renown +- reputation-gated offers +- first quest chain +- Tier-1 equipment +- first area boss + +--- + +## 3. Remove Legacy Progression Assumptions + +Perform a complete audit for obsolete XP-era behavior. + +For the implemented first region: + +- normal monsters must not grant XP +- normal monsters must not grant Silver directly +- normal monsters must not grant reputation directly +- old Grenzmarken / border-token progression must not remain active unless explicitly reintroduced as a separate intentional system +- merchant offers must not use old character-level gates as the primary progression requirement + +Search: + +- seed data +- entities +- DTOs +- API responses +- combat victory code +- loot service +- UI reward summaries +- tests +- docs used by implementation agents + +Do not leave old behavior silently active beside the new reputation model. + +--- + +## 4. Tier-1 Item Completion + +Ensure the first region has a coherent fixed-item progression. + +Recommended Tier-1 set of named items: + +- Worn Shortsword +- Bandit Blade +- Ashen Blade +- Bandit Hood +- Reinforced Leather Jacket +- Plunderer Gloves +- Watchman's Leggings +- Ashen Boots +- Mark of the Border Watch +- Charred Captain's Pendant + +Not every item needs to be mandatory. + +Each relevant item must have at least one clear acquisition path: + +- monster drop +- boss guaranteed pool +- reputation-gated merchant offer +- quest reward, only where appropriate + +Avoid items that exist in the database but cannot realistically be obtained. + +--- + +## 5. Target Combat Durations + +Use the existing balancing targets: + +### Normal enemies + +**4–8 rounds** in appropriate content. + +### Elite enemies + +**6–10 rounds.** + +### Boss + +**8–14 rounds.** + +Run deterministic simulations/tests where possible and manually play representative loadouts. + +Do not balance only around a fully optimized character. + +--- + +## 6. Progression States to Test + +At minimum test these player states: + +### Fresh character + +- starter weapon +- starter armor +- no loot bags +- no reputation + +Expected: + +- Ash Rat comfortable +- Road Bandit risky but understandable +- Watchpost clearly too early + +### Early Burned Road progression + +- Basic Hide Bag +- first equipment upgrade +- some merchant reputation + +Expected: + +- Burned Road reliable +- Watchpost possible but harder + +### Mid Ashen Fields + +- several Tier-1 upgrades +- Trophy Pouch or equivalent capacity improvement +- improved reputation + +Expected: + +- Watchpost reliable +- Ash Pit dangerous but approachable + +### Region-ready + +- strong but not necessarily Best-in-Slot Tier-1 loadout + +Expected: + +- Captain of the Ashen Band reasonably beatable with correct decisions + +--- + +## 7. Bag / Return-Trip Balancing + +The bag system must create decisions without becoming tedious. + +Validate: + +- default capacity 1 is useful for the tutorial moment +- Basic Hide Bag capacity 5 feels meaningful +- trade-in trips are frequent enough to create rhythm +- trips are not so frequent that the player spends more time returning than fighting +- higher-value raider trophies do not require excessive backtracking + +Adjust bag capacity, travel duration or reward value rather than adding arbitrary shortcuts immediately. + +--- + +## 8. Merchant Economy Balancing + +Validate: + +- a first meaningful purchase is reachable in a reasonable number of hunt cycles +- reputation unlocks occur often enough to be noticed +- locked items create goals rather than frustration +- Silver does not accumulate with no useful sinks +- trade goods have visibly different value +- rare encounters feel economically exciting + +Do not target a perfect final economy. Target a coherent first-region economy. + +--- + +## 9. World Renown Balancing + +World Renown is the long-term level-like progression. + +For this slice: + +- it must visibly increase during normal region play +- it must not race upward from every individual kill +- merchant exchange and major milestones are the key sources +- first area-boss victory may grant a one-time meaningful increase + +Do not finalize the complete 10–15-rank game-wide curve unless that has already been specified elsewhere. + +Only ensure the first region produces a sensible opening segment of that curve. + +--- + +## 10. Hunt Balancing + +Check encounter weights and player choice. + +The player should regularly see: + +- easy/reliable targets +- one more rewarding/riskier option +- occasional rare encounter + +Avoid hunts where all three cards are effectively identical. + +Rare Charred Raider should feel exciting but not so rare that normal play never demonstrates it. + +--- + +## 11. UI Polish + +Review all first-region screens against the established UI design. + +Important: + +- artwork remains dominant +- local view feels like a browser RPG, not a mobile card game +- current location is obvious +- NPCs/interactions are obvious +- bag capacity is visible when relevant +- reputation requirements are readable +- loot summary clearly distinguishes granted vs left-behind goods +- combat telegraphs are impossible to miss +- locked merchant items are understandable +- no German player-facing strings remain + +--- + +## 12. Content Consistency + +Verify names, keys and terminology across: + +- database seeds +- backend DTOs +- Angular components +- combat log +- item tooltips +- quest text +- merchant text +- location descriptions + +One entity must not appear as `Road Bandit` in one screen and `Street Raider` in another unless intentionally different. + +--- + +## 13. End-to-End Test Scenario + +Create at least one automated or documented repeatable E2E path: + +```text +1. Start with fresh character. +2. Visit South Gate Warden. +3. Accept Trouble Beyond the Gate. +4. Travel to Burned Road. +5. Hunt Ash Rat. +6. Receive first Ashen Pelt. +7. Hit HIDE capacity 1/1. +8. Return to warden. +9. Receive referral. +10. Visit Borin. +11. Receive Basic Hide Bag. +12. Collect five Ashen Pelts. +13. Complete quest. +14. Hunt/trade goods with Borin. +15. Gain Silver + regional reputation + World Renown. +16. Unlock/buy at least one progression offer. +17. Travel to Abandoned Watchpost. +18. Obtain stronger Tier-1 loot. +19. Discover Ash Pit. +20. Challenge Captain of the Ashen Band. +21. Win with a plausible non-BiS loadout. +22. Receive guaranteed relevant Tier-1 item. +23. Persist first-clear milestone. +``` + +The flow must survive page reloads and reconnects at sensible points. + +--- + +## 14. Technical Verification + +Before declaring Slice 0.12 complete: + +- all backend unit tests pass +- integration tests pass +- frontend tests pass +- production build passes +- migration path works from a clean database +- seed is idempotent where designed to be +- Docker production image still starts +- API and Angular static hosting still work together +- no client-authoritative combat/loot/reputation calculation has been introduced + +--- + +## 15. Definition of Done + +Slice 0.12 is complete when: + +- [ ] A fresh player can finish the complete Ashen Fields progression without manual DB edits. +- [ ] The first quest teaches the bag/merchant loop naturally. +- [ ] Burned Road, Abandoned Watchpost and Ash Pit each have a distinct purpose. +- [ ] Normal enemies reward goods/equipment, not direct XP/Silver/reputation. +- [ ] Merchant trade-in converts goods into Silver + reputation progression. +- [ ] Reputation visibly unlocks useful offers. +- [ ] Bags create a meaningful but not annoying return-trip rhythm. +- [ ] Tier-1 equipment progression is noticeable. +- [ ] Captain of the Ashen Band is a fair first-region climax. +- [ ] Boss guarantees meaningful progression loot. +- [ ] Combat duration targets are broadly met. +- [ ] Player-facing content is consistently English. +- [ ] The entire flow is server-authoritative for critical game state. +- [ ] Production build/tests are green. + +--- + +## 16. What Comes After 0.12 + +Only after this slice is stable should development expand into the next region. + +Recommended next phase: + +```text +Ashen Fields retrospective / balancing adjustments +→ next region foundation +→ new region enemies/mechanics +→ new reputation/economy content +``` + +Do not immediately add many MMO systems. + +The first question after 0.12 is: + +> **Is the repeatable loop of hunting, carrying, trading, upgrading and overcoming stronger content actually fun?** diff --git a/docs/playable-slices/0.6.6-English-Game-Content-Foundation.md b/docs/playable-slices/0.6.6-English-Game-Content-Foundation.md new file mode 100644 index 0000000..0a4fafe --- /dev/null +++ b/docs/playable-slices/0.6.6-English-Game-Content-Foundation.md @@ -0,0 +1,300 @@ +# Ashen Realms – Playable Slice 0.6.6 + +## English Game Content Foundation + +**Status:** Implementation Specification +**Depends on:** Slice 0.6.5 – Reputation / Renown Foundation +**Purpose:** Convert all currently implemented player-facing game content to English before the next major content expansion. + +--- + +## 1. Goal + +From this slice onward, **all player-facing game content is written in English**. + +Development documentation may remain German. Internal technical names may remain unchanged when they are already stable, but new data keys should be language-neutral English slugs. + +This slice is deliberately a cleanup and convention slice. It must not introduce a large localization framework. + +> Project rule after this slice: **All player-facing game content is English. Development documentation may remain German.** + +--- + +## 2. Why this slice exists now + +The project is about to add significantly more content: + +- more monsters +- trade goods +- loot bags +- merchants +- reputation-gated offers +- NPC dialogue +- quests +- the Abandoned Watchpost +- the Ash Pit +- the first area boss + +Translating later would touch much more persisted content, UI copy, seed data and tests. Therefore the language switch happens before Slice 0.7 V2. + +--- + +## 3. Scope + +Convert every **currently visible** German string in the playable loop: + +- navigation +- locations +- monsters +- items +- combat actions +- combat log +- hunt screen +- travel screen +- loot screen +- inventory +- character screen +- system messages +- danger labels +- error messages visible to the player + +Do **not** translate German development documentation in `/docs` unless it is itself rendered in the game. + +--- + +## 4. Naming convention + +Technical identity must not depend on display language. + +Preferred pattern: + +```ts +{ + key: 'ash-rat', + name: 'Ash Rat' +} +``` + +Avoid new language-specific identifiers such as: + +```ts +key: 'aschenratte' +``` + +Existing UUIDs must never change because of the language conversion. + +--- + +## 5. Initial terminology + +Use the following English terminology consistently for the currently relevant content. + +### Locations + +| German / Previous Label | English Player-Facing Label | Suggested Key | +|---|---|---| +| Graufurt | Graufurt | `graufurt` | +| Südtor von Graufurt | Graufurt South Gate | `graufurt-south-gate` | +| Verbrannte Straße | Burned Road | `burned-road` | +| Verlassener Wachtposten | Abandoned Watchpost | `abandoned-watchpost` | +| Aschengrube | Ash Pit | `ash-pit` | +| Aschenfelder | Ashen Fields | `ashen-fields` | + +`Graufurt` is treated as a proper place name and remains unchanged for now. + +### Monsters + +| German / Previous Label | English Label | Suggested Key | +|---|---|---| +| Aschenratte | Ash Rat | `ash-rat` | +| Verwilderter Straßenhund | Feral Road Hound | `feral-road-hound` | +| Straßenräuber | Road Bandit | `road-bandit` | +| Verkohlter Plünderer | Charred Raider | `charred-raider` | +| Plünderer-Späher | Raider Scout | `raider-scout` | +| Plünderer-Veteran | Raider Veteran | `raider-veteran` | +| Aschenwühler | Ash Burrower | `ash-burrower` | +| Verbrannter Jagdhund | Burned Hound | `burned-hound` | +| Hauptmann der Aschenbande | Captain of the Ashen Band | `ashen-band-captain` | + +### Core combat actions + +- Attack +- Heavy Strike +- Shield Bash +- Defend +- Potion +- Flee + +### Navigation + +- Map +- Hunt +- Quests +- Inventory +- Character +- Shop + +### Common UI + +- Current Location +- Travel +- Begin Travel +- Begin Hunt +- Search Again +- Attack +- Back +- Loot +- Equip +- Unequip +- Reputation +- World Renown +- Silver +- Required Reputation +- Bag Capacity + +### Danger labels + +- Weak +- Suitable +- Strong +- Very Dangerous +- Deadly + +--- + +## 6. Existing item names + +At minimum, currently seeded or visible Tier-1 items should use English display names. + +Recommended names: + +- Worn Shortsword +- Bandit Blade +- Ashen Blade +- Bandit Hood +- Reinforced Leather Jacket +- Plunderer Gloves +- Watchman's Leggings +- Ashen Boots +- Mark of the Border Watch +- Charred Captain's Pendant + +Do not rename stable item keys merely to make them prettier. Only display values should be changed unless the old key is clearly temporary and no persisted references depend on it. + +--- + +## 7. Combat log language + +All newly generated combat events must resolve to English text. + +Examples: + +```text +Round 2 +You use Shield Bash. +Road Bandit takes 12 damage. +Road Bandit's Heavy Strike is interrupted. +``` + +```text +Feral Road Hound bites you for 9 damage. +You are Bleeding for 2 rounds. +``` + +Combat event persistence should continue to prefer structured event data over storing fully rendered localized strings wherever possible. + +--- + +## 8. UI text architecture + +Do not build a full translation database in this slice. + +For static Angular UI text, it is acceptable to use centralized constants or a lightweight text map where useful. + +For persisted game content, keep: + +```text +key +name +shortDescription / description +``` + +with English values. + +A future localization layer may introduce translations without changing the content key. + +--- + +## 9. Error messages + +Domain error `code` values remain technical and stable. + +Example: + +```json +{ + "code": "BAG_CAPACITY_EXCEEDED", + "message": "You cannot carry any more hides." +} +``` + +The player-facing `message` is English. + +--- + +## 10. Migration / seed behavior + +If names and descriptions are stored in PostgreSQL: + +- update existing rows by stable `key` +- do not delete and recreate content rows unnecessarily +- preserve UUIDs +- preserve character inventory references +- keep migrations idempotent where appropriate + +If seed files own the content, update both the seed definitions and the existing database through a controlled migration/update step. + +--- + +## 11. Tests + +Add or update tests so that: + +- current location API returns English names +- hunt encounters return English monster names +- combat UI renders English action labels +- combat log contains English player-facing text +- inventory uses English item names +- no existing test expects German display text + +A simple development-only grep may be used to identify obvious German UI remnants, but it is not a substitute for actual runtime verification. + +--- + +## 12. Acceptance Criteria + +This slice is complete when: + +- [ ] The complete currently playable flow contains no intentional German player-facing text. +- [ ] Existing content IDs and player state remain valid. +- [ ] Locations, monsters and items use English display names. +- [ ] Combat actions and combat log are English. +- [ ] Navigation and common system messages are English. +- [ ] New stable content keys follow English/language-neutral slug conventions. +- [ ] No large localization framework was introduced. +- [ ] All existing tests pass. + +--- + +## 13. Explicitly Out of Scope + +Do not implement yet: + +- German/English language switch +- browser language detection +- translation management UI +- translation tables for every content entity +- Crowdin / Phrase / Lokalise integration +- automatic machine translation + +The only required runtime language after this slice is **English**. diff --git a/docs/playable-slices/0.7.5-Monster-Categories-and-Loot-Bags.md b/docs/playable-slices/0.7.5-Monster-Categories-and-Loot-Bags.md new file mode 100644 index 0000000..1ce1ee6 --- /dev/null +++ b/docs/playable-slices/0.7.5-Monster-Categories-and-Loot-Bags.md @@ -0,0 +1,321 @@ +# Ashen Realms – Playable Slice 0.7.5 + +## Monster Categories & Loot Bags + +**Status:** Implementation Specification +**Depends on:** Slice 0.7 V2 +**Purpose:** Introduce category-based carrying limits for monster trade goods and create the first logistical reason to return from hunting. + +--- + +## 1. Goal + +Monster loot should not be infinitely farmable in one trip. + +The player can carry only a small amount of category-specific trade goods without a bag. Dedicated loot bags increase that capacity. + +This creates the loop: + +**Hunt → fill bag → return → exchange goods → upgrade capacity → hunt longer.** + +The system must be simple, visible and data-driven. + +--- + +## 2. Core Rule + +Each trade good belongs to exactly one **Loot Category**. + +A character has a carrying capacity for each loot category. + +Without a matching bag, the default carrying capacity is: + +**1 unit per loot category.** + +Example: + +```text +Ashen Pelt: HIDE +Current HIDE capacity: 1 +Player owns 1 Ashen Pelt +→ another Ashen Pelt cannot be carried +``` + +A Hide Bag with capacity 5 changes this to: + +```text +HIDE capacity: 5 +``` + +--- + +## 3. Initial Categories + +Implement only the categories needed by the current content, but design the enum so future categories can be added. + +Initial categories: + +```text +HIDE +RAIDER_TROPHY +``` + +Suggested future categories, not required yet: + +```text +CHITIN +UNDEAD_RELIC +ARCANE_REMAINS +``` + +--- + +## 4. Initial Trade Good Mapping + +| Trade Good | Loot Category | +|---|---| +| Ashen Pelt | HIDE | +| Tough Hide | HIDE | +| Raider Insignia | RAIDER_TROPHY | +| Charred Raider Insignia | RAIDER_TROPHY | + +The mapping belongs to content data, not hardcoded monster-specific UI logic. + +--- + +## 5. Monster Categories + +Monster definitions also receive a broad gameplay category. + +Initial examples: + +```text +BEAST +HUMANOID +``` + +Suggested mapping: + +| Monster | Monster Category | +|---|---| +| Ash Rat | BEAST | +| Feral Road Hound | BEAST | +| Road Bandit | HUMANOID | +| Charred Raider | HUMANOID | + +Monster category and loot category are separate concepts. + +Example: different BEAST monsters can later drop HIDE, CHITIN or another loot category. + +Do not assume `monsterCategory === lootCategory`. + +--- + +## 6. Bag Model + +Loot bags are **not normal armor equipment slots**. + +They belong to a separate character loadout/state. + +Recommended data model concept: + +```text +LootBagDefinition +- id +- key +- name +- lootCategory +- capacity +- iconPath + +CharacterLootBag +- characterId +- lootBagDefinitionId +- equipped / active +``` + +Only one active bag per loot category is required for V1. + +No nesting and no physical inventory grid. + +--- + +## 7. Initial Bag Definitions + +The system must support at least: + +### Basic Hide Bag + +```text +Name: Basic Hide Bag +Category: HIDE +Capacity: 5 +``` + +### Basic Trophy Pouch + +```text +Name: Basic Trophy Pouch +Category: RAIDER_TROPHY +Capacity: 5 +``` + +They do not both need to be obtainable in this slice. Acquisition is handled by the merchant/quest slices. + +--- + +## 8. Capacity Calculation + +The server is the single authority. + +Conceptually: + +```text +capacity(category) = active bag capacity +if no active bag exists: + capacity(category) = 1 +``` + +The current amount is the sum of all owned trade-good quantities in that category. + +Equipment, consumables and normal items are not affected by loot-bag capacity. + +--- + +## 9. Full Bag Behavior + +If combat loot would exceed the capacity: + +- the combat victory remains valid +- the trade good is not granted beyond capacity +- other loot rolls still succeed normally +- equipment must not be lost because the hide bag is full +- the loot summary explains what was left behind + +Example: + +```text +Loot +Ashen Pelt ×1 – Left behind (Hide Bag full) +Bandit Hood ×1 – Added to inventory +``` + +Do not silently discard loot without player feedback. + +--- + +## 10. Mixed Loot + +If one result grants multiple trade goods, grant as many as fit. + +Example: + +```text +Current HIDE: 4 / 5 +Reward: Tough Hide ×2 +Granted: 1 +Left behind: 1 +``` + +The result DTO should make granted and rejected quantities explicit. + +--- + +## 11. API / DTO Requirements + +The frontend needs enough information to display current carrying state. + +Example DTO concept: + +```json +{ + "category": "HIDE", + "current": 4, + "capacity": 5, + "bag": { + "key": "basic-hide-bag", + "name": "Basic Hide Bag" + } +} +``` + +Provide a server-side way for the player UI to retrieve all relevant category capacities. + +This can be part of inventory state or a dedicated endpoint, whichever best fits the current codebase. + +--- + +## 12. UI Requirements + +The current carrying state must be visible during or directly after hunting. + +Minimum: + +```text +Hides 4 / 5 +Raider Trophies 1 / 1 +``` + +If a category is full, make it obvious before the player starts another farm cycle. + +The loot summary must display: + +- granted trade goods +- capacity after loot +- left-behind goods when full + +Do not turn this into a large inventory-management screen. + +--- + +## 13. Tests + +### Capacity + +- no bag gives capacity 1 +- Basic Hide Bag gives HIDE capacity 5 +- Hide Bag does not increase RAIDER_TROPHY capacity +- only active bag affects capacity + +### Loot granting + +- first Ashen Pelt can be carried without a bag +- second Ashen Pelt is rejected without a bag +- equipment reward still grants when trade-good capacity is full +- partial quantity grant works + +### Security + +- client cannot submit a fake capacity +- client cannot fake an equipped bag +- capacity is derived from persisted character state + +--- + +## 14. Acceptance Criteria + +- [ ] Monster definitions have a reusable monster category. +- [ ] Trade goods have a reusable loot category. +- [ ] Default capacity without bag is 1 per loot category. +- [ ] Dedicated loot bags increase only their configured category. +- [ ] Capacity enforcement is server-authoritative. +- [ ] Full bags never invalidate a combat victory. +- [ ] Excess trade goods are clearly shown as left behind. +- [ ] Equipment/consumables are unaffected by trade-good capacity. +- [ ] Current bag capacity can be displayed in the UI. +- [ ] Tests cover full, partial and no-bag cases. + +--- + +## 15. Out of Scope + +Do not implement: + +- weight +- Tetris inventory +- bag durability +- random bag affixes +- crafting bags +- upgrades on one physical bag item +- more than one active bag per category +- automatic sending of excess loot to storage diff --git a/docs/playable-slices/0.8-Graufurt-Merchant-and-Trade-In.md b/docs/playable-slices/0.8-Graufurt-Merchant-and-Trade-In.md new file mode 100644 index 0000000..4c226bc --- /dev/null +++ b/docs/playable-slices/0.8-Graufurt-Merchant-and-Trade-In.md @@ -0,0 +1,300 @@ +# Ashen Realms – Playable Slice 0.8 + +## Graufurt Merchant & Trade-In Loop + +**Status:** Implementation Specification +**Depends on:** Slice 0.7.5 +**Purpose:** Close the first new progression loop by allowing monster trade goods to be exchanged for Silver, regional reputation and World Renown. + +--- + +## 1. Goal + +For the first time, the player can turn hunting success into long-term progression. + +The loop becomes: + +**Hunt → carry trade goods → return to Graufurt → exchange goods → receive Silver + reputation + World Renown → prepare for the next trip.** + +Normal monster kills continue to give no direct money or reputation. + +--- + +## 2. Merchant + +Introduce one functional merchant in Graufurt. + +Working name: + +**Borin, Quartermaster** + +If an existing project NPC already fills this role, reuse that NPC instead of creating a duplicate. + +The merchant requires: + +- local-view/NPC interaction entry point +- dialogue/open-shop action +- trade-good exchange view +- basic shop view + +The UI must use English player-facing content. + +--- + +## 3. Merchant Roles + +Borin initially supports two separate operations: + +### Trade In + +The player hands over monster trade goods. + +The server grants configured rewards. + +### Shop + +The player can spend Silver on basic supplies/items already allowed by the current content scope. + +Reputation gating is introduced in Slice 0.8.5, not here. + +--- + +## 4. Trade-In Reward Model + +Each accepted trade good has a data-driven exchange definition. + +Recommended conceptual fields: + +```text +tradeGoodKey +merchantKey / regionKey +silverPerUnit +regionalReputationPerUnit +worldRenownPerUnit or batch rule +enabled +``` + +Exact values are balancing data and may remain provisional. + +The important architectural rule is: + +> The kill produces the object. The merchant converts the object into economic and reputation progression. + +--- + +## 5. Initial Exchangeable Goods + +At minimum: + +- Ashen Pelt +- Tough Hide +- Raider Insignia +- Charred Raider Insignia + +The rarer Charred Raider trade good should be worth visibly more than the basic Ashen Pelt. + +Do not hardcode reward logic in Angular. + +--- + +## 6. Reputation Effects + +A successful exchange can improve: + +- **regional reputation** for the current progression region +- **World Renown** as the global long-term progression value + +Use the reputation/renown system introduced before this slice. + +If the existing reputation implementation also tracks NPC-specific reputation, the exchange may optionally improve Borin's personal reputation, but the slice must not invent a second competing progression model. + +The exact reward numbers should be configurable. + +--- + +## 7. Silver + +Silver is now primarily earned through exchange and quests/services rather than directly from normal monsters. + +The merchant exchange must be one of the first reliable Silver sources. + +Silver is persisted server-side and must be granted in the same transaction as the removal of trade goods. + +--- + +## 8. Transaction Safety + +A trade-in is atomic. + +Conceptually: + +```text +validate character ownership +validate quantity +validate merchant accepts good +remove goods +add Silver +add regional reputation +add World Renown +persist exchange record if needed +commit +``` + +If any required step fails, no partial exchange is allowed. + +The same trade goods must never be redeemable twice. + +--- + +## 9. API + +Recommended operation: + +```text +POST /api/merchants/:merchantKey/trade-in +``` + +Example request: + +```json +{ + "items": [ + { "itemKey": "ashen-pelt", "quantity": 5 } + ] +} +``` + +Example response shape: + +```json +{ + "consumed": [ + { "itemKey": "ashen-pelt", "quantity": 5 } + ], + "rewards": { + "silver": 20, + "regionalReputation": 10, + "worldRenown": 2 + }, + "balances": { + "silver": 84, + "regionalReputation": 26, + "worldRenown": 4 + } +} +``` + +Numbers above are illustrative only. + +--- + +## 10. Trade-In UI + +The trade-in view should show: + +- trade good icon +- name +- quantity carried +- exchange value +- selected quantity +- resulting reward preview +- confirmation button + +Useful actions: + +- Trade Selected +- Trade All + +After completion, show a clear summary: + +```text +Trade Complete +5 Ashen Pelts handed in ++20 Silver ++10 Ashen Fields Reputation ++2 World Renown +``` + +Do not use a mobile-game reward explosion. Keep presentation consistent with the dark browser-RPG UI. + +--- + +## 11. Bag Interaction + +Trade-in removes items from the player's carrying total immediately. + +Example: + +```text +Hides before exchange: 5 / 5 +Trade 5 Ashen Pelts +Hides after exchange: 0 / 5 +``` + +This immediately frees bag capacity for another trip. + +--- + +## 12. Basic Shop + +The shop may initially sell only already-supported basics, such as: + +- Small Healing Potion +- simple starter equipment if desired + +Avoid adding many new items just to populate the shop. + +The important feature of this slice is **trade-in**, not shop breadth. + +--- + +## 13. Tests + +### Exchange + +- owned goods can be exchanged +- excessive quantity is rejected +- unsupported item is rejected +- trade removes exact quantity +- Silver is granted +- regional reputation is granted +- World Renown follows configured reward rule +- capacity becomes available after goods are removed + +### Atomicity + +- reward failure does not consume goods +- duplicate request cannot duplicate rewards if request/idempotency protection exists in current architecture + +### Regression + +- normal monster victory still grants no Silver/reputation directly + +--- + +## 14. Acceptance Criteria + +- [ ] Graufurt has an interactable merchant entry point. +- [ ] Player can view carried trade goods accepted by the merchant. +- [ ] Player can exchange selected quantities. +- [ ] Trade goods are removed server-side. +- [ ] Silver is granted server-side. +- [ ] Regional reputation is granted according to configured values. +- [ ] World Renown is granted according to configured values/rules. +- [ ] Bag capacity is freed by trade-in. +- [ ] Exchange is transactional and cannot be duplicated by client manipulation. +- [ ] The complete Hunt → Return → Trade loop works without DB editing. + +--- + +## 15. Out of Scope + +Do not add yet: + +- reputation-locked shop items +- referral-based merchant exceptions +- first tutorial quest +- advanced buy/sell economy +- player-to-player trading +- auction house +- crafting materials market diff --git a/docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md b/docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md new file mode 100644 index 0000000..a36acc9 --- /dev/null +++ b/docs/playable-slices/0.8.5-Reputation-Gated-Merchant-Offers.md @@ -0,0 +1,247 @@ +# Ashen Realms – Playable Slice 0.8.5 + +## Reputation-Gated Merchant Offers + +**Status:** Implementation Specification +**Depends on:** Slice 0.8 +**Purpose:** Make reputation visible and useful by locking selected merchant offers behind reputation or explicit unlock conditions instead of character levels. + +--- + +## 1. Goal + +The reputation system must change what the player can do. + +A merchant should be able to communicate: + +> "I don't know you well enough for that." + +or: + +> "Earn more trust in the Ashen Fields first." + +The player sees desirable locked items, understands the requirement, and has a reason to continue hunting and trading. + +--- + +## 2. No Level-Gated Merchant Progression + +New merchant offers must not use character level as the primary progression gate. + +Preferred gates: + +- regional reputation +- World Renown +- NPC reputation, if already supported +- quest/unlock flag + +The server is authoritative for all purchase requirements. + +If `requiredLevel` still exists in an old item schema, it may remain for compatibility, but new Tier-1 merchant progression in these slices should not rely on it. + +--- + +## 3. Offer Requirement Model + +Merchant offers should support an optional requirement definition. + +Conceptual examples: + +```text +NONE +REGION_REPUTATION +WORLD_RENOWN +NPC_REPUTATION +QUEST_FLAG +``` + +An offer may later support multiple requirements, but do not overbuild a general rule engine unless the current code already has one. + +A pragmatic structure is enough: + +```text +requiredRegionReputation +requiredWorldRenown +requiredNpcReputation +requiredUnlockFlag +``` + +with nullable values. + +--- + +## 4. Initial Locked Offers + +At least two useful offers should demonstrate the system. + +Recommended examples: + +### Basic Hide Bag + +- important progression item +- may be normally reputation-gated +- Slice 0.9 can temporarily bypass the gate through a quest referral + +### Basic Trophy Pouch + +- unlocked after the player has demonstrated some regional reputation + +Optional additional offer: + +### Bandit Blade or another Tier-1 gap filler + +- available at a higher reputation threshold + +Exact thresholds are balancing data. + +--- + +## 5. Visible Locked Offers + +Locked offers should usually remain visible. + +Example: + +```text +Basic Trophy Pouch +Capacity: 5 Raider Trophies +Price: 40 Silver +Requires: Ashen Fields Reputation 25 +Current: 14 +``` + +The Buy action is disabled. + +This is preferable to hiding every locked item, because visible rewards create goals. + +--- + +## 6. Server Validation + +A malicious client must not bypass a disabled button. + +Purchase flow: + +```text +load character +load merchant offer +validate price +validate reputation requirement +validate quest/unlock flag +validate inventory/bag ownership rules +remove currency +grant item/bag +commit +``` + +If reputation is insufficient, return a stable domain error such as: + +```text +MERCHANT_REPUTATION_TOO_LOW +``` + +with an English player-facing message. + +--- + +## 7. Referral / Exception Support + +Slice 0.9 needs a special tutorial moment: + +The merchant normally would not give the player a useful bag yet, but a gate NPC sends the player with a referral. + +Therefore the offer/unlock system must support one minimal exception: + +```text +requiredUnlockFlag = referred-by-south-gate-warden +``` + +or an equivalent quest reward/grant path. + +Do not implement a complex faction favor engine. + +The requirement is simply that the quest can legally allow one specific acquisition that reputation alone would not yet allow. + +--- + +## 8. UI Requirements + +Merchant cards/rows show: + +- item/bag name +- icon +- Silver price +- relevant effect +- requirement +- player's current value +- locked/unlocked state + +Use clear English copy. + +Examples: + +```text +Requires Ashen Fields Reputation 25 +``` + +```text +Requires World Renown 3 +``` + +```text +Unavailable – Borin does not know you well enough. +``` + +--- + +## 9. Feedback on Unlock + +When a reputation increase makes an offer newly available, the player should receive lightweight feedback. + +Example: + +```text +New merchant offer unlocked: Basic Trophy Pouch +``` + +No large modal is required. + +--- + +## 10. Tests + +- offer with no requirement can be purchased +- insufficient regional reputation blocks purchase +- sufficient regional reputation allows purchase +- insufficient World Renown blocks purchase +- quest flag can unlock configured tutorial offer +- disabled client state is not trusted by backend +- price is still required even when reputation condition is met +- unlock state changes after a successful trade-in raises reputation + +--- + +## 11. Acceptance Criteria + +- [ ] Merchant offers can define reputation/unlock requirements. +- [ ] At least two offers visibly demonstrate locked states. +- [ ] Requirements are shown to the player in English. +- [ ] Server rejects purchases when requirements are not met. +- [ ] New progression offers do not depend on level gates. +- [ ] Quest/referral unlock support exists for Slice 0.9. +- [ ] Reputation increase can visibly unlock a previously locked offer. +- [ ] No generalized rules engine was added unnecessarily. + +--- + +## 12. Out of Scope + +Do not implement: + +- dynamic merchant personalities +- haggling +- randomized daily shops +- faction wars +- reputation decay +- negative reputation systems +- multiple currencies per individual offer unless already required diff --git a/docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md b/docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md new file mode 100644 index 0000000..244607a --- /dev/null +++ b/docs/playable-slices/0.9-First-Quest-and-Bag-Tutorial.md @@ -0,0 +1,321 @@ +# Ashen Realms – Playable Slice 0.9 + +## First Quest & Loot-Bag Tutorial + +**Status:** Implementation Specification +**Depends on:** Slice 0.8.5 +**Purpose:** Introduce quests, NPC-directed progression, bag capacity and the merchant loop through one small coherent tutorial chain. + +--- + +## 1. Goal + +This slice should teach several systems without a conventional tutorial popup sequence. + +The player learns naturally that: + +- NPCs provide direction +- monster goods must be carried +- carrying capacity matters +- merchants can help +- reputation affects relationships/offers +- hunting goods can later be exchanged for progression + +The quest should feel like world interaction, not a checklist tutorial. + +--- + +## 2. Quest Concept + +Working quest title: + +**Trouble Beyond the Gate** + +Quest-giver: + +**South Gate Warden** + +Use an existing named gate NPC if one has already been implemented. Do not create a duplicate NPC merely because this document uses a generic title. + +Merchant: + +**Borin, Quartermaster** or the existing Graufurt merchant. + +--- + +## 3. Narrative Flow + +### Step 1 – Speak with the South Gate Warden + +The warden is concerned about increasingly aggressive scavengers and raiders beyond the gate. + +Player-facing English example: + +> "The road has gone bad. Start small. Bring me five Ashen Pelts from the rats beyond the gate. I want to know what the ash is doing to them." + +Objective: + +```text +Collect 5 Ashen Pelts +``` + +--- + +## 4. Capacity Problem + +The player currently has no Hide Bag. + +Default HIDE capacity is 1. + +After collecting the first Ashen Pelt: + +```text +Ashen Pelts 1 / 1 +Hide capacity reached. +``` + +The quest remains at `1 / 5`. + +The game should now provide a clear next action rather than leaving the player confused. + +Suggested quest update: + +```text +You cannot carry enough pelts. Return to the South Gate Warden. +``` + +Do not secretly increase capacity for the quest. + +--- + +## 5. Referral to the Merchant + +On returning, the warden acknowledges the problem and refers the player to Borin. + +Example: + +> "Right. You're not equipped for hauling spoils yet. Go see Borin in Graufurt. Tell him I sent you. He'll complain, but he'll give you something useful." + +Set a persisted unlock/referral flag, for example: + +```text +referred-by-south-gate-warden +``` + +New objective: + +```text +Speak with Borin in Graufurt +``` + +--- + +## 6. Merchant Interaction + +Borin normally would not provide the bag at the player's current reputation. + +His dialogue should make the relationship system visible. + +Example: + +> "I don't know you, and I don't hand out gear to every wanderer who walks in here." + +Then after recognizing the referral: + +> "But the South Gate Warden sent you. Fine. Take this. Bring it back full and make it worth my trouble." + +Grant: + +**Basic Hide Bag** + +```text +Category: HIDE +Capacity: 5 +``` + +The grant should use the quest/referral exception supported by Slice 0.8.5. + +--- + +## 7. Continue the Hunt + +The player can now carry five HIDE-category goods. + +Objective returns to: + +```text +Collect 5 Ashen Pelts +``` + +Important: + +- an already-owned first pelt still counts +- the player should need four more, not five more +- the bag capacity view changes immediately to `1 / 5` + +--- + +## 8. Quest Turn-In + +The player returns to the South Gate Warden with five Ashen Pelts. + +The quest consumes the required five pelts. + +The warden explains the normal long-term loop: + +> "Good. That's enough for me. From now on, take hides and trophies to Borin. He'll pay for useful spoils, and word gets around when you keep the roads clear." + +This line connects: + +- monster loot +- merchant exchange +- Silver +- regional reputation +- World Renown + +without needing a separate tutorial screen. + +--- + +## 9. Quest Rewards + +The main reward of this quest is the **Basic Hide Bag** and system knowledge. + +Optional one-time rewards may include: + +- small regional reputation increase +- small World Renown milestone increase +- quest completion flag + +Avoid a large Silver reward that undermines the merchant trade loop. + +No XP reward. + +--- + +## 10. Minimal Quest System Requirements + +The system must support at least: + +- quest definition +- quest acceptance +- ordered objectives +- item collection progress +- NPC interaction objective +- persisted completion +- quest flags/unlock flags +- item consumption on turn-in +- one-time rewards + +Do not build a full branching narrative engine. + +A simple step-based quest state machine is sufficient. + +--- + +## 11. Quest State Safety + +The flow must not softlock when: + +- player already owns one or more Ashen Pelts before accepting +- player sells/trades pelts while quest is active +- player disconnects during a quest step +- bag was already obtained through development data +- player tries to receive the referral bag twice + +Define deterministic behavior. + +Recommended: + +- objectives derive progress from current owned quantity where appropriate +- quest item consumption validates quantity at turn-in +- bag grant is idempotent +- referral flag persists + +--- + +## 12. UI Requirements + +At minimum: + +### NPC / Local View + +- quest available marker +- quest in progress marker +- quest turn-in marker +- Talk interaction + +### Quest UI + +Show: + +- title +- short description +- current objective +- progress + +Example: + +```text +Trouble Beyond the Gate +Collect Ashen Pelts 1 / 5 + +You cannot carry enough pelts. Return to the South Gate Warden. +``` + +### Bag UI + +When the bag is granted: + +```text +New Loot Bag +Basic Hide Bag +Hide Capacity: 5 +``` + +--- + +## 13. Tests + +- quest can be accepted only once in active state +- first pelt updates objective to 1/5 +- no bag blocks carrying second HIDE item +- return step activates correctly +- referral flag is set +- merchant grants Basic Hide Bag once +- capacity becomes 5 +- previously owned pelt remains counted +- five pelts allow turn-in +- turn-in consumes five pelts +- quest completes and cannot reward bag repeatedly + +--- + +## 14. Acceptance Criteria + +- [ ] One complete NPC quest chain is playable. +- [ ] The quest intentionally exposes the default 1-item carrying limit. +- [ ] The player is directed back instead of softlocked. +- [ ] A persisted merchant referral is created. +- [ ] Merchant grants Basic Hide Bag because of the referral. +- [ ] Hide capacity becomes 5. +- [ ] Player can collect five Ashen Pelts. +- [ ] Turn-in consumes the pelts and completes the quest. +- [ ] NPC explains that future goods should be traded to the merchant. +- [ ] Quest grants no XP. +- [ ] All dialogue/UI text is English. + +--- + +## 15. Out of Scope + +Do not implement: + +- branching quest choices +- voice acting +- cinematic dialogue +- daily quests +- repeatable bounty framework +- quest sharing +- party quest progress +- large quest journal taxonomy diff --git a/docs/playable-slices/Ashen Realms – Playable Slice 0.13_ Realtime Game Events Foundation.md b/docs/playable-slices/Ashen Realms – Playable Slice 0.13_ Realtime Game Events Foundation.md new file mode 100644 index 0000000..dad1c2e --- /dev/null +++ b/docs/playable-slices/Ashen Realms – Playable Slice 0.13_ Realtime Game Events Foundation.md @@ -0,0 +1,1738 @@ +# Ashen Realms – Playable Slice 0.13 + +## Realtime Game Events Foundation + +**Status:** Implementation Specification +**Slice:** 0.13 +**Scope:** Zentraler WebSocket-Kanal für serverinitiierte Game Events +**Primary Use Case:** Combat Events +**Architecture:** REST Commands + WebSocket Events + server authoritative state + +--- + +# 1. Ziel + +Slice 0.13 führt eine zentrale Realtime-Kommunikationsschicht für Ashen Realms ein. + +Bis einschließlich Slice 0.12 funktioniert das Spiel primär request-basiert: + +```text +Client +→ REST Request +→ Server verarbeitet +→ Response +``` + +Das reicht für: + +- Reisen +- Jagd +- Einzelspieler-Kämpfe +- Inventar +- Equipment +- Quests +- Händler +- Charakterverwaltung + +Langfristig entstehen jedoch Situationen, bei denen sich der Spielzustand verändern kann, **ohne dass der aktuelle Client selbst einen Request ausgelöst hat**. + +Beispiele: + +- ein anderer Spieler führt im gemeinsamen Kampf eine Aktion aus +- ein NPC führt verzögert eine Kampfaktion aus +- ein anderer Spieler ist nun am Zug +- ein Gruppenmitglied tritt einem Kampf bei +- ein Begleiter oder Pet führt eine Aktion aus +- der Kampf endet durch die Aktion eines anderen Beteiligten +- später verändert ein externer Effekt Charakterwerte +- Gruppen-, Quest- oder Weltzustände ändern sich + +Dafür wird ein zentraler WebSocket-Kanal eingeführt. + +Grundsatz: + +> **REST beschreibt, was der Spieler tun möchte. WebSocket Events beschreiben, was im Spiel passiert ist.** + +--- + +# 2. Nicht-Ziel dieses Slices + +Slice 0.13 führt **kein vollständiges Multiplayer-Kampfsystem** ein. + +Noch nicht Teil dieses Slices: + +- mehrere menschliche Spieler im selben Kampf +- mehrere Gegner in einem Kampf +- Pets +- NPC-Begleiter +- Party-System +- PvP +- Chat +- Location Presence +- Welt-Events +- globale Tick-Simulation +- WebSocket-basierte HP-Regeneration +- vollständige Umstellung bestehender REST-Endpunkte auf WebSockets + +Der bestehende 1-vs-1-Combat bleibt funktional bestehen. + +0.13 schafft ausschließlich die technische Grundlage, auf der spätere Mehrteilnehmer-Systeme aufbauen können. + +--- + +# 3. Architekturprinzip + +Ashen Realms verwendet ab Slice 0.13 drei unterschiedliche Mechanismen für unterschiedliche Arten von Zustand. + +## 3.1 REST – Commands und Reads + +REST bleibt verantwortlich für explizite Spieleraktionen. + +Beispiele: + +```text +POST /api/travel +POST /api/hunts +POST /api/combats/:id/actions +POST /api/equipment + +GET /api/characters/me +GET /api/inventory +GET /api/combats/:id +``` + +Der Client sendet weiterhin keine berechneten Spielwerte an den Server. + +Beispiel: + +```json +{ + "action": "ATTACK" +} +``` + +Nicht: + +```json +{ + "action": "ATTACK", + "damage": 27 +} +``` + +--- + +## 3.2 WebSocket – diskrete serverseitige Ereignisse + +WebSocket wird verwendet, wenn der Server einen Client über ein Ereignis informieren muss. + +Beispiele: + +```text +combat.action.resolved +combat.turn.changed +combat.finished + +später: +character.vitals.changed +party.member.joined +quest.updated +location.player.joined +world.event.started +``` + +--- + +## 3.3 Zeitstempel – kontinuierlich ableitbarer Zustand + +Zeitabhängige Werte werden weiterhin nicht permanent übertragen. + +Beispiele: + +- HP-Regeneration +- Reisezeit +- Cooldowns +- Buff-Dauer +- Respawn-Zeit + +Diese Systeme verwenden weiterhin: + +```text +Basiszustand ++ +Zeitstempel ++ +Regel +``` + +Der Client darf daraus die Darstellung interpolieren. + +Der Server bleibt für den tatsächlichen Zustand autoritativ. + +--- + +# 4. Zentrale Regel + +Es gibt **eine WebSocket-Verbindung pro eingeloggtem Client**. + +Nicht: + +```text +/combat websocket +/character websocket +/party websocket +``` + +Sondern: + +```text +/events +``` + +Darüber werden verschiedene fachliche Event-Typen transportiert. + +--- + +# 5. Zielarchitektur + +```text +Angular Client + | + | REST Commands + v +NestJS Controllers + | + v +Domain Services + | + +--------------------------+ + | | + v v +PostgreSQL GameEventPublisher + | + v + EventsGateway + | + | WebSocket + v + Angular Client +``` + +Beispiel Combat: + +```text +Spieler klickt Angriff + | + v +POST /api/combats/:id/actions + | + v +CombatService + | + ├── CombatEngine + ├── State speichern + ├── CombatEvents speichern + └── Transaction Commit + | + v + CombatEventPublisher + | + v + EventsGateway + | + v + combat.action.resolved +``` + +--- + +# 6. Wichtigste Architekturregel + +Ein WebSocket-Event darf erst veröffentlicht werden, **nachdem der zugehörige persistente Zustand erfolgreich gespeichert wurde**. + +Nicht: + +```text +Event senden +→ danach DB speichern +``` + +Sondern: + +```text +DB Transaction +→ Commit +→ Event veröffentlichen +``` + +Dadurch gilt: + +> Wenn ein Client ein Event erhält, existiert der gemeldete Zustand bereits autoritativ auf dem Server. + +--- + +# 7. WebSocket-Verbindung + +Endpoint: + +```text +/events +``` + +Die konkrete technische Umsetzung darf beispielsweise mit NestJS WebSocket Gateway und Socket.IO oder nativen WebSockets erfolgen. + +Für V1 sollte die einfachere, robuste NestJS-Integration bevorzugt werden. + +--- + +# 8. Authentifizierung + +Die WebSocket-Verbindung muss authentifiziert sein. + +Der Server muss aus der Verbindung mindestens bestimmen können: + +```text +userId +characterId +``` + +Ein Client darf niemals selbst angeben: + +```text +Ich bin Character X. +``` + +ohne dass der Server diese Zuordnung aus der Authentifizierung validiert. + +--- + +# 9. Connection Lifecycle + +Der Client baut nach erfolgreicher Authentifizierung eine Verbindung auf. + +```text +Login +→ Character laden +→ WebSocket /events verbinden +``` + +Bei Logout: + +```text +WebSocket trennen +``` + +Bei Verbindungsverlust: + +```text +Reconnect versuchen +``` + +Der WebSocket darf niemals Voraussetzung dafür sein, dass der persistente Spielzustand korrekt bleibt. + +--- + +# 10. Reconnect-Prinzip + +Ein WebSocket ist ein Benachrichtigungskanal, nicht die Source of Truth. + +Wenn der Client die Verbindung verliert: + +```text +WebSocket disconnected +``` + +läuft das Spiel serverseitig weiter. + +Nach Wiederherstellung: + +```text +WebSocket reconnect +→ aktuellen Zustand per REST synchronisieren +``` + +Beispielsweise: + +```text +GET /api/characters/me +GET /api/combats/:id +``` + +Der Client darf niemals annehmen, dass er während des Disconnects keine Events verpasst hat. + +--- + +# 11. Event Envelope + +Alle Events verwenden eine gemeinsame Grundstruktur. + +Beispiel: + +```ts +export interface GameEvent { + id: string; + sequence?: number; + type: GameEventType; + occurredAt: string; + payload: TPayload; +} +``` + +Beispiel: + +```json +{ + "id": "event-uuid", + "sequence": 42, + "type": "combat.action.resolved", + "occurredAt": "2026-08-21T15:00:00.000Z", + "payload": { + "combatId": "combat-uuid" + } +} +``` + +--- + +# 12. Event Naming + +Event-Namen verwenden: + +```text +domain.entity-or-action.event +``` + +Für Slice 0.13: + +```text +combat.action.resolved +combat.turn.changed +combat.finished +combat.state.changed +``` + +Später möglich: + +```text +character.vitals.changed +character.stats.changed + +party.member.joined +party.member.left + +quest.updated + +location.player.joined +location.player.left +``` + +--- + +# 13. Keine technischen Event-Namen + +Nicht: + +```text +UPDATE_COMBAT +REFRESH_UI +DB_CHANGED +SYNC_PLAYER +``` + +Events beschreiben fachlich, was passiert ist. + +Gut: + +```text +combat.turn.changed +``` + +Schlecht: + +```text +refreshCombatScreen +``` + +--- + +# 14. Rooms / Channels + +Die zentrale WebSocket-Verbindung verwendet serverseitige Rooms. + +Vorgesehene Room-Typen: + +```text +user: +character: +combat: +``` + +Später: + +```text +party: +location: +guild: +``` + +--- + +# 15. User Room + +Jeder eingeloggte Client wird automatisch Mitglied von: + +```text +user: +``` + +Dieser Room ist geeignet für: + +- globale persönliche Benachrichtigungen +- Account-bezogene Ereignisse +- spätere systemweite Hinweise + +--- + +# 16. Character Room + +Der Client tritt zusätzlich bei: + +```text +character: +``` + +Geeignet für: + +- Charakteränderungen +- Statusänderungen +- später Buffs +- später Ruf +- später externe Heilung + +--- + +# 17. Combat Room + +Wenn ein Charakter einen aktiven Kampf besitzt: + +```text +combat: +``` + +Der Server fügt den Client diesem Room hinzu. + +Beim Verlassen oder Ende des Kampfes wird die Subscription entfernt. + +Später können mehrere Spieler gleichzeitig Mitglied desselben Combat Rooms sein. + +--- + +# 18. Kein frei wählbares Room Joining + +Der Client darf nicht einfach senden: + +```text +join combat:xyz +``` + +und dadurch beliebige Kämpfe beobachten. + +Der Server validiert immer: + +```text +Ist dieser Charakter Teilnehmer dieses Kampfes? +``` + +Erst danach darf der Socket dem Room beitreten. + +--- + +# 19. Game Events Backend-Struktur + +Vorgesehene Struktur: + +```text +apps/api/src/events/ +├── events.module.ts +├── events.gateway.ts +├── game-event.types.ts +├── game-event-publisher.service.ts +├── event-room.service.ts +└── events.gateway.spec.ts +``` + +Optional fachlich getrennt: + +```text +apps/api/src/combat/ +└── combat-event.publisher.ts +``` + +--- + +# 20. `EventsGateway` + +Verantwortlich für: + +- WebSocket-Verbindungen +- Authentifizierung +- Disconnect +- Reconnect-Unterstützung +- Room-Mitgliedschaften +- Senden von Events + +Nicht verantwortlich für: + +- Combat-Regeln +- Schadensberechnung +- Charakterwerte +- Loot +- Questlogik + +--- + +# 21. `GameEventPublisher` + +Der Domain-Code soll nicht direkt mit dem WebSocket-Gateway kommunizieren. + +Nicht: + +```ts +this.eventsGateway.server + .to(room) + .emit(...); +``` + +innerhalb des CombatService. + +Stattdessen: + +```ts +this.gameEventPublisher.publishToCombat( + combatId, + event, +); +``` + +Dadurch bleibt der Transport austauschbar und die Domain-Logik kennt keine Socket.IO-Details. + +--- + +# 22. Beispiel Publisher Interface + +Konzeptionell: + +```ts +export interface GameEventPublisher { + publishToUser( + userId: string, + event: GameEvent, + ): void; + + publishToCharacter( + characterId: string, + event: GameEvent, + ): void; + + publishToCombat( + combatId: string, + event: GameEvent, + ): void; +} +``` + +--- + +# 23. Combat als erster Use Case + +Slice 0.13 verwendet Combat als ersten realen Event-Produzenten. + +Der bestehende Combat-Flow bleibt: + +```text +Client +→ POST Combat Action +→ Server berechnet Resultat +→ Server persistiert Resultat +→ Response +``` + +Zusätzlich: + +```text +→ Server veröffentlicht Combat Events +→ WebSocket liefert sie an Combat Room +``` + +--- + +# 24. `combat.action.resolved` + +Wird veröffentlicht, wenn eine Combat Action serverseitig vollständig verarbeitet wurde. + +Beispiel: + +```json +{ + "type": "combat.action.resolved", + "payload": { + "combatId": "abc", + "round": 4, + "events": [ + { + "sequence": 21, + "type": "PLAYER_ATTACK", + "actorId": "player", + "targetId": "monster", + "amount": 20 + } + ] + } +} +``` + +Der Client verwendet dieses Event für: + +- Animationen +- Damage Numbers +- Combat Log +- UI-State + +--- + +# 25. `combat.turn.changed` + +Wird veröffentlicht, sobald sich der aktive Actor ändert. + +Payload: + +```ts +interface CombatTurnChangedPayload { + combatId: string; + actorId: string; + startedAt: string; + expiresAt?: string; +} +``` + +Für den aktuellen 1-vs-1-Combat kann dieses Event zunächst nur begrenzt genutzt werden. + +Die Struktur wird jedoch bereits für spätere Mehrteilnehmer-Kämpfe vorbereitet. + +--- + +# 26. `combat.finished` + +Wird bei Sieg, Niederlage oder später Flucht veröffentlicht. + +Beispiel: + +```json +{ + "type": "combat.finished", + "payload": { + "combatId": "abc", + "result": "WON" + } +} +``` + +Später können weitere Informationen ergänzt werden: + +```text +loot +reputation +questProgress +``` + +Diese Daten müssen jedoch weiterhin serverseitig autoritativ sein. + +--- + +# 27. `combat.state.changed` + +Optionales allgemeines Sync-Event. + +Es darf verwendet werden, wenn der Client wissen soll: + +> Der Combat-State hat sich geändert; lade bei Bedarf den aktuellen Zustand neu. + +Beispiel: + +```json +{ + "type": "combat.state.changed", + "payload": { + "combatId": "abc", + "revision": 17 + } +} +``` + +Es soll nicht jede spezialisierte Combat-Nachricht ersetzen. + +--- + +# 28. HTTP Response und WebSocket Event + +Für die Aktion des lokalen Spielers darf die HTTP Response weiterhin das Resultat enthalten. + +Das erzeugt bewusst mögliche doppelte Information: + +```text +HTTP Response ++ +WebSocket Broadcast +``` + +Der Client muss damit umgehen können. + +Deshalb brauchen Events stabile IDs oder Sequenzen. + +--- + +# 29. Event-Deduplizierung + +Der Client darf dasselbe Combat Event nicht zweimal animieren. + +CombatEvents besitzen bereits beziehungsweise erhalten eine stabile Reihenfolge. + +Beispiel: + +```text +combatId +sequence +``` + +Client speichert: + +```text +lastProcessedSequence +``` + +Wenn: + +```text +sequence <= lastProcessedSequence +``` + +wird das Event nicht erneut abgespielt. + +--- + +# 30. Event-Reihenfolge + +Innerhalb eines Combat Rooms muss die Reihenfolge serverseitig eindeutig sein. + +Beispiel: + +```text +41 PLAYER_ATTACK +42 DAMAGE_APPLIED +43 MONSTER_ATTACK +44 DAMAGE_APPLIED +45 TURN_CHANGED +``` + +Der Client spielt diese Reihenfolge ab. + +Nicht die Empfangszeit entscheidet über die fachliche Reihenfolge. + +--- + +# 31. Persistierte CombatEvents bleiben Source of Truth + +WebSocket Events ersetzen nicht die persistierten `CombatEvent`-Datensätze. + +Die Datenbank bleibt maßgeblich. + +Dadurch werden später möglich: + +- Reconnect +- Combat Log +- Debugging +- Replay +- nachträgliches Nachladen verpasster Events + +--- + +# 32. Resync nach Event-Lücke + +Wenn ein Client erkennt: + +```text +letzte bekannte Sequence = 40 +neues Event = 44 +``` + +existiert eine Lücke. + +Dann darf der Client nicht raten. + +Er synchronisiert: + +```text +GET /api/combats/:id +``` + +oder später: + +```text +GET /api/combats/:id/events?after=40 +``` + +Der konkrete Events-After-Endpunkt muss in 0.13 noch nicht zwingend implementiert werden. + +--- + +# 33. Frontend-Struktur + +Vorgeschlagene Struktur: + +```text +apps/web/src/app/core/events/ +├── game-events.service.ts +├── game-events.models.ts +├── game-events.store.ts +└── game-events.service.spec.ts +``` + +--- + +# 34. `GameEventsService` + +Verantwortlich für: + +- Verbindung herstellen +- Reconnect +- eingehende Events empfangen +- Event-Typen verteilen +- Lifecycle verwalten + +Nicht verantwortlich für: + +- Combat-State +- Character-State +- Quest-State + +--- + +# 35. Event-Verteilung im Frontend + +Der zentrale Event-Service verteilt Events fachlich. + +Konzeptionell: + +```ts +combatEvents$ +characterEvents$ +partyEvents$ +``` + +oder mit Angular Signals: + +```ts +combatEvent +characterEvent +``` + +Feature Stores abonnieren nur relevante Eventtypen. + +--- + +# 36. CombatStore Integration + +Der CombatStore reagiert auf: + +```text +combat.action.resolved +combat.turn.changed +combat.finished +``` + +Der Store darf daraus: + +- Events in Animationsqueue einreihen +- Aktionen aktivieren/deaktivieren +- Combat-State aktualisieren +- bei Unsicherheit REST-Resync auslösen + +--- + +# 37. WebSocket Event ≠ Animation + +Das Backend definiert fachliche Events. + +Nicht: + +```text +PLAY_SWORD_ANIMATION +WAIT_800_MS +SHOW_RED_NUMBER +``` + +Sondern: + +```text +ATTACK +DAMAGE +HEAL +STATUS_APPLIED +TURN_CHANGED +``` + +Der Client entscheidet, wie diese visuell dargestellt werden. + +Damit bleibt die Trennung erhalten: + +> Server entscheidet, was passiert. Client entscheidet, wie es aussieht. + +--- + +# 38. Keine WebSocket-HP-Ticks + +Das bestehende HP-Regenerationsmodell bleibt unverändert. + +Weiterhin: + +```text +currentHp +hpRegenSince +hpRegenPerSecond +``` + +Der Client berechnet die sichtbare Regeneration lokal. + +Nicht implementieren: + +```text +character.hp.changed 71 +character.hp.changed 72 +character.hp.changed 73 +``` + +pro Sekunde. + +--- + +# 39. Spätere Character Events + +Die Architektur soll jedoch ermöglichen: + +```text +character.vitals.changed +``` + +wenn eine **diskrete externe Veränderung** eintritt. + +Beispiele: + +- ein anderer Spieler heilt +- ein Gruppenbuff verändert Max HP +- ein Debuff verursacht Schaden +- eine externe Spielaktion verändert HP + +Beispiel: + +```json +{ + "type": "character.vitals.changed", + "payload": { + "characterId": "abc", + "currentHp": 84, + "hpRegenSince": "2026-08-21T15:10:20.000Z" + } +} +``` + +Danach läuft die bestehende lokale Regeneration weiter. + +Dieses Event muss in Slice 0.13 noch nicht produktiv verwendet werden. + +--- + +# 40. Keine globale Server-Tick-Schleife + +Slice 0.13 führt ausdrücklich keinen zentralen: + +```text +10 Hz +20 Hz +60 Hz +``` + +Game Loop ein. + +Ashen Realms bleibt: + +```text +event-driven ++ +turn-based ++ +timestamp-based +``` + +Server-Ticks werden erst eingeführt, wenn ein konkretes zukünftiges Feature sie tatsächlich benötigt. + +--- + +# 41. Vorbereitung für verzögerte NPC-Aktionen + +Die Event-Infrastruktur muss ermöglichen, dass später folgendes passiert: + +```text +Player Action +→ Commit +→ Combat Event +→ NPC Turn geplant +→ HTTP Request ist längst beendet +→ NPC Action wird ausgeführt +→ Commit +→ WebSocket Combat Event +``` + +Der Client muss dadurch nicht mehr selbst warten und anschließend eine NPC-Aktion simulieren. + +Die tatsächliche serverseitige NPC-Turn-Scheduling-Logik ist nicht Teil von 0.13. + +--- + +# 42. Keine `sleep()`-Requests + +Nicht implementieren: + +```ts +await sleep(1000); +performNpcAttack(); +return response; +``` + +HTTP Requests dürfen nicht künstlich offen gehalten werden, um Kampftiming zu simulieren. + +Spätere AI-Turns müssen unabhängig vom ursprünglichen Request verarbeitet werden können. + +--- + +# 43. Vorbereitung auf Combat Participants + +0.13 muss noch keine vollständige `CombatParticipant`-Migration durchführen, darf die Event Contracts aber nicht hart auf: + +```text +PLAYER +MONSTER +``` + +begrenzen. + +Events sollten allgemein mit: + +```text +actorId +targetId +``` + +arbeiten. + +Nicht: + +```text +playerDamage +monsterDamage +``` + +Dadurch können spätere Teilnehmer sein: + +```text +PLAYER +NPC +PET +COMPANION +BOSS +``` + +--- + +# 44. Beispiel zukünftiger Kampf + +Die 0.13-Architektur soll später ohne neuen Transportmechanismus ermöglichen: + +```text +TEAM A +- Player A +- Player B +- Pet + +TEAM B +- Bandit +- Wolf +- Bandit +``` + +Ablauf: + +```text +Player A handelt +→ Event + +Wolf handelt +→ Event + +Player B ist dran +→ Event + +Player B Client erhält: +combat.turn.changed +``` + +--- + +# 45. Fehlerbehandlung + +WebSocket-Fehler dürfen keinen falschen Spielzustand erzeugen. + +Bei: + +```text +Disconnect +invalid event +sequence gap +parse error +``` + +gilt: + +```text +UI als unsynchronisiert markieren +→ REST Resync +``` + +Nicht: + +```text +lokal weitersimulieren +``` + +--- + +# 46. Client-Verbindungsstatus + +Optional sichtbar oder intern: + +```ts +type EventConnectionState = + | 'DISCONNECTED' + | 'CONNECTING' + | 'CONNECTED' + | 'RECONNECTING'; +``` + +Bei kurzfristigem Disconnect soll nicht sofort eine störende Fehlermeldung erscheinen. + +Erst wenn Realtime tatsächlich für eine aktive Funktion nötig ist, muss der Zustand prominent dargestellt werden. + +--- + +# 47. Security + +Der Server muss prüfen: + +- gültige Authentifizierung +- Socket gehört zum User +- Character gehört zum User +- Combat gehört zum Character +- Room-Mitgliedschaft ist erlaubt + +Der Client darf: + +- keine fremden Combat Rooms abonnieren +- keine fremden Character Rooms abonnieren +- keine Server Events fälschen + +--- + +# 48. Rate Limits + +Für 0.13 ist kein komplexes WebSocket Rate Limiting notwendig. + +Dennoch gilt: + +Client-Nachrichten über den Socket sollten möglichst minimal bleiben. + +Da Commands weiterhin über REST laufen, ist der WebSocket in 0.13 fast ausschließlich: + +```text +Server → Client +``` + +--- + +# 49. Warum trotzdem WebSocket statt SSE + +Obwohl Slice 0.13 hauptsächlich Server-Push benötigt, wird direkt WebSocket verwendet. + +Grund: + +Spätere Systeme benötigen voraussichtlich bidirektionale Echtzeitkommunikation: + +- Multiplayer Combat +- Parties +- Chat +- Presence +- Social Systems + +Dadurch wird vermieden: + +```text +REST ++ SSE ++ später zusätzlich WebSocket +``` + +Ziel bleibt: + +```text +REST ++ +WebSocket +``` + +--- + +# 50. Shared Contracts + +Cross-Boundary Contracts gehören nach: + +```text +packages/shared +``` + +Geeignet: + +```ts +GameEvent +GameEventType +CombatActionResolvedPayload +CombatTurnChangedPayload +CombatFinishedPayload +``` + +Nicht dort ablegen: + +- NestJS Gateway +- Socket.IO Server +- Angular Services +- TypeORM Entities + +--- + +# 51. Vorgeschlagene Dateien Backend + +```text +packages/shared/src/events/ +├── game-event.ts +├── game-event-type.ts +└── combat-events.ts + +apps/api/src/events/ +├── events.module.ts +├── events.gateway.ts +├── event-room.service.ts +├── game-event-publisher.service.ts +├── events.gateway.spec.ts +└── game-event-publisher.service.spec.ts +``` + +Zusätzlich Änderungen in: + +```text +apps/api/src/combat/ +``` + +--- + +# 52. Vorgeschlagene Dateien Frontend + +```text +apps/web/src/app/core/events/ +├── game-events.service.ts +├── game-events.service.spec.ts +└── game-events.models.ts +``` + +Änderungen: + +```text +CombatStore +App initialization +Authentication lifecycle +``` + +--- + +# 53. Implementierungsreihenfolge + +## Task 1 – Shared Event Contracts + +Implementieren: + +```text +GameEvent +GameEventType +Combat Event Payloads +``` + +Tests: + +- TypeScript compile +- Contract consistency + +--- + +## Task 2 – WebSocket Gateway + +Implementieren: + +```text +/events +Authentication +Connect +Disconnect +``` + +Tests: + +- unauthenticated rejected +- authenticated accepted + +--- + +## Task 3 – Room Infrastructure + +Implementieren: + +```text +user room +character room +combat room +``` + +Tests: + +- nur berechtigte Membership +- kein fremder Combat Room + +--- + +## Task 4 – GameEventPublisher + +Domain-unabhängige Publish API implementieren. + +Tests: + +```text +publishToUser +publishToCharacter +publishToCombat +``` + +--- + +## Task 5 – Combat Integration + +Nach erfolgreichem Combat Commit: + +```text +combat.action.resolved +combat.turn.changed +combat.finished +``` + +veröffentlichen. + +--- + +## Task 6 – Frontend GameEventsService + +Implementieren: + +```text +connect +disconnect +reconnect +event parsing +``` + +--- + +## Task 7 – CombatStore Integration + +CombatEvents empfangen und in bestehende Combat-Darstellung integrieren. + +Bestehende REST-Funktionalität muss weiterhin funktionieren. + +--- + +## Task 8 – Reconnect & Resync + +Bei Reconnect: + +```text +Character neu laden +aktiven Combat prüfen +Combat State neu laden +Rooms erneut herstellen +``` + +--- + +# 54. Testing Backend + +Mindestens folgende Tests: + +### Gateway + +- nicht authentifizierte Verbindung wird abgelehnt +- authentifizierte Verbindung wird akzeptiert +- User Room wird automatisch verbunden +- Character Room wird korrekt verbunden +- fremder Combat Room wird abgelehnt + +### Publisher + +- Combat Event geht nur an Combat Room +- Character Event geht nur an Character Room + +### Combat + +- Action wird persistiert +- erst danach Event veröffentlicht +- Kampfende veröffentlicht `combat.finished` +- Event-Sequenz entspricht persistierter Reihenfolge + +--- + +# 55. Testing Frontend + +Mindestens: + +- Verbindung wird nach Login aufgebaut +- Verbindung wird bei Logout geschlossen +- Reconnect wird versucht +- unbekannte Event-Typen crashen den Client nicht +- Combat Event erreicht CombatStore +- doppelte Sequence wird ignoriert +- Sequence Gap löst Resync aus +- Disconnect zerstört lokalen Spielzustand nicht + +--- + +# 56. End-to-End-Test + +Minimaler E2E-Flow: + +```text +Client verbindet /events + +→ Hunt starten +→ Combat starten +→ Combat Room betreten +→ ATTACK per REST senden + +Server: +→ Action validieren +→ Combat berechnen +→ Combat speichern +→ Transaction committen +→ combat.action.resolved senden + +Client: +→ Event empfangen +→ Combat UI aktualisieren +→ Animation ausführen +``` + +Bei letztem Treffer: + +```text +→ Combat WON +→ combat.finished +→ Combat Room verlassen +``` + +--- + +# 57. Migration Requirement + +Slice 0.13 sollte möglichst **keine neue Datenbankmigration nur für WebSockets** benötigen. + +Persistierte CombatEvents bleiben bestehen. + +Falls für Event-Reihenfolge bereits ein geeignetes Feld existiert, wird dieses weiterverwendet. + +Nur wenn eine stabile Sequenz heute technisch nicht vorhanden ist, darf eine kleine CombatEvent-Sequenzmigration ergänzt werden. + +--- + +# 58. Performance-Grundsatz + +Keine globalen Broadcasts. + +Nicht: + +```text +send to all connected clients +``` + +Sondern: + +```text +send to combat room +send to character room +send to user room +``` + +Damit wächst das System später mit der Anzahl aktiver Spieler besser mit. + +--- + +# 59. Observability + +Für Development Logging vorsehen: + +```text +socket connected +socket disconnected +room joined +room left +event published +``` + +Keine vollständigen sensiblen Payloads ungefiltert loggen. + +Besonders hilfreich: + +```text +event type +combatId +room +sequence +``` + +--- + +# 60. Definition of Done + +Slice 0.13 ist abgeschlossen, wenn: + +- ein authentifizierter zentraler `/events` WebSocket existiert +- der Client genau eine zentrale Verbindung verwendet +- Reconnect funktioniert +- User-, Character- und Combat-Room-Konzept existiert +- Clients keine fremden Rooms abonnieren können +- ein gemeinsames `GameEvent`-Format existiert +- Combat Event Contracts in `packages/shared` liegen +- Combat-Aktionen weiterhin per REST ausgelöst werden +- Combat-Änderungen zusätzlich per WebSocket veröffentlicht werden +- Events erst nach erfolgreichem DB Commit gesendet werden +- Combat Events geordnet und deduplizierbar sind +- der Frontend CombatStore Events empfangen kann +- Disconnect und Reconnect keinen Spielzustand beschädigen +- HP-Regeneration unverändert timestamp-basiert funktioniert +- keine globale Server-Tick-Schleife eingeführt wurde +- bestehende Slice-0.12-Funktionalität weiterhin funktioniert +- Backend- und Frontendtests grün sind + +--- + +# 61. Architektur nach Slice 0.13 + +Nach erfolgreicher Implementierung gilt: + +```text + Ashen Realms Client + / \ + / \ + REST Commands WebSocket Events + | ^ + v | + NestJS API EventsGateway + | ^ + v | + Domain Services ---- EventPublisher + | + v + PostgreSQL +``` + +Grundregel: + +> **REST: Ich möchte etwas tun.** + +> **WebSocket: Etwas ist passiert.** + +> **Zeitstempel: Etwas verändert sich vorhersehbar mit der Zeit.** + +--- + +# 62. Vorbereitung für Slice 0.14+ + +Die Architektur soll insbesondere folgende nächsten Schritte ermöglichen: + +### Slice 0.14 +Combat Participants / mehrere Kampfteilnehmer + +### Slice 0.15 +Erster Mehrgegnerkampf + +Beispiel: + +```text +1 Spieler +vs. +3 NPCs +``` + +### Später + +```text +2 Spieler +vs. +3 NPCs +``` + +sowie: + +```text +Pets +NPC-Begleiter +Party Combat +PvP +``` + +Diese Systeme dürfen denselben: + +```text +/events WebSocket +``` + +weiterverwenden. + +Es wird kein separates Realtime-System für jeden neuen Featurebereich aufgebaut. + +--- + +# 63. Leitentscheidung + +Die wichtigste Entscheidung von Slice 0.13 lautet: + +> **Ashen Realms erhält keine klassische permanente Realtime-Simulation, sondern eine serverautoritative, ereignisgetriebene Realtime-Schicht.** + +Damit bleibt das Spiel technisch passend zu seiner Identität: + +- browserbasiert +- rundenbasiert +- persistent +- serverautoritativ +- später multiplayerfähig +- ohne unnötigen permanenten Game-Server-Tick \ No newline at end of file diff --git a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.11_Grenzmarken_First_Merchant.md b/docs/playable-slices/Ashen_Realms_Playable_Slice_0.11_Grenzmarken_First_Merchant.md deleted file mode 100644 index c51ca0c..0000000 --- a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.11_Grenzmarken_First_Merchant.md +++ /dev/null @@ -1,283 +0,0 @@ -# Ashen Realms – Playable Slice 0.11: Grenzmarken & First Merchant - -**Status:** Ready for implementation -**Prerequisite:** Playable Slice 0.10 – First NPC & Quest -**Scope:** First regional currency and targeted merchant progression -**Currency:** Grenzmarken -**Next Slice:** Playable Slice 0.12 – Aschenfelder Complete - -## 1. Goal - -Implement the first anti-frustration progression system. - -The core philosophy is: - -> Drops create excitement. Regional currency prevents frustration. - -The player can earn Grenzmarken through normal progression and use them to buy targeted Tier-1 upgrades. - -## 2. Grenzmarken - -Implement persistent: - -```text -Grenzmarken -``` - -Sources include existing configured rewards such as: - -```text -Quests -rare enemies -stronger enemies -Elite -Boss -``` - -Use the current balancing/content definitions as the source of truth. - -## 3. Currency model - -At this stage a reusable currency model may become worthwhile. - -Conceptually: - -```text -CurrencyDefinition -CharacterCurrency -``` - -Example stable keys: - -```text -silver -border-marks -``` - -If Silver is already safely represented directly on Character, do not force a risky large migration solely for architectural purity. - -The important requirement is that Grenzmarken are persistent and server-authoritative. - -## 4. Merchant - -Introduce the first regional merchant at the appropriate existing NPC/location. - -Use existing project content if a merchant NPC/location has already been defined. - -Minimal merchant data: - -```text -ShopDefinition -ShopOffer -``` - -A ShopOffer should determine: - -```text -item -currency -price -availability -``` - -## 5. Grenzmarken offers - -Use the established prices: - -| Item | Price | -|---|---:| -| Räuberhaube | 5 Grenzmarken | -| Plündererhandschuhe | 6 Grenzmarken | -| Wachmannsbeinkleid | 8 Grenzmarken | -| Verstärkte Lederjacke | 10 Grenzmarken | -| Aschenklinge | 15 Grenzmarken | - -Do not silently rebalance these prices in this implementation slice. - -## 6. Shop API - -Use the existing REST conventions. - -Conceptually: - -```http -GET /api/shops/:shopId -POST /api/shops/:shopId/purchases -``` - -Purchase request: - -```json -{ - "offerId": "uuid" -} -``` - -Do not accept authoritative client values such as: - -```text -price -itemId -currency amount -discount -``` - -The server derives them from the persisted offer. - -## 7. Purchase transaction - -A purchase must be atomic: - -```text -load character currency -↓ -load and validate offer -↓ -validate balance -↓ -subtract Grenzmarken -↓ -create CharacterItem -↓ -commit -``` - -If the transaction fails, neither the currency deduction nor the item grant should remain partially applied. - -## 8. Ownership and duplicate items - -Purchased equipment becomes normal persistent CharacterItem data. - -Duplicates are allowed. - -Do not implement: - -```text -salvaging -buyback -duplicate conversion -pity conversion -``` - -## 9. Merchant UI - -Show: - -- merchant/NPC presentation -- current Grenzmarken balance -- available offers -- item icon -- item name -- item stats -- price -- affordability -- purchase action - -Reuse the item display and comparison language from Inventory where possible. - -## 10. Purchase feedback - -After purchase: - -```text -Grenzmarken balance decreases -↓ -item appears in inventory -↓ -player may open inventory -↓ -player may equip item -``` - -Do not automatically equip purchased equipment. - -## 11. Progression proof - -The important flow is: - -```text -desired drop does not appear -↓ -player keeps playing -↓ -Grenzmarken accumulate -↓ -merchant provides targeted upgrade -``` - -The player should always feel that unlucky drops still produce progress. - -## 12. Server authority - -The server decides: - -- currency balance -- reward grants -- offer availability -- offer price -- purchase validity -- resulting item ownership - -Angular only requests purchase of a valid server-provided `offerId`. - -## 13. Explicit non-goals - -Do not implement: - -```text -selling -buyback -dynamic prices -limited stock -shop refresh timers -player trading -auction house -discount systems -reputation -crafting vendor -``` - -## 14. Required tests - -Verify: - -- Grenzmarken rewards persist -- current balance is server-authoritative -- shop returns persisted offers -- insufficient balance rejects purchase -- valid purchase deducts exact configured cost -- valid purchase grants the correct CharacterItem -- purchase is transactional -- repeated request cannot accidentally duplicate a single transaction through race conditions -- item survives refresh -- purchased item can be equipped using existing equipment flow - -## 15. Definition of Done - -The player can: - -```text -earn Grenzmarken -↓ -see balance -↓ -open merchant -↓ -inspect offers -↓ -buy targeted Tier-1 item -↓ -item enters inventory -↓ -equip item -``` - -Random loot and guaranteed long-term progression are now connected. - -## 16. Handoff - -Next: - -```text -Playable Slice 0.12 – Aschenfelder Complete -``` diff --git a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.12_Aschenfelder_Complete.md b/docs/playable-slices/Ashen_Realms_Playable_Slice_0.12_Aschenfelder_Complete.md deleted file mode 100644 index b3981d2..0000000 --- a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.12_Aschenfelder_Complete.md +++ /dev/null @@ -1,490 +0,0 @@ -# Ashen Realms – Playable Slice 0.12: Aschenfelder Complete - -**Status:** Final integration slice for the first region -**Prerequisite:** Playable Slices 0.1–0.11 -**Scope:** Integration, balancing, content completion and polish for the Aschenfelder -**Primary Goal:** Complete the first genuinely playable regional progression loop. - -## 1. Goal - -Playable Slice 0.12 does not introduce a large new system. - -It integrates, verifies, balances, and polishes everything built so far into one coherent first region. - -The full intended flow is: - -```text -Graufurt -↓ -Südtor -↓ -Verbrannte Straße -↓ -first hunts -↓ -first combat -↓ -first loot -↓ -first upgrades -↓ -Verlassener Wachtposten -↓ -NPC / Quest -↓ -stronger enemies -↓ -first Elite -↓ -Grenzmarken -↓ -targeted upgrades -↓ -Aschengrube -↓ -regional Boss -↓ -guaranteed boss loot -↓ -path toward Dämmerwald discovered -``` - -## 2. Complete locations - -The Aschenfelder progression contains: - -```text -Südtor von Graufurt -Verbrannte Straße -Verlassener Wachtposten -Aschengrube -``` - -Each location must have a clear purpose and correct travel connections. - -## 3. Complete Tier-1 enemy set - -The relevant first-region pool should now include the established enemies: - -```text -Aschenratte -Verwilderter Straßenhund -Straßenräuber -Plünderer-Späher -Plünderer-Veteran -Verkohlter Plünderer -Aschenwühler -Verbrannter Jagdhund -Plündererhauptmann / Elite -Hauptmann der Aschenbande / Boss -``` - -If final naming differs in existing project content, preserve the existing finalized names rather than creating duplicates. - -## 4. Complete Tier-1 item set - -The relevant Tier-1 pool should now include: - -```text -Abgenutztes Kurzschwert -Räuberklinge -Aschenklinge -Räuberhaube -Verstärkte Lederjacke -Plündererhandschuhe -Wachmannsbeinkleid -Aschenstiefel -Zeichen der Grenzwacht -Anhänger des verbrannten Hauptmanns -``` - -Not every item must be required for progression. - -Prestige/special drops remain optional. - -## 5. Combat duration targets - -During the balancing pass, verify: - -### Normal enemies - -```text -4–8 rounds -``` - -### Elite - -```text -6–10 rounds -``` - -### Boss - -```text -8–14 rounds -``` - -These are balancing targets rather than hard rules. - -## 6. Power progression targets - -Use the established Combat Power progression as a reference: - -### Start - -```text -approximately 47 CP -``` - -### After first upgrades - -```text -approximately 60–70 CP -``` - -### Wachtposten progression - -```text -approximately 80–90 CP -``` - -### Aschengrube / boss-ready range - -```text -approximately 100–110 CP -``` - -Combat Power is an internal balancing tool, not a hard access gate. - -## 7. Progression pacing - -A typical player should need roughly: - -```text -12–18 normal combats -+ -quests -+ -boss -``` - -to progress meaningfully through the first region. - -The intended experience must not require 50+ normal combats for basic progression. - -## 8. First upgrade pacing - -Verify that within approximately the first: - -```text -3–5 combats -``` - -the player is very likely to see a meaningful equipment upgrade. - -If this does not happen reliably enough, tune only the smallest necessary variables: - -```text -drop rate -quest reward -starter equipment -``` - -Do not add unnecessary new reward systems. - -## 9. Boss readiness - -The regional boss should become reasonably beatable at approximately: - -```text -70–80% of maximum realistically obtainable regional power -``` - -Best-in-slot equipment should provide comfort and completionist value, not be mandatory. - -## 10. Soft-gate validation - -The player should not be blocked primarily by arbitrary level errors. - -Where possible: - -```text -actual world danger -``` - -should communicate that the player is too weak. - -A player may attempt difficult content early, but the game should make the risk clear. - -## 11. Full UI consistency pass - -Review all implemented screens together: - -```text -World -Hunt -Combat -Loot -Inventory -Quests -Merchant -``` - -Verify consistency of: - -```text -Topbar -SideNavigation -Footer -Context Panel -Panel frames -Buttons -DangerBadges -Item icons -Combat actions -Typography -Spacing -Design tokens -``` - -No screen should feel like a separate web application. - -## 12. Loading and error-state pass - -All core player actions need intentional UI states: - -```text -loading -success -domain error -network error -retry -``` - -Review at minimum: - -```text -Travel -Hunt -Combat Action -Reward -Equip -Quest -Purchase -``` - -Do not use browser alerts. - -## 13. Refresh and recovery pass - -The following states must survive browser refresh through authoritative backend state: - -```text -active travel -current hunt -active combat -finished combat/reward -inventory -equipment -active quest -Grenzmarken -boss completion -``` - -No critical gameplay state may exist only in Angular memory. - -## 14. Full integration test - -Where practical, automate the first complete progression flow: - -```text -character starts at Südtor -↓ -travel to Verbrannte Straße -↓ -hunt -↓ -combat -↓ -win -↓ -receive loot -↓ -equip upgrade -↓ -travel to Wachtposten -↓ -accept quest -↓ -defeat relevant enemies -↓ -progress quest -↓ -earn Grenzmarken -↓ -buy targeted item -↓ -equip item -↓ -travel to Aschengrube -↓ -defeat boss -↓ -receive guaranteed boss reward -↓ -region completion persists -``` - -## 15. Manual playtest – average RNG - -Perform at least one normal playthrough and measure: - -```text -combat duration -loot frequency -upgrade frequency -number of hunts -travel flow -quest pacing -Grenzmarken income -boss readiness -total region time -``` - -## 16. Manual playtest – bad RNG - -Test a deliberately unlucky run. - -Verify: - -```text -Grenzmarken still provide progress -progression does not hard-block -boss readiness remains achievable -player does not require extreme grind -``` - -## 17. Manual playtest – good RNG - -Test a deliberately lucky run. - -Verify: - -```text -region does not become instantly trivial -progression cannot be skipped too aggressively -boss remains meaningful -``` - -## 18. Data/content validation - -Verify: - -- all locations use persisted content -- all encounter pools use persisted relationships -- all loot is data-driven -- all shop offers are data-driven -- quest definitions are persisted/data-driven -- no core gameplay path depends on hardcoded Angular content -- no server domain service contains unnecessary location-specific special cases - -## 19. Server-authority validation - -Confirm that the backend remains authoritative for: - -```text -travel -hunt availability -encounter generation -danger rating -combat -loot -XP -Silver -Grenzmarken -inventory ownership -equipment -effective stats -quest progress -shop purchases -boss completion -``` - -## 20. Performance / technical cleanup - -Review the accumulated implementation for: - -- duplicate API calls -- duplicated frontend state -- duplicated domain validation -- unused temporary placeholder logic -- obsolete Slice 0.1–0.5 shortcuts -- hardcoded demo combat stats that should now use CharacterStatsService -- temporary combat placeholder routes -- duplicated item definitions -- migration quality -- seed idempotency - -Do not perform unrelated architectural rewrites. - -## 21. Definition of Done - -Playable Slice 0.12 / Aschenfelder Complete is complete when a fresh character can, without developer intervention: - -```text -start -↓ -travel -↓ -hunt -↓ -fight -↓ -receive loot -↓ -improve equipment -↓ -meet NPC -↓ -complete quest -↓ -fight Elite -↓ -earn Grenzmarken -↓ -buy targeted item -↓ -reach Aschengrube -↓ -defeat regional Boss -↓ -receive guaranteed progression reward -↓ -discover path toward Dämmerwald -``` - -## 22. Final product test - -The most important qualitative result is: - -> After completing the Aschenfelder, the player should want to see what waits in the Dämmerwald. - -If the loop is technically correct but does not create that motivation, the region still needs iteration. - -## 23. After Slice 0.12 - -Do not immediately expand into the full Dämmerwald. - -First: - -```text -playtest -measure -balance -fix friction -validate reward pacing -validate combat decisions -validate boss readiness -``` - -Only after the first region loop works well should the same systems be expanded into the Dämmerwald and later the Vergessene Ruinen. diff --git a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.7_Complete_Verbrannte_Strasse.md b/docs/playable-slices/Ashen_Realms_Playable_Slice_0.7_Complete_Verbrannte_Strasse.md deleted file mode 100644 index 2d00f9f..0000000 --- a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.7_Complete_Verbrannte_Strasse.md +++ /dev/null @@ -1,235 +0,0 @@ -# Ashen Realms – Playable Slice 0.7: Complete Verbrannte Straße - -**Status:** Ready for implementation -**Prerequisite:** Playable Slice 0.6 – Full First Combat -**Scope:** First fully playable hunting location -**Primary Location:** Verbrannte Straße -**Next Slice:** Playable Slice 0.8 – Wachtposten & First Elite - -## 1. Goal - -Turn the technical hunting location into the first complete gameplay location. - -The Verbrannte Straße must support repeated hunting, multiple enemy archetypes, differentiated mechanics, meaningful loot, and the first visible progression through equipment. - -## 2. Complete encounter pool - -The hunting pool becomes: - -```text -Aschenratte -Verwilderter Straßenhund -Straßenräuber -Verkohlter Plünderer – rare -``` - -## 3. Aschenratte - -Role: - -- Level 1. -- Basic enemy. -- No special mechanic required. - -Rewards: - -```text -4–7 Silver -8 XP -60% Aschenfell / trade material -8% simple starter-slot equipment -``` - -## 4. Verwilderter Straßenhund - -Role: - -- Level 1–2. -- Introduces the first simple status effect. - -Mechanic: - -```text -BLEED -``` - -Keep Bleed deliberately simple: - -- fixed duration -- fixed damage -- no complex stacking -- server-authoritative -- represented through structured combat state/events - -Rewards: - -```text -6–10 Silver -12 XP -70% Zähes Fell -8% Aschenstiefel -5% Kleiner Heiltrank -``` - -Do not implement the entire long-term consumable economy merely because a potion can drop. - -## 5. Straßenräuber - -Role: - -- Level 2. -- Reinforces Telegraphing. - -Mechanic: - -```text -prepared Heavy Attack -``` - -Rewards: - -```text -9–15 Silver -16 XP -18% Räuberklinge -12% Räuberhaube -8% Plündererhandschuhe -10% Kleiner Heiltrank -``` - -## 6. Verkohlter Plünderer - -Role: - -- Level 3. -- Rare encounter. -- Noticeably stronger. -- Early long-term target. - -Guaranteed: - -```text -25–35 Silver -35 XP -1 Grenzmarke -``` - -Additional equipment drops use the existing balancing/content definitions. - -The encounter should communicate: - -> This enemy may be too strong right now, but the player can return later. - -## 7. Encounter pool weights - -All encounter probability belongs in persisted `LocationMonster` content. - -Do not hardcode location-specific monster probabilities inside the HuntingService. - -Conceptually: - -```text -Aschenratte common -Straßenhund common -Straßenräuber normal -Verkohlter Plünderer rare -``` - -Exact weights may be tuned later. - -## 8. Grenzmarken - -Slice 0.7 may begin persisting Grenzmarken as reward data because the rare enemy already grants one. - -They do not need to be spendable yet. - -Spending Grenzmarken belongs to Slice 0.11. - -## 9. Progression target - -Within approximately the first 3–5 combats, the player should have a high chance of seeing the first meaningful equipment upgrade. - -The purpose is to prove: - -```text -hunt -→ fight -→ loot -→ equip -→ become stronger -``` - -through repeated play at one location. - -## 10. Hunt UI - -The Hunt context panel should now communicate the broader enemy pool. - -Example: - -```text -Mögliche Begegnungen - -Aschenratte -Verwilderter Straßenhund -Straßenräuber -??? -``` - -A rare enemy may remain hidden until first encountered. - -Do not expose exact encounter percentages. - -## 11. Explicit non-goals - -Do not implement yet: - -```text -Quest -NPC -Wachtposten progression -Boss -Merchant -Grenzmarken shop -Area completion -``` - -## 12. Required tests - -Verify: - -- all four monsters are persisted content -- all four are assigned to `burned-road` -- weighted selection remains deterministic in tests -- rare encounter selection is supported -- Bleed resolves correctly -- Straßenräuber Telegraphing still works -- loot tables match configured content -- Grenzmarken can be persisted when granted -- item upgrades remain persistent after reward flow - -## 13. Definition of Done - -The player can remain on the Verbrannte Straße and experience: - -```text -different encounters -↓ -different mechanics -↓ -different rewards -↓ -first upgrades -↓ -visibly increasing strength -``` - -The location now feels like a real piece of the game rather than a technical test area. - -## 14. Handoff - -Next: - -```text -Playable Slice 0.8 – Wachtposten & First Elite -``` diff --git a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.8_Wachtposten_First_Elite.md b/docs/playable-slices/Ashen_Realms_Playable_Slice_0.8_Wachtposten_First_Elite.md deleted file mode 100644 index ef3398f..0000000 --- a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.8_Wachtposten_First_Elite.md +++ /dev/null @@ -1,238 +0,0 @@ -# Ashen Realms – Playable Slice 0.8: Wachtposten & First Elite - -**Status:** Ready for implementation -**Prerequisite:** Playable Slice 0.7 – Complete Verbrannte Straße -**Scope:** First progression inside a region and first elite challenge -**Primary Location:** Verlassener Wachtposten -**Next Slice:** Playable Slice 0.9 – Aschengrube & First Boss - -## 1. Goal - -Introduce the first meaningful progression from one dangerous location to another. - -The player should reach a new location, encounter stronger enemies, discover an elite target, improve equipment, and return strong enough to defeat it. - -## 2. New location - -Add: - -```text -Verlassener Wachtposten -``` - -Connection: - -```text -Verbrannte Straße -↔ -Verlassener Wachtposten -``` - -Travel duration: - -```text -approximately 15 seconds -``` - -Travel remains server-authoritative. - -## 3. Location functions - -The Wachtposten should support: - -```text -Jagd beginnen -Wachtposten untersuchen -NPC visible -travel back to Verbrannte Straße -future travel toward Aschengrube -``` - -The NPC may already be visually present, but full NPC/quest interaction is deferred to Slice 0.10. - -## 4. Encounter pool - -Use the existing world/content design as the source of truth. - -Core enemies: - -```text -Straßenräuber -Plünderer-Späher -Plünderer-Veteran -Plündererhauptmann -``` - -## 5. Plünderer-Späher - -Role: - -- lighter humanoid enemy -- familiar combat foundation -- part of Wachtposten progression - -Use persisted content and data-driven mechanics. - -## 6. Plünderer-Veteran - -Role: - -- first clearly tougher regular humanoid -- combines existing mechanics - -Mechanics: - -```text -Heavy Attack -+ -defensive stance -``` - -The defensive stance may temporarily increase armor or otherwise use the established deterministic defensive mechanic. - -Avoid adding a generic scripting engine. - -## 7. Plündererhauptmann - -Role: - -```text -ELITE -``` - -The elite combines 2–3 already learned mechanics. - -The elite should be: - -- difficult or impractical for an unupgraded starting character -- manageable after meaningful Tier-1 upgrades -- farmable for a recognizable desirable reward - -## 8. Elite combat duration - -Target: - -```text -6–10 rounds -``` - -This is a balancing goal, not a hard mechanical rule. - -## 9. Elite loot - -The elite must have a clear reason to be farmed. - -Use the existing balancing/content data for its target rewards. - -Do not invent a separate random loot pool if the project already defines the relevant Tier-1 upgrade source. - -A desirable weapon such as the Aschenklinge may serve as a primary target where consistent with existing content. - -## 10. Danger Rating - -By this slice, danger ratings should use real effective player stats. - -The backend should derive them from: - -```text -CharacterStatsService -+ -enemy / encounter power -``` - -Supported ratings: - -```text -WEAK -MATCH -STRONG -VERY_DANGEROUS -DEADLY -``` - -Use the balancing thresholds as the initial calibration. - -Do not calculate this in Angular. - -## 11. Encounter UI - -Elite encounters must remain part of the reusable EncounterCard system. - -They may receive: - -```text -ELITE label -stronger border treatment -higher visual emphasis -``` - -Do not create a completely separate elite hunting UI. - -## 12. World UI - -Add the Wachtposten as a real travel node. - -The world screen should communicate: - -- current location -- Verbrannte Straße connection -- Wachtposten connection -- current danger/recommended range -- future Aschengrube direction as appropriate - -## 13. Explicit non-goals - -Do not implement yet: - -```text -full quest flow -merchant -boss -area completion -Dämmerwald -``` - -## 14. Required tests - -Verify: - -- location and connection persistence -- travel works in both intended directions -- encounter pool is location-driven -- Plünderer-Veteran mechanics work -- elite encounter classification is persisted -- Danger Rating uses authoritative effective character stats -- elite loot is server-generated -- an undergeared character receives a stronger danger rating than an upgraded one where expected - -## 15. Definition of Done - -The player can experience: - -```text -reach Wachtposten -↓ -see stronger enemies -↓ -discover Elite -↓ -struggle or fail -↓ -farm upgrades -↓ -return -↓ -defeat Elite -``` - -This is the first direct playable proof of: - -> Become stronger and return. - -## 16. Handoff - -Next: - -```text -Playable Slice 0.9 – Aschengrube & First Boss -``` diff --git a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.9_Aschengrube_First_Boss.md b/docs/playable-slices/Ashen_Realms_Playable_Slice_0.9_Aschengrube_First_Boss.md deleted file mode 100644 index 807b136..0000000 --- a/docs/playable-slices/Ashen_Realms_Playable_Slice_0.9_Aschengrube_First_Boss.md +++ /dev/null @@ -1,245 +0,0 @@ -# Ashen Realms – Playable Slice 0.9: Aschengrube & First Boss - -**Status:** Ready for implementation -**Prerequisite:** Playable Slice 0.8 – Wachtposten & First Elite -**Scope:** First region climax and first boss -**Primary Location:** Aschengrube -**Boss:** Hauptmann der Aschenbande -**Next Slice:** Playable Slice 0.10 – First NPC & Quest - -## 1. Goal - -Implement the first true regional progression check. - -The player reaches the hardest Aschenfelder location, fights stronger enemies, challenges the first boss, receives guaranteed meaningful progress, and discovers the future path toward the Dämmerwald. - -## 2. New location - -Add: - -```text -Aschengrube -``` - -Connection: - -```text -Verlassener Wachtposten -↔ -Aschengrube -``` - -Use the existing world/content design for travel duration, danger, artwork, and location metadata. - -## 3. Regular encounter pool - -Core encounters: - -```text -Aschenwühler -Plünderer-Veteran -Verbrannter Jagdhund -``` - -Use the existing balancing and content definitions. - -The location should feel more dangerous than the previous two hunting areas. - -## 4. Boss access - -The boss should not be rolled as a normal random hunt encounter. - -The Aschengrube exposes a dedicated location action: - -```text -Hauptmann herausfordern -``` - -This establishes the reusable concept of a boss/location action without creating a boss-specific one-off controller. - -## 5. Boss - -Implement: - -```text -Hauptmann der Aschenbande -Level 3 -BOSS -``` - -Mechanics: - -```text -normal attack - -telegraphed Heavy Attack - -defensive phase / armor increase - -below 30% HP: -more aggressive behavior -``` - -Use reusable combat mechanics rather than a bespoke boss script framework. - -## 6. Boss duration - -Target: - -```text -8–14 rounds -``` - -This is a balancing target. - -The boss should not be realistically comfortable for a fresh character, but should become manageable through the Tier-1 progression available in the Aschenfelder. - -## 7. Boss reward rule - -Introduce the first boss guarantee system. - -### Roll A – guaranteed progression - -Always grant: - -```text -1 high-quality Tier-1 item -``` - -Guaranteed pool: - -```text -Verstärkte Lederjacke -Wachmannsbeinkleid -Aschenstiefel -Zeichen der Grenzwacht -``` - -### Roll B – additional special drops - -```text -20% Aschenklinge -8% Anhänger des verbrannten Hauptmanns -``` - -Guaranteed additional rewards: - -```text -50–70 Silver -80 XP -5 Grenzmarken -``` - -The guaranteed progression roll and special rolls are separate. - -## 8. Loot architecture extension - -Extend the existing loot system to support: - -```text -guaranteed item pool -+ -independent optional rolls -``` - -Do not implement the boss reward directly inside the controller. - -The behavior must remain data-driven and reusable. - -## 9. Boss completion persistence - -Persist first boss completion / regional progression state. - -The exact data structure should follow the existing project conventions. - -At minimum, the server must know that the character has defeated the regional boss. - -Do not rely on Angular state. - -## 10. Future path discovery - -After the first boss victory, expose the future progression hint: - -```text -Ein Pfad in Richtung Dämmerwald wurde entdeckt. -``` - -The Dämmerwald itself does not need to be playable yet. - -The world state may mark a future node/connection as discovered or unlocked according to the existing architecture. - -## 11. Boss UI - -The boss encounter should visually communicate that it is a major challenge. - -Use: - -- existing combat screen -- boss label -- stronger framing -- clear Telegraphing -- phase/status information -- existing combat log/event system - -Do not build a separate game mode. - -## 12. Explicit non-goals - -Do not implement yet: - -```text -Dämmerwald content -complex boss scripting DSL -multiple boss instances -raid mechanics -group combat -boss matchmaking -``` - -## 13. Required tests - -Verify: - -- boss can only be started through a valid server-side boss/location action -- boss combat uses persisted authoritative stats -- defensive phase changes combat behavior -- low-HP aggression triggers correctly -- boss victory persists -- boss reward is granted exactly once -- guaranteed Tier-1 item always appears -- special drop rolls remain independent -- XP, Silver and Grenzmarken match content rules -- page refresh does not duplicate boss reward -- future path discovery persists - -## 14. Definition of Done - -The player can: - -```text -reach Aschengrube -↓ -fight regular encounters -↓ -challenge boss -↓ -read and react to mechanics -↓ -win -↓ -receive guaranteed progression item -↓ -receive boss rewards -↓ -discover route toward Dämmerwald -``` - -A boss victory must never end without meaningful progress. - -## 15. Handoff - -Next: - -```text -Playable Slice 0.10 – First NPC & Quest -``` diff --git a/docs/playable-slices/README.md b/docs/playable-slices/README.md new file mode 100644 index 0000000..9298c7b --- /dev/null +++ b/docs/playable-slices/README.md @@ -0,0 +1,39 @@ +# Ashen Realms – Slice Pack 0.6.6 to 0.12 + +This pack replaces the previously planned post-0.6 progression order with a sequence aligned to the new Reputation / World Renown progression model. + +## Recommended implementation order + +1. `0.6.6-English-Game-Content-Foundation.md` +2. `0.7-Complete-Burned-Road-V2.md` +3. `0.7.5-Monster-Categories-and-Loot-Bags.md` +4. `0.8-Graufurt-Merchant-and-Trade-In.md` +5. `0.8.5-Reputation-Gated-Merchant-Offers.md` +6. `0.9-First-Quest-and-Bag-Tutorial.md` +7. `0.10-Abandoned-Watchpost.md` +8. `0.11-Ash-Pit-and-Ashen-Band-Captain.md` +9. `0.12-Ashen-Fields-Complete-and-Polish.md` + +## Core progression rule for this pack + +Normal monsters do not directly grant XP, Silver, regional reputation or World Renown. + +Instead: + +```text +Monster +→ trade goods / equipment +→ loot-bag capacity +→ merchant +→ Silver + regional reputation + World Renown +→ better bags / equipment / access +→ stronger content +``` + +Major one-time milestones such as a first area-boss victory may grant direct World Renown when explicitly configured. + +## Language rule + +From Slice 0.6.6 onward: + +> All player-facing game content is English. Development documentation may remain German. diff --git a/docs/superpowers/plans/2026-08-21-persistent-hp-and-regeneration.md b/docs/superpowers/plans/2026-08-21-persistent-hp-and-regeneration.md new file mode 100644 index 0000000..9bd6f61 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-persistent-hp-and-regeneration.md @@ -0,0 +1,1958 @@ +# Persistent Character HP & Out-of-Combat Regeneration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Carry a character's remaining HP from one combat into the next, regenerate 1 HP/second while out of combat, and never regenerate while a combat is running. + +**Architecture:** `characters.current_hp` becomes "HP as of `hp_regen_since`" instead of "current HP". A new `CharacterVitalsService` is the only code allowed to turn that pair into an effective HP value (pure function, clamped to max HP) or to move it (`pause`/`resume`/`settle`). `CharacterStatsService` — already the single authoritative source of effective stats — calls it internally, so every existing reader of `currentHp` becomes correct automatically. `CombatService` calls the mutators directly at combat start, every round, and at combat end. `EquipmentService` calls `settle` before an equipment change can move max HP. The web client counts up locally from the server-supplied anchor purely for display; the server never trusts the client's math. + +**Tech Stack:** NestJS + TypeORM (Postgres) on the API, Angular + Signals + Vitest on the web client, Jest on the API. + +**Spec:** `docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md` + +## Global Constraints + +- Regeneration rate is `HP_REGEN_PER_SECOND = 1`, defined once in `apps/api/src/characters/character-vitals.constants.ts`. No other file may hardcode this number. +- No code outside `CharacterVitalsService` reads `Character.currentHp` or `Character.hpRegenSince` to compute an effective HP value. Everything else goes through `CharacterStatsService.calculate()` (server) or the client's mirrored formula in `WorldStore` (display only). +- Starting a combat requires effective HP `>= 1`; there is no separate "defeated" flag or cooldown timer (design R2). +- A combat's `playerMaxHp` is still frozen at combat start, unchanged by this plan (pre-existing behaviour). +- Migration files are timestamp-prefixed in `apps/api/src/database/migrations/`; this plan's migration is `1792000000000-AddHpRegeneration.ts` (skips `1791000000000`, reserved by the not-yet-implemented Renown plan). Migration specs assert TypeORM entity metadata only — this repo's tests never open a live database connection. +- API tests run with `jest`, from `apps/api` (`npm run test --workspace=@ashen-realms/api`, or scope with `-- `). Web tests run with `vitest`, from `apps/web` (`npm run test --workspace=@ashen-realms/web`, or with a path filter). + +--- + +### Task 1: Move the `Clock` abstraction out of the travel module + +**Files:** +- Create: `apps/api/src/shared/clock.ts` +- Delete: `apps/api/src/travel/clock.ts` +- Modify: `apps/api/src/travel/travel.module.ts` +- Modify: `apps/api/src/travel/travel.service.ts` +- Modify: `apps/api/src/travel/travel.service.spec.ts` + +**Interfaces:** +- Produces: `CLOCK` (injection token, `Symbol`), `Clock` interface (`{ now(): Date }`), `systemClock: Clock` — all now importable from `../shared/clock` (or `./clock` from within `shared/`). Every later task that needs a clock imports from here. + +This is a pure move: the character vitals service (Task 4) needs a clock too, and two feature modules must not borrow one from a third's folder. + +- [ ] **Step 1: Create `apps/api/src/shared/clock.ts` with the moved content** + +```ts +export const CLOCK = Symbol('CLOCK'); + +export interface Clock { + now(): Date; +} + +export const systemClock: Clock = { + now: () => new Date(), +}; +``` + +- [ ] **Step 2: Delete `apps/api/src/travel/clock.ts`** + +- [ ] **Step 3: Update the import in `apps/api/src/travel/travel.module.ts`** + +Change: +```ts +import { CLOCK, systemClock } from './clock'; +``` +to: +```ts +import { CLOCK, systemClock } from '../shared/clock'; +``` + +- [ ] **Step 4: Update the imports in `apps/api/src/travel/travel.service.ts`** + +Change: +```ts +import { CLOCK } from './clock'; +import type { Clock } from './clock'; +``` +to: +```ts +import { CLOCK } from '../shared/clock'; +import type { Clock } from '../shared/clock'; +``` + +- [ ] **Step 5: Update the import in `apps/api/src/travel/travel.service.spec.ts`** + +Change: +```ts +import { Clock } from './clock'; +``` +to: +```ts +import { Clock } from '../shared/clock'; +``` + +- [ ] **Step 6: Run the travel suite to confirm nothing broke** + +Run: `npm run test --workspace=@ashen-realms/api -- travel` +Expected: PASS (same test count as before the move) + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/shared/clock.ts apps/api/src/travel/clock.ts apps/api/src/travel/travel.module.ts apps/api/src/travel/travel.service.ts apps/api/src/travel/travel.service.spec.ts +git commit -m "refactor(api): move Clock abstraction from travel to shared" +``` + +--- + +### Task 2: Add `hp_regen_since` to the `characters` table + +**Files:** +- Modify: `apps/api/src/characters/entities/character.entity.ts` +- Create: `apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts` +- Create: `apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts` + +**Interfaces:** +- Produces: `Character.hpRegenSince: Date | null`. Task 4 (`CharacterVitalsService`) reads and writes this field on `Character` instances it is given. + +- [ ] **Step 1: Write the failing migration spec** + +```ts +// apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts +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); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm run test --workspace=@ashen-realms/api -- add-hp-regeneration.migration` +Expected: FAIL — `column` is `undefined` because the entity has no such property yet. + +- [ ] **Step 3: Add the column to the entity** + +In `apps/api/src/characters/entities/character.entity.ts`, add after the `currentHp` column: + +```ts + // `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; +``` + +- [ ] **Step 4: Run the migration spec again to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- add-hp-regeneration.migration` +Expected: PASS + +- [ ] **Step 5: Write the migration** + +```ts +// apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddHpRegeneration1792000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"'); + } +} +``` + +- [ ] **Step 6: Run the full API test suite to confirm nothing else references the old shape** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: Some failures are expected here (`character-stats.service.spec.ts`, `characters.service.spec.ts`, `equipment.service.spec.ts`, `combat.service.spec.ts`, `combat-equipment-integration.spec.ts` all construct `Character` fixtures directly and will be fixed in later tasks). Confirm the only new failures are compile/runtime errors mentioning these known fixtures, not something unrelated. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/characters/entities/character.entity.ts apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts +git commit -m "feat(api): add hp_regen_since column for persistent HP regeneration" +``` + +--- + +### Task 3: Add the regeneration-rate constant + +**Files:** +- Create: `apps/api/src/characters/character-vitals.constants.ts` + +**Interfaces:** +- Produces: `HP_REGEN_PER_SECOND: number`. Consumed by `CharacterVitalsService` (Task 4) and `CharacterStatsService` (Task 6). + +- [ ] **Step 1: Create the constants file** + +```ts +// apps/api/src/characters/character-vitals.constants.ts +export const HP_REGEN_PER_SECOND = 1; +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/api/src/characters/character-vitals.constants.ts +git commit -m "feat(api): add HP regeneration rate constant" +``` + +--- + +### Task 4: `CharacterVitalsService` (TDD) + +**Files:** +- Create: `apps/api/src/characters/character-vitals.service.ts` +- Create: `apps/api/src/characters/character-vitals.service.spec.ts` + +**Interfaces:** +- Consumes: `Clock`, `CLOCK` from `../shared/clock` (Task 1); `HP_REGEN_PER_SECOND` from `./character-vitals.constants` (Task 3); `Character` from `./entities/character.entity` (Task 2). +- Produces: `class CharacterVitalsService` with: + - `effectiveHp(character: Pick, maxHp: number): number` + - `pause(character: Character, value: number): void` + - `resume(character: Character, value: number): void` + - `settle(character: Character, maxHp: number): void` + + Consumed by `CharacterStatsService` (Task 6), `CombatService` (Tasks 9-10), `EquipmentService` (Task 12). + +- [ ] **Step 1: Write the failing test file** + +```ts +// apps/api/src/characters/character-vitals.service.spec.ts +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 { + 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); + }); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npm run test --workspace=@ashen-realms/api -- character-vitals.service` +Expected: FAIL — `Cannot find module './character-vitals.service'` + +- [ ] **Step 3: Write the implementation** + +```ts +// apps/api/src/characters/character-vitals.service.ts +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, + 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)); + } +} +``` + +- [ ] **Step 4: Run the test again to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- character-vitals.service` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/characters/character-vitals.service.ts apps/api/src/characters/character-vitals.service.spec.ts +git commit -m "feat(api): add CharacterVitalsService for anchored HP regeneration" +``` + +--- + +### Task 5: Wire `CharacterVitalsService` into `CharactersModule` + +**Files:** +- Modify: `apps/api/src/characters/characters.module.ts` + +**Interfaces:** +- Produces: `CharacterVitalsService` and `CharacterStatsService` both exported from `CharactersModule`. `CombatModule` and `EquipmentModule` already import `CharactersModule`, so they need no import changes to receive `CharacterVitalsService` via constructor injection in later tasks. + +- [ ] **Step 1: Update the module** + +```ts +// apps/api/src/characters/characters.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CLOCK, systemClock } from '../shared/clock'; +import { CharacterStatsService } from './character-stats.service'; +import { CharacterVitalsService } from './character-vitals.service'; +import { CharactersController } from './characters.controller'; +import { CharactersService } from './characters.service'; +import { Character } from './entities/character.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Character])], + controllers: [CharactersController], + providers: [ + CharactersService, + CharacterStatsService, + CharacterVitalsService, + { provide: CLOCK, useValue: systemClock }, + ], + exports: [CharacterStatsService, CharacterVitalsService], +}) +export class CharactersModule {} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/api/src/characters/characters.module.ts +git commit -m "feat(api): provide CharacterVitalsService from CharactersModule" +``` + +(This task has no independent test — `npm run build:api` or the Nest app bootstrapping in the E2E harness would catch a wiring mistake; later tasks' unit tests exercise the service directly.) + +--- + +### Task 6: `CharacterStatsService` returns effective HP + +**Files:** +- Modify: `apps/api/src/characters/character-stats.service.ts` +- Modify: `apps/api/src/characters/character-stats.service.spec.ts` + +**Interfaces:** +- Consumes: `CharacterVitalsService.effectiveHp` (Task 4), `HP_REGEN_PER_SECOND` (Task 3). +- Produces: `EffectiveCharacterStats` gains `hpRegenPerSecond: number` and `hpRegenSince: Date | null`; `currentHp` is now the effective value, not the raw column. `CharacterStatsService`'s constructor becomes `(dataSource: DataSource, characterVitals: CharacterVitalsService)` — every direct instantiation elsewhere must add the second argument (Tasks 7, 9, 12, 13). + +- [ ] **Step 1: Update the fixture and the existing pass-through test to be explicit about the paused case** + +In `apps/api/src/characters/character-stats.service.spec.ts`, update the `character()` fixture to set `hpRegenSince` explicitly: + +```ts +function character(overrides: Partial = {}): Character { + return { + id: 'character-1', + baseHp: 100, + baseAttack: 6, + currentHp: 90, + hpRegenSince: null, + ...overrides, + } as Character; +} +``` + +Replace the `describe('CharacterStatsService', ...)` block's `const service = ...` line and the last test with: + +```ts +describe('CharacterStatsService', () => { + const characterVitals = new CharacterVitalsService({ + now: () => new Date('2026-08-21T12:00:00.000Z'), + }); + const service = new CharacterStatsService({} as DataSource, characterVitals); +``` + +and change the final test from: + +```ts + it('passes currentHp through unchanged from the character', async () => { + const scope = fakeScope([]); + + const stats = await service.calculate(character({ currentHp: 42 }), scope); + + expect(stats.currentHp).toBe(42); + }); +}); +``` + +to: + +```ts + 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); + }); +}); +``` + +Add the import at the top of the file: + +```ts +import { CharacterVitalsService } from './character-vitals.service'; +``` + +- [ ] **Step 2: Run the suite to verify the new/changed tests fail** + +Run: `npm run test --workspace=@ashen-realms/api -- character-stats.service` +Expected: FAIL — `CharacterStatsService` constructor doesn't accept a second argument yet, and `hpRegenPerSecond`/`hpRegenSince` are undefined on the result. + +- [ ] **Step 3: Update the implementation** + +```ts +// apps/api/src/characters/character-stats.service.ts +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; + +/** + * 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 { + 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, + }; + } +} +``` + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- character-stats.service` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/characters/character-stats.service.ts apps/api/src/characters/character-stats.service.spec.ts +git commit -m "feat(api): CharacterStatsService reports effective (regenerated) HP" +``` + +--- + +### Task 7: `CharactersService` exposes effective HP and regen fields + +**Files:** +- Modify: `apps/api/src/characters/characters.service.ts` +- Modify: `apps/api/src/characters/characters.service.spec.ts` + +**Interfaces:** +- Consumes: `EffectiveCharacterStats.currentHp/hpRegenPerSecond/hpRegenSince` (Task 6). +- Produces: `getDemoCharacter()`'s return type gains `hpRegenPerSecond: number` and `hpRegenSince: string | null`. This is the shape `GET /api/characters/me` returns to the web client (Task 15 updates the matching frontend type). + +- [ ] **Step 1: Update the test fixtures and expectations** + +In `apps/api/src/characters/characters.service.spec.ts`, update `fakeCharacterStats`: + +```ts +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; +} +``` + +Update the first test's expected result (the `.resolves.toEqual({...})` block) to add the two new fields: + +```ts + await expect(service.getDemoCharacter()).resolves.toEqual({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + currentHp: 100, + maxHp: 115, + attack: 7, + hpRegenPerSecond: 1, + hpRegenSince: '2026-08-18T09:00:00.000Z', + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + }, + }); +``` + +- [ ] **Step 2: Run the suite to verify the updated test fails** + +Run: `npm run test --workspace=@ashen-realms/api -- characters.service` +Expected: FAIL — actual result is missing `hpRegenPerSecond`/`hpRegenSince`. + +- [ ] **Step 3: Update the implementation** + +In `apps/api/src/characters/characters.service.ts`, replace the return block: + +```ts + return { + id: character.id, + name: character.name, + level: character.level, + experience: character.experience, + silver: character.silver, + currentHp: stats.currentHp, + maxHp: stats.maxHp, + attack: stats.attack, + hpRegenPerSecond: stats.hpRegenPerSecond, + hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null, + currentLocation: { + id: character.currentLocation.id, + key: character.currentLocation.key, + name: character.currentLocation.name, + }, + }; +``` + +Note this also fixes a latent bug: the field used to read `character.currentHp` (the raw column) directly instead of `stats.currentHp` (the calculated value) — harmless before this plan since they were always equal, but wrong now that `character.currentHp` can be a stale anchor value. + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- characters.service` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/characters/characters.service.ts apps/api/src/characters/characters.service.spec.ts +git commit -m "feat(api): expose effective HP and regen anchor from GET /characters/me" +``` + +--- + +### Task 8: Add the `CHARACTER_TOO_WOUNDED` combat error + +**Files:** +- Modify: `apps/api/src/combat/combat.errors.ts` + +**Interfaces:** +- Produces: `characterTooWounded(): CombatDomainError` with `code: 'CHARACTER_TOO_WOUNDED'`, `409 Conflict`. Consumed by `CombatService.startCombat` (Task 9). + +- [ ] **Step 1: Add the error code and factory function** + +In `apps/api/src/combat/combat.errors.ts`, add `'CHARACTER_TOO_WOUNDED'` to the `CombatErrorCode` union: + +```ts +export type CombatErrorCode = + | 'HUNT_ENCOUNTER_NOT_FOUND' + | 'HUNT_ENCOUNTER_ALREADY_CONSUMED' + | 'INVALID_HUNT_ENCOUNTER' + | 'CHARACTER_TRAVELLING' + | 'CHARACTER_TOO_WOUNDED' + | 'COMBAT_ALREADY_ACTIVE' + | 'COMBAT_NOT_FOUND' + | 'COMBAT_ALREADY_FINISHED' + | 'COMBAT_STATE_INVALID' + | 'COMBAT_NO_POTIONS_REMAINING'; +``` + +Add the factory function, next to `characterTravelling()`: + +```ts +export function characterTooWounded(): CombatDomainError { + return new CombatDomainError( + 'CHARACTER_TOO_WOUNDED', + HttpStatus.CONFLICT, + 'The character is too wounded to fight.', + ); +} +``` + +- [ ] **Step 2: Compile check (no dedicated test file for this error module; Task 9 exercises it end to end)** + +Run: `npm run build:api` +Expected: no new type errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/combat/combat.errors.ts +git commit -m "feat(api): add CHARACTER_TOO_WOUNDED combat error" +``` + +--- + +### Task 9: `CombatService.startCombat` seeds from carried-over HP and gates on it + +**Files:** +- Modify: `apps/api/src/combat/combat.service.ts` +- Modify: `apps/api/src/combat/combat.service.spec.ts` + +**Interfaces:** +- Consumes: `CharacterVitalsService.pause` (Task 4), `characterTooWounded` (Task 8). +- Produces: `CombatService`'s constructor becomes `(dataSource, travelService, combatEngine, characterStats, characterVitals, combatRewards)` — the new `characterVitals` parameter sits between `characterStats` and `combatRewards`. `combat.playerCurrentHp` at combat start is now the character's effective HP, not `playerMaxHp`. Consumed by Task 10 (same file/class) and Task 13 (integration spec). + +- [ ] **Step 1: Update the fixture, error-path tests, and add new tests** + +In `apps/api/src/combat/combat.service.spec.ts`, add the import: + +```ts +import { CharacterVitalsService } from '../characters/character-vitals.service'; +``` + +Update the `character()` fixture to set `hpRegenSince` explicitly: + +```ts +function character(overrides: Partial = {}): 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; +} +``` + +Update `createService` to build and pass a `CharacterVitalsService`: + +```ts +function createService( + options: { state?: FakeState; travelService?: TravelService } = {}, +) { + const state = options.state ?? createState(); + const dataSource = new FakeDataSource(state); + const travelService = options.travelService ?? fakeTravelService(); + const combatEngine = new CombatEngineService(); + const characterCombatStats = fakeCharacterStats(); + const characterVitals = new CharacterVitalsService({ + now: () => new Date('2026-08-18T09:00:00.000Z'), + }); + const service = new CombatService( + dataSource as unknown as DataSource, + travelService, + combatEngine, + characterCombatStats, + characterVitals, + fakeRewardService(), + ); + return { dataSource, service, travelService }; +} +``` + +Update the two existing tests that rely on a near-dead player (they previously worked because `playerMaxHp` was seeded and `baseHp: 1` made that tiny; now the seed comes from `currentHp`, so it must be set explicitly too): + +```ts + it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => { + const state = createState({ + characters: [character({ baseHp: 1, currentHp: 1 })], + }); +``` + +and + +```ts + it('frees the encounter for another attempt when the fight is lost', async () => { + const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] }); +``` + +Add these new tests inside `describe('startCombat', ...)`, after the existing "starts an ACTIVE combat..." test: + +```ts + 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' }); + }); +``` + +- [ ] **Step 2: Run the suite to verify the new/changed tests fail** + +Run: `npm run test --workspace=@ashen-realms/api -- combat.service` +Expected: FAIL — `CombatService` doesn't accept a `characterVitals` constructor argument yet, and still seeds from `maxHp`. + +- [ ] **Step 3: Update the implementation** + +In `apps/api/src/combat/combat.service.ts`, add imports: + +```ts +import { CharacterVitalsService } from '../characters/character-vitals.service'; +``` + +and add `characterTooWounded` to the existing `combat.errors` import list (alphabetical, matching the existing style): + +```ts +import { + characterNotFound, + characterTooWounded, + characterTravelling, + combatAlreadyActive, + combatAlreadyFinished, + combatNoPotionsRemaining, + combatNotFound, + combatStateInvalid, + huntEncounterAlreadyConsumed, + huntEncounterNotFound, + invalidHuntEncounter, +} from './combat.errors'; +``` + +Update the constructor: + +```ts + constructor( + private readonly dataSource: DataSource, + private readonly travelService: TravelService, + private readonly combatEngine: CombatEngineService, + private readonly characterStats: CharacterStatsService, + private readonly characterVitals: CharacterVitalsService, + private readonly combatRewards: CombatRewardService, + ) {} +``` + +In `startCombat`, replace: + +```ts + const playerStats = await this.characterStats.calculate(character, manager); + + const combat = combats.create({ + characterId, + huntEncounterId: encounter.id, + monsterDefinitionId: monster.id, + status: CombatStatus.ACTIVE, + round: 1, + playerMaxHp: playerStats.maxHp, + playerCurrentHp: playerStats.maxHp, +``` + +with: + +```ts + 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({ + characterId, + huntEncounterId: encounter.id, + monsterDefinitionId: monster.id, + status: CombatStatus.ACTIVE, + round: 1, + playerMaxHp: playerStats.maxHp, + playerCurrentHp: playerStats.currentHp, +``` + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- combat.service` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts +git commit -m "feat(api): carry HP into combat and gate starting a fight on it" +``` + +--- + +### Task 10: `CombatService.performAction` mirrors HP each round and resumes regen at combat end + +**Files:** +- Modify: `apps/api/src/combat/combat.service.ts` +- Modify: `apps/api/src/combat/combat.service.spec.ts` + +**Interfaces:** +- Consumes: `CharacterVitalsService.pause`/`resume` (Task 4). +- Produces: after this task, `Character.currentHp`/`hpRegenSince` are kept in sync with `Combat.playerCurrentHp` on every round, and regeneration restarts (from wherever the fight ended, including 0 on a loss) the moment a combat finishes. + +- [ ] **Step 1: Add the failing tests** + +In `apps/api/src/combat/combat.service.spec.ts`, inside `describe('performAction', ...)`, add after "persists ordered, sequential CombatEvents across multiple rounds": + +```ts + 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'), + ); + }); +``` + +- [ ] **Step 2: Run the suite to verify the new tests fail** + +Run: `npm run test --workspace=@ashen-realms/api -- combat.service` +Expected: FAIL — the character row is never updated by `performAction` yet. + +- [ ] **Step 3: Update the implementation** + +In `apps/api/src/combat/combat.service.ts`, `performAction` currently discards the locked character: + +```ts + await this.lockCharacter(characters, characterId); +``` + +Change it to keep the reference: + +```ts + const character = await this.lockCharacter(characters, characterId); +``` + +Then replace the block that mutates and saves `combat`: + +```ts + combat.round = result.state.round; + combat.status = result.state.status; + combat.playerCurrentHp = result.state.player.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) { + combat.completedAt = new Date(); + await this.settleEncounter( + manager.getRepository(HuntEncounter), + combat.huntEncounterId, + combat.status, + ); + } + await combats.save(combat); +``` + +with: + +```ts + combat.round = result.state.round; + combat.status = result.state.status; + combat.playerCurrentHp = result.state.player.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) { + combat.completedAt = new Date(); + this.characterVitals.resume(character, combat.playerCurrentHp); + await this.settleEncounter( + manager.getRepository(HuntEncounter), + combat.huntEncounterId, + combat.status, + ); + } else { + this.characterVitals.pause(character, combat.playerCurrentHp); + } + await characters.save(character); + await combats.save(combat); +``` + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- combat.service` +Expected: PASS + +- [ ] **Step 5: Run the whole combat directory once more as a final check** + +Run: `npm run test --workspace=@ashen-realms/api -- combat/` +Expected: PASS (this also re-runs `combat-equipment-integration.spec.ts` and `combat-engine.service.spec.ts`; the integration spec is expected to still fail until Task 12/13 update its harness — confirm the only failures left are in that one file) + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts +git commit -m "feat(api): mirror HP onto the character each round and resume regen at combat end" +``` + +--- + +### Task 11: `EquipmentService.equip` re-anchors HP before a max-HP change + +**Files:** +- Modify: `apps/api/src/equipment/equipment.service.ts` +- Modify: `apps/api/src/equipment/equipment.service.spec.ts` + +**Interfaces:** +- Consumes: `CharacterVitalsService.settle` (Task 4). +- Produces: `EquipmentService`'s constructor becomes `(dataSource, characterStats, characterVitals)`. + +- [ ] **Step 1: Update the fixture and harness, add the failing test** + +In `apps/api/src/equipment/equipment.service.spec.ts`, add the import: + +```ts +import { CharacterVitalsService } from '../characters/character-vitals.service'; +``` + +Update the `character()` fixture: + +```ts +function character(overrides: Partial = {}): Character { + return { + id: CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + hpRegenSince: null, + ...overrides, + } as Character; +} +``` + +Update `createHarness`: + +```ts +function createHarness(state: Partial = {}) { + 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 }; +} +``` + +Add a new test inside `describe('equip', ...)`: + +```ts + 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: 100, hpRegenSince: null })], + 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); + }); +``` + +- [ ] **Step 2: Run the suite to verify it fails** + +Run: `npm run test --workspace=@ashen-realms/api -- equipment.service` +Expected: FAIL — `EquipmentService` doesn't accept a third constructor argument yet. + +- [ ] **Step 3: Update the implementation** + +In `apps/api/src/equipment/equipment.service.ts`, add the import: + +```ts +import { CharacterVitalsService } from '../characters/character-vitals.service'; +``` + +Update the constructor: + +```ts + constructor( + private readonly dataSource: DataSource, + private readonly characterStats: CharacterStatsService, + private readonly characterVitals: CharacterVitalsService, + ) {} +``` + +In `equip()`, insert the re-anchor after the level-requirement check and before the equipment-slot write: + +```ts + 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({ +``` + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- equipment.service` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/equipment/equipment.service.ts apps/api/src/equipment/equipment.service.spec.ts +git commit -m "feat(api): re-anchor HP regeneration before an equipment-driven max-HP change" +``` + +--- + +### Task 12: Update the combat/equipment integration spec for the new constructors + +**Files:** +- Modify: `apps/api/src/combat/combat-equipment-integration.spec.ts` + +**Interfaces:** +- Consumes: `CharacterVitalsService` (Task 4), the updated `CombatService`/`EquipmentService`/`CharacterStatsService` constructors (Tasks 6, 9, 11). + +This file constructs real (non-mocked) `CombatService`, `EquipmentService`, and `CharacterStatsService` instances sharing one `dataSource`, so it needs the same wiring update, plus the `hpRegenSince` fixture default. + +- [ ] **Step 1: Update the fixture and harness** + +Add the import: + +```ts +import { CharacterVitalsService } from '../characters/character-vitals.service'; +``` + +Update the `character()` fixture: + +```ts +function character(overrides: Partial = {}): 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; +} +``` + +Find the harness construction block (the one building `characterStats`, `equipmentService`, and `combatService` together) and change it to: + +```ts + 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 }; +``` + +- [ ] **Step 2: Run the suite** + +Run: `npm run test --workspace=@ashen-realms/api -- combat-equipment-integration` +Expected: PASS. (This fight plays out over several rounds to a `WON` result with the player well above 0 HP — verified by hand against `combat-damage.ts`'s formula before writing this task — so the second fight's `startCombat` call is never blocked by the new `CHARACTER_TOO_WOUNDED` gate. If this assumption turns out wrong and the test fails on that gate instead, the fix is to increase the player's `currentHp` or the monster's HP pool in the harness's `character()`/`monster()` fixtures, not to change production code.) + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/combat/combat-equipment-integration.spec.ts +git commit -m "test(api): update combat/equipment integration harness for HP vitals wiring" +``` + +--- + +### Task 13: Seed script anchors the demo character's regeneration + +**Files:** +- Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts` + +**Interfaces:** +- None new — this only affects the dev-seed data path, not any service's public interface. + +- [ ] **Step 1: Update the insert** + +In `apps/api/src/database/seeds/vertical-slice.seed.ts`, find the demo character insert: + +```ts + if (!existing) { + await characterRepository.insert({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + currentLocationId: southGateId, + }); + } +``` + +and add `hpRegenSince`: + +```ts + if (!existing) { + await characterRepository.insert({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + hpRegenSince: new Date(), + currentLocationId: southGateId, + }); + } +``` + +- [ ] **Step 2: Compile check (no dedicated seed test in this repo)** + +Run: `npm run build:api` +Expected: no new type errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/database/seeds/vertical-slice.seed.ts +git commit -m "chore(api): anchor the seeded demo character's HP regeneration" +``` + +--- + +### Task 14: Add the API error code and regen fields to the web `CharacterResponse` model + +**Files:** +- Modify: `apps/web/src/app/core/api/game-api.models.ts` +- Modify: `apps/web/src/app/features/inventory/inventory-page.component.spec.ts` +- Modify: `apps/web/src/app/features/combat/combat.store.ts` +- Modify: `apps/web/src/app/features/combat/combat.store.spec.ts` + +**Interfaces:** +- Produces: `CharacterResponse` gains `hpRegenPerSecond: number` and `hpRegenSince: string | null`, matching `GET /api/characters/me`'s new shape (Task 7). `COMBAT_ERROR_MESSAGES` gains a `CHARACTER_TOO_WOUNDED` entry (Task 8). Consumed by `WorldStore` (Task 15) and `app.spec.ts`/`world.store.spec.ts` fixtures (Task 15, 16). + +- [ ] **Step 1: Update the model** + +In `apps/web/src/app/core/api/game-api.models.ts`: + +```ts +export interface CharacterResponse { + id: string; + name: string; + level: number; + experience: number; + silver: number; + currentHp: number; + maxHp: number; + attack: number; + hpRegenPerSecond: number; + hpRegenSince: string | null; + currentLocation: LocationSummary; +} +``` + +- [ ] **Step 2: Fix the now-incomplete fixture in `inventory-page.component.spec.ts`** + +This fixture is passed directly to the component under test (not through `WorldStore`), so it only needs to satisfy the type: + +```ts +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' }, +}; +``` + +- [ ] **Step 3: Run the inventory suite to confirm it still compiles and passes** + +Run: `npm run test --workspace=@ashen-realms/web -- inventory-page` +Expected: PASS + +- [ ] **Step 4: Add the failing error-mapping test for `combat.store.spec.ts`** + +Add this test near the existing "clears any previous combat and reports the mapped error when starting fails" test: + +```ts + 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.'); + }); +``` + +- [ ] **Step 5: Run it to verify it fails** + +Run: `npm run test --workspace=@ashen-realms/web -- combat.store` +Expected: FAIL — the message falls back to the generic error text because the code is unmapped. + +- [ ] **Step 6: Add the mapping** + +In `apps/web/src/app/features/combat/combat.store.ts`: + +```ts +const COMBAT_ERROR_MESSAGES: Readonly> = { + HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.', + HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.', + INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.', + 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_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.', + COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.', + COMBAT_NO_POTIONS_REMAINING: 'Du hast keine Tränke mehr.', +}; +``` + +- [ ] **Step 7: Run it to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/web -- combat.store` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/inventory/inventory-page.component.spec.ts apps/web/src/app/features/combat/combat.store.ts apps/web/src/app/features/combat/combat.store.spec.ts +git commit -m "feat(web): model HP regen fields and map CHARACTER_TOO_WOUNDED" +``` + +--- + +### Task 15: `WorldStore` counts up displayed HP locally between server syncs + +**Files:** +- Modify: `apps/web/src/app/features/world/world.store.ts` +- Modify: `apps/web/src/app/features/world/world.store.spec.ts` + +**Interfaces:** +- Consumes: `CharacterResponse.hpRegenPerSecond`/`hpRegenSince` (Task 14). +- Produces: `WorldStore.displayedCharacter: Signal`. Consumed by `app-shell.component.html` (Task 16). + +- [ ] **Step 1: Update the shared fixture and add the failing tests** + +In `apps/web/src/app/features/world/world.store.spec.ts`, update the top-level `character` fixture: + +```ts +const character: CharacterResponse = { + id: 'character-id', + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + currentHp: 100, + maxHp: 100, + attack: 6, + hpRegenPerSecond: 1, + hpRegenSince: null, + currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, +}; +``` + +Add a new `describe` block at the end of the file, before the closing of the outer `describe('WorldStore', ...)`: + +```ts + describe('HP regeneration display', () => { + it('counts displayedCharacter up once per second while an anchor is set', async () => { + const wounded: CharacterResponse = { + ...character, + currentHp: 40, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(wounded)); + + await store.load(); + expect(store.displayedCharacter()?.currentHp).toBe(40); + + await vi.advanceTimersByTimeAsync(3_000); + + expect(store.displayedCharacter()?.currentHp).toBe(43); + }); + + it('stops at maxHp instead of counting past it', async () => { + const almostHealed: CharacterResponse = { + ...character, + currentHp: 99, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(almostHealed)); + + await store.load(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(store.displayedCharacter()?.currentHp).toBe(100); + }); + + it('does not tick while regeneration is paused', async () => { + const paused: CharacterResponse = { + ...character, + currentHp: 40, + maxHp: 100, + hpRegenSince: null, + }; + api.getCharacter.mockReturnValue(of(paused)); + + await store.load(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(store.displayedCharacter()?.currentHp).toBe(40); + }); + + it('resyncs the ticker to a freshly loaded anchor on refreshCharacter', async () => { + api.getCharacter.mockReturnValue(of(character)); + await store.load(); + + const stillWounded: CharacterResponse = { + ...character, + currentHp: 10, + maxHp: 100, + hpRegenSince: '2026-08-18T10:00:00.000Z', + }; + api.getCharacter.mockReturnValue(of(stillWounded)); + await store.refreshCharacter(); + + await vi.advanceTimersByTimeAsync(4_000); + + expect(store.displayedCharacter()?.currentHp).toBe(14); + }); + }); +``` + +- [ ] **Step 2: Run the suite to verify the new tests fail and existing ones still pass** + +Run: `npm run test --workspace=@ashen-realms/web -- world.store` +Expected: The four new tests FAIL (`displayedCharacter` doesn't exist yet); every pre-existing test in the file still PASSes. + +- [ ] **Step 3: Update the implementation** + +In `apps/web/src/app/features/world/world.store.ts`, add a new private state signal and its public accessor near the top: + +```ts + private readonly characterState = signal(null); + private readonly displayedCharacterState = signal(null); +``` + +```ts + readonly character = this.characterState.asReadonly(); + readonly displayedCharacter = this.displayedCharacterState.asReadonly(); +``` + +Add a new private field beside `countdownTimer`: + +```ts + private countdownTimer: ReturnType | undefined; + private regenTimer: ReturnType | undefined; +``` + +Replace the three call sites that currently do `this.characterState.set(character);` — inside `load()`, inside `refreshCharacter()`, and inside `reloadAuthoritativeState()` — with `this.applyCharacter(character);`. Do not change anything else in those three methods. + +Add the new private methods, near `startCountdown`/`stopCountdown`: + +```ts + private applyCharacter(character: CharacterResponse): void { + this.characterState.set(character); + this.stopRegenTicker(); + this.refreshDisplayedCharacter(character); + if (character.hpRegenSince !== null) { + this.regenTimer = setInterval(() => this.refreshDisplayedCharacter(character), 1_000); + } + } + + private refreshDisplayedCharacter(character: CharacterResponse): void { + if (this.destroyed) { + return; + } + + const currentHp = this.computeDisplayedHp(character); + this.displayedCharacterState.set({ ...character, currentHp }); + if (currentHp >= character.maxHp) { + this.stopRegenTicker(); + } + } + + private computeDisplayedHp(character: CharacterResponse): number { + if (character.hpRegenSince === null) { + return character.currentHp; + } + + const elapsedSeconds = Math.max(0, (Date.now() - Date.parse(character.hpRegenSince)) / 1000); + const regenerated = Math.floor(elapsedSeconds * character.hpRegenPerSecond); + return Math.min(character.maxHp, character.currentHp + regenerated); + } + + private stopRegenTicker(): void { + if (this.regenTimer !== undefined) { + clearInterval(this.regenTimer); + this.regenTimer = undefined; + } + } +``` + +Update `ngOnDestroy` to also stop the new timer: + +```ts + ngOnDestroy(): void { + this.destroyed = true; + this.stopCountdown(); + this.stopRegenTicker(); + this.clearTravelRetry(); + } +``` + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `npm run test --workspace=@ashen-realms/web -- world.store` +Expected: PASS (all tests, old and new) + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/app/features/world/world.store.ts apps/web/src/app/features/world/world.store.spec.ts +git commit -m "feat(web): count displayed HP up locally between server syncs" +``` + +--- + +### Task 16: HUD reads the locally-ticking HP value + +**Files:** +- Modify: `apps/web/src/app/layout/app-shell/app-shell.component.html` +- Modify: `apps/web/src/app/app.spec.ts` + +**Interfaces:** +- Consumes: `WorldStore.displayedCharacter` (Task 15). + +- [ ] **Step 1: Update the failing test fixture** + +In `apps/web/src/app/app.spec.ts`, the fake `WorldStore` provided in `beforeEach` only supplies `{ character, currentLocation, selectedConnection }`. Add a `displayedCharacter` signal alongside `character`: + +```ts + let character: WritableSignal; + let displayedCharacter: WritableSignal; + let currentLocation: WritableSignal; + let selectedConnection: WritableSignal; + + beforeEach(async () => { + character = signal(null); + displayedCharacter = signal(null); + currentLocation = signal(null); + selectedConnection = signal(null); + + await TestBed.configureTestingModule({ + imports: [AppShellComponent], + providers: [ + provideRouter([ + { path: 'location', children: [] }, + { path: 'world', children: [] }, + { path: 'hunt', children: [] }, + { path: 'inventory', children: [] }, + ]), + { + provide: WorldStore, + useValue: { character, displayedCharacter, currentLocation, selectedConnection }, + }, + ], + }).compileComponents(); + }); +``` + +In the `'renders loaded character values supplied by the WorldStore'` test, the component is about to switch from reading `character()` to reading `displayedCharacter()`. Set `character` to a decoy value the HUD must NOT show, and `displayedCharacter` to the value it must show — this way the test actually fails before Task 16 Step 3's binding change, instead of passing for the wrong reason: + +```ts + it('renders loaded character values supplied by the WorldStore', () => { + 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', + name: 'Mara Ashfall', + level: 7, + experience: 320, + silver: 150, + currentHp: 52, + maxHp: 80, + attack: 12, + hpRegenPerSecond: 1, + hpRegenSince: null, + currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' }, + }; + character.set(decoy); + displayedCharacter.set(value); + const fixture = TestBed.createComponent(AppShellComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain( + 'Mara Ashfall', + ); + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('Stufe 7'); + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('52 / 80'); + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('150'); + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320'); + }); +``` + +- [ ] **Step 2: Run the suite to verify it fails** + +Run: `npm run test --workspace=@ashen-realms/web -- app.spec` +Expected: FAIL — the template still binds `worldStore.character()`, so it renders "Stale Decoy" / "Stufe 1" / "1 / 1" instead of the expected values. + +- [ ] **Step 3: Update the binding** + +In `apps/web/src/app/layout/app-shell/app-shell.component.html`, change: + +```html + +``` + +to: + +```html + +``` + +- [ ] **Step 4: Run the full web suite to catch anything else touching AppShellComponent or TopBarComponent** + +Run: `npm run test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/app/layout/app-shell/app-shell.component.html apps/web/src/app/app.spec.ts +git commit -m "feat(web): bind the HUD health bar to the locally-ticking HP value" +``` + +--- + +### Task 17: Full-suite verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full API test suite** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: PASS, zero failures + +- [ ] **Step 2: Run the full web test suite** + +Run: `npm run test --workspace=@ashen-realms/web` +Expected: PASS, zero failures + +- [ ] **Step 3: Build both apps to catch any type errors the test suites didn't** + +Run: `npm run build` +Expected: PASS, no compile errors + +- [ ] **Step 4: Re-read the design doc's edge-case table (§10) against the finished code** + +Confirm by inspection: max-HP rise re-anchors (Task 11), max-HP fall has no unequip path to exercise it (documented gap, not a bug), backwards clock cannot reduce HP (Task 4's test), abandoned mid-fight combat leaves the anchor null (Task 9/10 never call `resume` except at combat end), equipment mid-combat cannot happen (`characterInCombat()` guard, pre-existing), potion healing is unaffected (still clamped to the frozen `combat.playerMaxHp`, untouched by this plan). + +No commit for this task — it is a checkpoint, not a change. diff --git a/docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md b/docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md new file mode 100644 index 0000000..a7e775b --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md @@ -0,0 +1,174 @@ +# Persistent Character HP & Out-of-Combat Regeneration: Design + +**Status:** Approved section by section in brainstorming on 2026-08-21. Every open question the request left is recorded below as a ruling with its cost if wrong. + +## 1. Problem + +The player starts every combat at full HP. Finishing a fight on 10 HP and immediately entering the next one hands the player a free heal, so damage taken has no cost beyond the current round and there is no reason to ever retreat. + +Requested behaviour: + +1. HP surviving a combat carries over to the character. +2. The character regenerates 1 HP per second outside combat. +3. The HP the character walks in with is the HP the next combat starts with. +4. No automatic regeneration while a combat is running. + +## 2. Scope + +Architectural. It introduces a resource mechanic shared by the characters, combat, and equipment modules, adds a column and a service, changes the meaning of an existing column, and changes two API payloads plus one frontend store. + +## 3. Current-state findings that shape this design + +(From a codebase survey on `master` at `dd90e05`.) + +- `characters.current_hp` **already exists** (`character.entity.ts:35-36`), is already surfaced by `GET /api/characters/me` (`characters.service.ts:34`), and is already rendered by the HUD health bar (`top-bar.component.html`). Nothing writes it after seeding. +- `CombatService.startCombat` seeds the fight with `playerCurrentHp: playerStats.maxHp` (`combat.service.ts:146`). This one line is the actual bug. +- Combat never writes back: `performAction` mutates `combat.playerCurrentHp` (`combat.service.ts:242`) and saves the combat row, but the character row is only locked, never updated. +- `CharacterStatsService` is documented as the "single authoritative source of effective character stats (spec §18)" and already passes `currentHp` through (`character-stats.service.ts:57`). Its three consumers are `CharactersService`, `EquipmentService`, and `CombatService`. +- A `Clock` abstraction with a `CLOCK` injection token already exists, but lives in the travel module (`travel/clock.ts`) and is provided only by `TravelModule`. +- `TravelService.completeTravelIfDue` establishes the house lazy-settle-on-read pattern: open a transaction, lock the character, apply elapsed time, write back. +- `EquipmentService.equip` is the **only** way equipment changes today — there is no unequip endpoint, and it already refuses to run during an active combat (`characterInCombat()`, `equipment.service.ts:83-88`). +- `WorldStore` already owns the character signal that feeds the top bar (`app-shell.component.html:2`), already runs a one-second `countdownTimer` for travel, and already cleans it up in `ngOnDestroy`. +- Migration convention: timestamp-prefixed files in `apps/api/src/database/migrations/`, each with a sibling `*.migration.spec.ts` that asserts TypeORM entity metadata only and never touches a live database. Latest on disk is `1790000000000-ExtendCombatEventTypes.ts`; the not-yet-implemented Renown plan reserves `1791000000000`. +- No design document in `docs/` specifies any HP regeneration rule. This mechanic is genuinely new. + +## 4. Rulings + +**R1 — Defeat leaves the character at 0 HP and regenerates up from there.** +No revive, no partial restore. Losing costs real time, which is what makes retreating a decision. +*Cost if wrong:* a single line in the combat-end path. + +**R2 — Starting a combat requires ≥ 1 effective HP; there is no separate defeat lockout.** +One rule the player can learn, no "was defeated" flag on the character, no second timer. After a loss the player waits roughly one second. +*Cost if wrong:* raising the threshold is a constant change plus one test; it needs no schema change. + +**R3 — Regeneration is a derived value anchored to a timestamp, not a scheduled job and not a settle-on-read write.** +`characters.current_hp` becomes "HP as of `hp_regen_since`". Effective HP is a pure function of `(current_hp, hp_regen_since, maxHp, now)`. +Rejected alternatives: +- *A cron tick incrementing every character each second* — one write per character per second, and every server restart or outage silently swallows the elapsed time. Only testable against real timers. +- *Settle-on-read after the `completeTravelIfDue` pattern* — turns every `GET` into a locking write transaction. Since the client ticks locally, the API has to return the anchor timestamp regardless, which makes the write pure redundancy and opens a lost-update window between concurrent reads. + +The derived model additionally gets max-HP clamping for free (the `min(maxHp, …)` happens at read time, so an equipment change never has to touch HP) and produces exactly the four values the client needs to count up locally. +*Cost if wrong:* switching to settle-on-read later reuses the same column and the same arithmetic; only the call sites change. + +**R4 — Regeneration is paused during combat by setting the anchor to `NULL`, and time spent in combat is not credited afterwards.** +This is what "no regeneration during combat" means: a paused clock, not a deferred one. `NULL` encodes "frozen at `current_hp`" and needs no extra column. +*Cost if wrong:* crediting combat time instead would mean not nulling the anchor at all — a deletion, not a rewrite. + +**R5 — Every combat round mirrors the player's HP onto the character, rather than only writing back once at combat end.** +`performAction` already holds a pessimistic write lock on the character row, so this is one extra field on a row that is being saved anyway. It keeps the HUD truthful mid-fight and removes "combat end" as a special case — the end differs only in that it restarts the anchor. +*Cost if wrong:* dropping the mirror leaves the write-back at combat end; the code path stays identical. + +**R6 — `EquipmentService.equip` re-anchors HP, to stop accumulated overflow from being cashed in.** +The anchor clamps on read, not on write. A character idling ten minutes at 100/100 (anchor: 100 HP, 600 s ago) who then equips +20 max HP would evaluate to `min(120, 100 + 600) = 120` and receive the new points instantly. `settle()` before the equipment change collapses the anchor to the clamped value at `now()`. +This is the only call site: there is no unequip endpoint, and `equip` is already blocked during combat — so `settle()` here always runs against a live (non-`NULL`) anchor. +*Cost if wrong:* removing the call restores the old behaviour; it is one line. + +**R7 — The regeneration rate is one exported constant, not a column and not per-character.** +`HP_REGEN_PER_SECOND = 1` in `characters/character-vitals.constants.ts`, matching how `STARTING_POTION_COUNT` lives beside its service. +*Cost if wrong:* making it character-derived later changes the service signature and one migration; no consumer outside the vitals service reads it. + +**R8 — The client counts up locally from the server's anchor; the server stays authoritative.** +The API returns `hpRegenPerSecond` and `hpRegenSince`; `WorldStore` recomputes the displayed value every second using the same formula, clamped to `maxHp`. Every real request overwrites the local value. No polling loop. +*Cost if wrong:* falling back to "update only on navigation" is deleting the timer. + +## 5. Data model + +Migration `1792000000000-AddHpRegeneration.ts` (skipping `1791000000000`, which the pending Renown plan reserves): + +```sql +ALTER TABLE characters ADD COLUMN hp_regen_since timestamptz NULL; +``` + +Backfill in the same migration: + +- `hp_regen_since = now()` for every character, then +- `hp_regen_since = NULL` for characters holding a combat with `status = 'ACTIVE'`, so the migration does not gift an in-flight fight. + +`down()` drops the column. + +**Column contract**, documented on the entity: `current_hp` is the HP value as of `hp_regen_since`. When `hp_regen_since` is `NULL`, regeneration is paused and `current_hp` is exact. **No code outside `CharacterVitalsService` may read `current_hp` directly.** + +## 6. Components + +### 6.1 `shared/clock.ts` (moved) + +`travel/clock.ts` moves to `shared/clock.ts` verbatim. Two modules must not borrow a clock from the travel module. `TravelModule` keeps providing it; `CharactersModule` provides it too. Import paths in `travel.module.ts` and `travel.service.ts` change; nothing else does. + +### 6.2 `CharacterVitalsService` (new) + +`apps/api/src/characters/character-vitals.service.ts`. The only place the anchor arithmetic exists. Injects `CLOCK`. No method opens a transaction — each operates on an already-loaded `Character` instance, leaving locking and saving to the caller. + +| Method | Behaviour | +|---|---| +| `effectiveHp(character, maxHp): number` | Pure. Anchor `NULL` ⇒ `min(maxHp, current_hp)`. Otherwise `min(maxHp, current_hp + floor(elapsedSeconds × HP_REGEN_PER_SECOND))`, with `elapsedSeconds` floored at 0 so a backwards-moving clock can never reduce HP. | +| `pause(character, value)` | `current_hp = value`, `hp_regen_since = null`. | +| `resume(character, value)` | `current_hp = value`, `hp_regen_since = clock.now()`. | +| `settle(character, maxHp)` | `resume(character, effectiveHp(character, maxHp))`. Re-anchors without changing the effective value. | + +### 6.3 `CharacterStatsService` (changed) + +`currentHp` in `EffectiveCharacterStats` becomes the effective value rather than the raw column, via an injected `CharacterVitalsService`. This keeps the documented "single authoritative source" claim true and makes all three existing consumers correct without touching them individually. + +### 6.4 Module wiring + +`CharactersModule` adds `CharacterVitalsService` and `{ provide: CLOCK, useValue: systemClock }` to `providers`, and `CharacterVitalsService` to `exports`. `CombatModule` and `EquipmentModule` already import `CharactersModule`, so they need no new imports — only the constructor injection. + +## 7. Data flow + +| Event | Effect | +|---|---| +| `GET /api/characters/me` | Read-only. Response gains `hpRegenPerSecond: number` and `hpRegenSince: string \| null` (ISO 8601). `currentHp` is the effective value. | +| `POST /api/combats` | Compute effective HP. If `< 1`, throw `CHARACTER_TOO_WOUNDED`. Otherwise `pause(character, effectiveHp)`, save the character, and seed `combat.playerCurrentHp` with that value instead of `playerStats.maxHp`. `playerMaxHp` still comes from `playerStats.maxHp`. | +| `POST /api/combats/:id/actions`, fight continues | After the engine resolves the round: `pause(character, combat.playerCurrentHp)`, save the character in the same transaction. | +| `POST /api/combats/:id/actions`, fight ends (`WON`/`LOST`) | `resume(character, combat.playerCurrentHp)` instead of `pause`. The anchor restarts; a loss regenerates from 0. | +| `POST /api/equipment` | `settle(character, maxHpBeforeChange)` before writing the equipment row. | + +The character row is already locked in all three write paths, so no new locking or lock ordering is introduced. + +## 8. Frontend + +- `CharacterResponse` (`game-api.models.ts`) gains `hpRegenPerSecond` and `hpRegenSince`. +- `WorldStore` gains a second one-second timer beside `countdownTimer`, cleaned up in the same `ngOnDestroy`, and a `displayedCharacter` signal that applies the section 6.2 formula to the last server snapshot. The timer only runs while `hpRegenSince !== null`. `characterState` keeps the raw server value; every real request resyncs it. +- `app-shell.component.html` binds the top bar to `worldStore.displayedCharacter()`. +- `TopBarComponent` is unchanged — it still reads one input. +- Error messages for `CHARACTER_TOO_WOUNDED` go into both `COMBAT_ERROR_MESSAGES` (`combat.store.ts`) and `HUNT_ERROR_MESSAGES` (`hunting.store.ts`), because the fight is started from the hunt screen: *"Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast."* + +## 9. Error handling + +One new code in `combat.errors.ts`, following the existing factory-function pattern: + +```ts +export function characterTooWounded(): CombatDomainError { + return new CombatDomainError( + 'CHARACTER_TOO_WOUNDED', + HttpStatus.CONFLICT, + 'The character is too wounded to fight.', + ); +} +``` + +added to the `CombatErrorCode` union. `409 Conflict` matches `CHARACTER_TRAVELLING`, the other "right request, wrong moment" case. + +## 10. Edge cases + +| Case | Handling | +|---|---| +| Max HP rises (equipping) | R6's `settle()` re-anchors first, so only time elapsed *after* the change counts toward the new ceiling. | +| Max HP falls | No unequip endpoint exists today, but `min(maxHp, …)` clamps on every read, so a future one needs no HP-specific code beyond the existing `settle()` call. | +| Clock moves backwards | Elapsed seconds floor at 0; HP can never decrease through regeneration. | +| Combat abandoned mid-fight | The anchor stays `NULL`, so the character does not regenerate. This follows directly from R4. It is not a dead end: `App.ngOnInit` already resumes an open combat on the next page load. | +| Equipment changed mid-combat | Cannot happen — `EquipmentService.equip` already throws `characterInCombat()`. | +| Potion healing | Unchanged. The engine still clamps to `combat.playerMaxHp`, which is frozen at combat start. | + +## 11. Testing + +TDD throughout, in plan order. + +- **`character-vitals.service.spec.ts`** (the core): anchor arithmetic against a fake clock, clamping at `maxHp`, `NULL` anchor returns the frozen value, backwards clock yields no loss, `pause`/`resume`/`settle` write the expected pair of fields. +- **`character-stats.service.spec.ts`**: the existing `passes currentHp through unchanged from the character` case is replaced — `currentHp` is now the effective value. +- **`combat.service.spec.ts`**: start seeds from character HP rather than max HP; start at 0 effective HP throws `CHARACTER_TOO_WOUNDED`; start at exactly 1 HP succeeds; a continuing round mirrors HP onto the character with a `NULL` anchor; a win and a loss both restart the anchor at `now()`. +- **`equipment.service.spec.ts`**: equipping re-anchors, so a long-idle character at full HP does not instantly gain the item's bonus HP. +- **`add-hp-regeneration.migration.spec.ts`**: entity-metadata assertions matching the eight existing migration specs, plus the backfill rule for a character with an active combat. +- **`world.store.spec.ts`**: local count-up under Jest fake timers, stop at `maxHp`, no ticking while the anchor is `null`, timer cleared on destroy. +- **E2E**: finish a combat with damage taken → `GET /characters/me` reports the reduced HP → after advancing the injected clock, it reports a higher value. diff --git a/tools/asset-gen.mjs b/tools/asset-gen.mjs new file mode 100644 index 0000000..ad5c282 --- /dev/null +++ b/tools/asset-gen.mjs @@ -0,0 +1,180 @@ +/** + * Derives 9-slice-safe HUD assets from the hand-made art in + * `apps/web/public/assets/hud-elements/`. + * + * `panel-bg.png` carries a gem ornament at the centre of every edge. Stretched + * by `border-image` those ornaments smear, so this script paints them out + * against a plain stretch of the same edge and saves the result as a frame that + * tiles at any panel size. The ornaments are saved separately so the UI can put + * them back at the true centre of each panel. + * + * The generated files are committed, so this only needs re-running when the + * source art changes. It drives Playwright's bundled Chromium for canvas work; + * Playwright is not a direct dependency (it arrives with the Angular test + * builder), so install it explicitly if that ever stops being true. + * + * Run with the dev server up (a file:// canvas is tainted and cannot be read): + * node tools/asset-gen.mjs # defaults to http://localhost:4455 + * ASSET_ORIGIN=http://localhost:4200 node tools/asset-gen.mjs + */ +import { chromium } from 'playwright'; +import { fileURLToPath } from 'url'; +import { writeFile } from 'fs/promises'; +import path from 'path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = path.join(root, 'apps/web/public/assets/hud-elements'); +const ORIGIN = process.env.ASSET_ORIGIN ?? 'http://localhost:4455'; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.goto(`${ORIGIN}/assets/hud-elements/panel-bg.png`); + +const result = await page.evaluate(async () => { + const load = async (src) => { + const img = new Image(); + img.src = src; + await img.decode(); + return img; + }; + const canvasOf = (w, h) => { + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + return c; + }; + + const img = await load('panel-bg.png'); + const src = canvasOf(img.naturalWidth, img.naturalHeight); + src.getContext('2d').drawImage(img, 0, 0); + const sctx = src.getContext('2d'); + + // ---- 1. trim the transparent margin so the frame meets the image edge ---- + const full = sctx.getImageData(0, 0, src.width, src.height).data; + const alphaAt = (x, y) => full[(y * src.width + x) * 4 + 3]; + let x0 = 0; + let x1 = src.width - 1; + let y0 = 0; + let y1 = src.height - 1; + const opaqueCol = (x) => { + for (let y = 0; y < src.height; y++) if (alphaAt(x, y) > 40) return true; + return false; + }; + const opaqueRow = (y) => { + for (let x = 0; x < src.width; x++) if (alphaAt(x, y) > 40) return true; + return false; + }; + while (x0 < x1 && !opaqueCol(x0)) x0++; + while (x1 > x0 && !opaqueCol(x1)) x1--; + while (y0 < y1 && !opaqueRow(y0)) y0++; + while (y1 > y0 && !opaqueRow(y1)) y1--; + + const W = x1 - x0 + 1; + const H = y1 - y0 + 1; + const frame = canvasOf(W, H); + const fctx = frame.getContext('2d'); + fctx.drawImage(src, x0, y0, W, H, 0, 0, W, H); + + // ---- 2. locate the centred ornament on each edge ---- + // An edge is a repeating bar away from its ornament, so a line sampled from a + // quiet stretch is a faithful stand-in. Walk out from the centre until the + // line matches that reference again: that is where the ornament ends. + const band = 96; // how deep into the panel an edge ornament reaches + const data = fctx.getImageData(0, 0, W, H).data; + const px = (x, y) => { + const i = (y * W + x) * 4; + return [data[i], data[i + 1], data[i + 2], data[i + 3]]; + }; + const diffColumns = (ax, bx, from, to) => { + let sum = 0; + for (let y = from; y < to; y++) { + const a = px(ax, y); + const b = px(bx, y); + sum += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]); + } + return sum / (to - from); + }; + const diffRows = (ay, by, from, to) => { + let sum = 0; + for (let x = from; x < to; x++) { + const a = px(x, ay); + const b = px(x, by); + sum += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]); + } + return sum / (to - from); + }; + + const THRESHOLD = 18; + const refX = Math.round(W * 0.26); // quiet stretch of the horizontal edges + const refY = Math.round(H * 0.26); // quiet stretch of the vertical edges + const cx = Math.round(W / 2); + const cy = Math.round(H / 2); + + const spanX = (from, to) => { + let a = cx; + let b = cx; + while (a > W * 0.15 && diffColumns(a, refX, from, to) > THRESHOLD) a--; + while (b < W * 0.85 && diffColumns(b, refX, from, to) > THRESHOLD) b++; + return [a, b]; + }; + const spanY = (from, to) => { + let a = cy; + let b = cy; + while (a > H * 0.15 && diffRows(a, refY, from, to) > THRESHOLD) a--; + while (b < H * 0.85 && diffRows(b, refY, from, to) > THRESHOLD) b++; + return [a, b]; + }; + + const [topA, topB] = spanX(0, band); + const [botA, botB] = spanX(H - band, H); + const [leftA, leftB] = spanY(0, band); + const [rightA, rightB] = spanY(W - band, W); + + // ---- 3. keep a copy of the top ornament before painting it out ---- + // The bottom edge measures the ornament most tightly (the top edge's texture + // drifts enough to over-report), so use its width for both. + const ornW = Math.max(botB - botA, 96); + const ornH = band; + const ornX = Math.round(cx - ornW / 2); + const ornament = canvasOf(ornW, ornH); + ornament.getContext('2d').drawImage(frame, ornX, 0, ornW, ornH, 0, 0, ornW, ornH); + + // ---- 4. paint each ornament out with the plain part of its own edge ---- + // `drawImage` composites, so a transparent clean column would leave an opaque + // ornament showing through. Clear the region first, then replace it. + const patchH = (a, b, y) => { + fctx.clearRect(a, y, b - a, band); + for (let x = a; x < b; x++) fctx.drawImage(frame, refX, y, 1, band, x, y, 1, band); + }; + const patchV = (a, b, x) => { + fctx.clearRect(x, a, band, b - a); + for (let y = a; y < b; y++) fctx.drawImage(frame, x, refY, band, 1, x, y, band, 1); + }; + + patchH(topA, topB, 0); + patchH(botA, botB, H - band); + patchV(leftA, leftB, 0); + patchV(rightA, rightB, W - band); + + return { + frame: frame.toDataURL('image/png'), + ornament: ornament.toDataURL('image/png'), + meta: { + source: { w: img.naturalWidth, h: img.naturalHeight }, + trimmed: { w: W, h: H }, + ornaments: { top: [topA, topB], bottom: [botA, botB], left: [leftA, leftB], right: [rightA, rightB] }, + ornamentSize: { w: ornW, h: ornH }, + }, + }; +}); + +const write = async (name, dataUrl) => { + const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1); + await writeFile(path.join(outDir, name), Buffer.from(base64, 'base64')); +}; + +await write('panel-frame.png', result.frame); +await write('panel-ornament.png', result.ornament); + +console.log(JSON.stringify(result.meta, null, 2)); +await browser.close();