diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index 270597e..be70953 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -403,7 +403,7 @@ } .action__key { - inset-block-start: 90%; + inset-block-start: 85%; color: var(--ar-text-muted); font-size: var(--ar-font-sm); font-variant-numeric: tabular-nums; diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index 64edb5d..e1ea829 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -162,7 +162,7 @@ describe('CombatPageComponent', () => { expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1); // The monster strikes back after the beat. - await vi.advanceTimersByTimeAsync(260); + await vi.advanceTimersByTimeAsync(1260); fixture.detectChanges(); expect(sprite?.classList.contains('sprite--hit')).toBe(true); expect(monster?.classList.contains('sprite--lunge')).toBe(true); diff --git a/docs/superpowers/specs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md b/docs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md similarity index 100% rename from docs/superpowers/specs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md rename to docs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md diff --git a/docs/Ashen_Realms_NPC_System_Specification_V1.md b/docs/Ashen_Realms_NPC_System_Specification_V1.md new file mode 100644 index 0000000..a39c2b5 --- /dev/null +++ b/docs/Ashen_Realms_NPC_System_Specification_V1.md @@ -0,0 +1,1333 @@ +# Ashen Realms – NPC System Specification V1 + +## Zweck dieser Dokumentation + +Dieses Dokument definiert das NPC-System von **Ashen Realms** für den ersten erweiterten Vertical Slice und die darauf aufbauende Spielarchitektur. + +Es beschreibt: + +- das gemeinsame NPC-Grundmodell +- die Trennung zwischen NPC-Definition und spielerspezifischem Zustand +- NPC-Fähigkeiten statt Klassenvererbung +- Händler, Questgeber und gemischte Rollen +- Dialoge und Dialogbedingungen +- Ruf- und Freischaltbedingungen +- Materialtausch und Verkaufslogik +- Anbindung des Taschensystems +- wiederverwendbare Conditions +- technische Datenmodelle und Services +- den empfohlenen V1-Scope + +Das System folgt den bestehenden Grundsätzen von Ashen Realms: + +- Content ist datengetrieben. +- Spiellogik ist serverautoritativ. +- Content-Definitionen und Player-State bleiben getrennt. +- NPCs sollen Teil der Welt sein und nicht nur UI-Schaltflächen mit Portrait. +- Neue Spezialfälle sollen möglichst über wiederverwendbare Bausteine modelliert werden. + +--- + +# 1. Designziel + +NPCs sind zentrale Interaktionspunkte der Welt. + +Sie können unter anderem: + +- Quests anbieten +- Quests abschließen +- Gegenstände verkaufen +- Materialien ankaufen oder eintauschen +- Ruf prüfen +- besondere Waren freischalten +- Taschen verkaufen oder vergeben +- Informationen geben +- Story und Lore vermitteln +- Reisen oder andere Dienste anbieten + +Ein NPC darf mehrere dieser Funktionen gleichzeitig besitzen. + +Beispiel: + +**Elyra, die verbannte Jägerin** kann gleichzeitig: + +- Story-NPC sein +- Jagdquests anbieten +- Materialien annehmen +- Dämmerwald-Ruf berücksichtigen +- Gebietsausrüstung verkaufen +- besondere Angebote nach Quests freischalten + +Grundsatz: + +> **NPCs sind Personen mit mehreren Fähigkeiten – keine voneinander getrennten NPC-Klassen.** + +--- + +# 2. Keine klassische NPC-Vererbung + +Für das NPC-System wird ausdrücklich keine tiefe Klassenhierarchie verwendet. + +Nicht vorgesehen: + +```ts +class Npc {} +class MerchantNpc extends Npc {} +class QuestNpc extends Npc {} +class QuestMerchantNpc extends MerchantNpc {} +``` + +Dieses Modell skaliert schlecht, sobald ein NPC mehrere Rollen kombiniert. + +Stattdessen verwendet Ashen Realms: + +> **Composition / Capability-based NPC design** + +Ein NPC besitzt eine gemeinsame Identität und kann über verknüpfte Daten verschiedene Fähigkeiten erhalten. + +Beispiel: + +```text +NPC +├── Dialogue +├── Quest Assignments +├── Shop +├── Resource Exchange +├── Reputation Requirements +└── Player-specific State +``` + +--- + +# 3. NPC Definition + +Jeder NPC besitzt eine persistierte Content-Definition. + +Vorgeschlagenes Modell: + +```ts +NpcDefinition { + id: string; + key: string; + + name: string; + title?: string; + description?: string; + + locationId: string; + factionKey?: string; + + portraitPath: string; + artworkPath?: string; + + dialogueProfileId?: string; + + enabled: boolean; + + createdAt: Date; + updatedAt: Date; +} +``` + +## Pflichtfelder + +| Feld | Bedeutung | +|---|---| +| `id` | interne UUID | +| `key` | stabiler fachlicher Key | +| `name` | sichtbarer NPC-Name | +| `locationId` | aktueller Standort | +| `portraitPath` | Portrait für UI und Dialog | +| `enabled` | NPC aktiv/inaktiv | + +## Optionale Felder + +| Feld | Bedeutung | +|---|---| +| `title` | z. B. „Händler der Grenzwacht“ | +| `description` | kurze Beschreibung | +| `factionKey` | Fraktionszugehörigkeit | +| `artworkPath` | größeres Artwork für lokale Ansicht | +| `dialogueProfileId` | Dialogdefinition | + +--- + +# 4. Stabiler NPC-Key + +Jeder NPC benötigt einen stabilen fachlichen Key. + +Beispiele: + +```text +captain-garrick +merchant-borin +elyra-exiled-huntress +brother-caelan +``` + +Dieser Key darf in: + +- Seeds +- Quests +- Dialogbedingungen +- Shopbedingungen +- Flags +- Tests + +verwendet werden. + +Interne UUIDs sollen nicht zur fachlichen Referenzierung von Content benutzt werden. + +--- + +# 5. NPC Capabilities + +NPC-Fähigkeiten werden nicht über Vererbung bestimmt. + +Für V1 und spätere Erweiterungen sind folgende Capabilities vorgesehen: + +| Capability | Funktion | +|---|---| +| `DIALOGUE` | NPC kann angesprochen werden | +| `QUEST_GIVER` | bietet Quests an | +| `QUEST_TURN_IN` | nimmt Quests entgegen | +| `MERCHANT` | besitzt normalen Shop | +| `REPUTATION_MERCHANT` | Angebote hängen von Ruf ab | +| `RESOURCE_EXCHANGE` | tauscht Materialien gegen Belohnungen | +| `BAG_MERCHANT` | verkauft oder vergibt Taschen | +| `LORE` | bietet Story-/Weltinformationen | +| `TRAINER` | spätere Skills/Fähigkeiten | +| `TRAVEL` | spätere Reiseleistung | +| `SERVICE` | Heilung, Bank, Reparatur etc. | +| `EVENT` | zeit- oder weltzustandsabhängige Funktion | + +Capabilities sind primär beschreibend. + +Die tatsächliche Funktion entsteht durch verknüpfte Daten wie: + +- `NpcShop` +- `NpcQuestAssignment` +- `NpcDialogueProfile` +- `NpcExchangeProfile` + +--- + +# 6. NPC und Standort + +Ein NPC befindet sich grundsätzlich an einem Ort. + +V1: + +```text +NpcDefinition.locationId -> LocationDefinition.id +``` + +Später möglich: + +- NPC wechselt abhängig von Weltzustand den Ort +- NPC erscheint nur während einer Quest +- NPC wird nach Storyfortschritt ersetzt +- NPC verschwindet temporär + +Für V1 wird der Standort statisch gespeichert. + +--- + +# 7. Trennung von Content und Player-State + +Ein NPC ist globaler Content. + +Der individuelle Zustand eines Spielers gegenüber diesem NPC ist separater Player-State. + +Nicht in `NpcDefinition` speichern: + +- ob ein Spieler den NPC bereits getroffen hat +- persönliche Beziehung +- individuelle Dialogflags +- persönliche Freischaltungen + +Dafür existiert optional: + +```ts +CharacterNpcState { + id: string; + characterId: string; + npcId: string; + + relationValue?: number; + firstMetAt?: Date; + lastInteractionAt?: Date; + + flags: Record; +} +``` + +--- + +# 8. Ruf ist nicht automatisch NPC-Beziehung + +Ashen Realms unterscheidet ausdrücklich zwischen: + +## Welt-/Gebiet-/Fraktionsruf + +Beispiele: + +```text +World Renown +Graufurt Reputation +Ashen Fields Reputation +Duskwood Reputation +Faction Reputation +``` + +und + +## Persönlicher NPC-Beziehung + +Beispiele: + +```text +Borin kennt den Spieler +Elyra vertraut dem Spieler +Caelan reagiert auf eine Storyentscheidung +``` + +Ruf ist ein übergeordnetes Progressionssystem. + +NPC-Beziehung ist optionaler individueller Zustand. + +Ein Händler kann daher verlangen: + +```text +Graufurt Reputation >= 3 +``` + +ohne dass der Spieler automatisch „3 Ruf bei Borin“ besitzt. + +--- + +# 9. Character Reputation + +Ruf wird unabhängig von NPCs gespeichert. + +Beispielmodell: + +```ts +CharacterReputation { + id: string; + characterId: string; + + type: ReputationType; + targetKey: string; + + value: number; +} +``` + +Mögliche Typen: + +```ts +enum ReputationType { + WORLD = 'WORLD', + REGION = 'REGION', + FACTION = 'FACTION', +} +``` + +Beispiele: + +```text +WORLD / global / 4 +REGION / graufurt / 3 +REGION / duskwood / 2 +FACTION / border-watch / 1 +``` + +--- + +# 10. NPC Dialogue System + +NPCs sollen nicht nur statische Texte besitzen. + +Dialoge reagieren auf: + +- Quests +- Ruf +- Items +- Flags +- besiegte Gegner oder Bosse +- Storyfortschritt +- erste Begegnung + +Vorgeschlagenes Modell: + +```ts +NpcDialogueProfile { + id: string; + key: string; + npcId: string; +} +``` + +```ts +DialogueNode { + id: string; + profileId: string; + + key: string; + text: string; + + priority: number; + + conditions: GameCondition[]; + actions: DialogueAction[]; + responses: DialogueResponse[]; +} +``` + +--- + +# 11. Dialogpriorität + +Mehrere Dialoge können gleichzeitig gültig sein. + +Daher besitzt jeder Node eine Priorität. + +Beispiel: + +```text +1000 – Questabschluss verfügbar +900 – Quest aktiv +800 – neue wichtige Quest +500 – Ruf-basierter Dialog +100 – Standarddialog +``` + +Der Server ermittelt den höchstpriorisierten gültigen Dialog. + +Dadurch kann ein NPC situationsabhängig reagieren, ohne große If-/Else-Blöcke im Code. + +--- + +# 12. Dialogue Responses + +Ein Dialog kann mehrere auswählbare Antworten besitzen. + +```ts +DialogueResponse { + id: string; + nodeId: string; + + text: string; + targetNodeKey?: string; + + conditions?: GameCondition[]; + actions?: DialogueAction[]; +} +``` + +Beispiele: + +```text +„Zeig mir deine Waren.“ +„Was weißt du über die Aschengrube?“ +„Ich habe die Felle gebracht.“ +„Leb wohl.“ +``` + +--- + +# 13. Dialogue Actions + +Dialoge dürfen Aktionen auslösen. + +V1-relevante Actions: + +```text +START_QUEST +COMPLETE_QUEST +OPEN_SHOP +OPEN_EXCHANGE +GRANT_ITEM +SET_FLAG +``` + +Später möglich: + +```text +START_TRAVEL +OPEN_TRAINER +OPEN_SERVICE +CHANGE_RELATION +DISCOVER_LOCATION +START_EVENT +``` + +Beispiel: + +```json +{ + "type": "OPEN_SHOP", + "shopKey": "borin-general-store" +} +``` + +--- + +# 14. Quest-Zuordnung zu NPCs + +Questgeber und Questabgabe werden getrennt modelliert. + +Nicht vorgesehen: + +```ts +npc.isQuestGiver = true; +``` + +Stattdessen: + +```ts +NpcQuestAssignment { + id: string; + npcId: string; + questId: string; + role: NpcQuestRole; +} +``` + +```ts +enum NpcQuestRole { + OFFER = 'OFFER', + TURN_IN = 'TURN_IN', + PROGRESS = 'PROGRESS', +} +``` + +Dadurch kann eine Quest mehrere NPCs verwenden. + +Beispiel: + +```text +Garrick -> OFFER +Borin -> PROGRESS +Garrick -> TURN_IN +``` + +--- + +# 15. Händler-System + +Ein Shop ist kein Spezialtyp von NPC. + +Ein NPC kann null, einen oder später mehrere Shops besitzen. + +```ts +NpcShop { + id: string; + key: string; + npcId: string; + name: string; + enabled: boolean; +} +``` + +```ts +ShopOffer { + id: string; + shopId: string; + itemDefinitionId: string; + + currencyType: string; + price: number; + + quantity?: number; + repeatable: boolean; + + conditions: GameCondition[]; +} +``` + +--- + +# 16. Rufbasierte Shop-Angebote + +Nicht mehrere Shops pro Rufstufe anlegen. + +Stattdessen erhält jedes Angebot eigene Conditions. + +Beispiel: + +```text +Großer Fellbeutel +Preis: 140 Silber +Bedingung: Graufurt Ruf >= 3 +``` + +Oder: + +```text +Dämmerjäger-Kapuze +Preis: 28 Dämmermarken +Bedingungen: +- Dämmerwald Ruf >= 4 +- Quest „Graufangs Spur“ abgeschlossen +``` + +Dadurch bleiben Shops vollständig datengetrieben. + +--- + +# 17. Resource Exchange + +Mit dem neuen Rufsystem können bestimmte NPCs Materialien annehmen. + +Dafür wird kein normaler Item-Shop missbraucht. + +Vorgesehen: + +```ts +NpcExchangeProfile { + id: string; + key: string; + npcId: string; +} +``` + +```ts +ExchangeRule { + id: string; + profileId: string; + + inputItemId: string; + inputQuantity: number; + + silverReward?: number; + worldRenownReward?: number; + regionReputationReward?: number; + factionReputationReward?: number; + + conditions: GameCondition[]; +} +``` + +Beispiel: + +```text +5 × Aschenfell +→ 18 Silber +→ 10 Graufurt-Ruf +→ 2 Weltruhm +``` + +Die genauen Werte werden separat gebalanced. + +--- + +# 18. Verbindung zum Taschensystem + +Das NPC-System muss das Taschensystem unterstützen. + +NPCs können: + +- Taschen verkaufen +- Taschen einmalig vergeben +- Taschen über Quests freischalten +- größere Taschen erst ab Rufstufen anbieten + +Beispiel: + +```text +Borin +├── Einfacher Fellbeutel +│ └── über Intro-Quest verfügbar +│ +├── Verstärkter Fellbeutel +│ └── Graufurt Ruf >= 2 +│ +└── Großer Jägerbeutel + └── Graufurt Ruf >= 4 +``` + +Taschen selbst bleiben Teil des Item-/Bag-Systems. + +Der NPC kontrolliert nur: + +- Angebot +- Freischaltung +- Preis +- Questbezug + +--- + +# 19. Game Conditions + +NPC-Systeme verwenden eine gemeinsame Condition Engine. + +V1 Condition Types: + +```ts +enum GameConditionType { + QUEST_ACTIVE = 'QUEST_ACTIVE', + QUEST_COMPLETED = 'QUEST_COMPLETED', + HAS_ITEM = 'HAS_ITEM', + REGION_REPUTATION = 'REGION_REPUTATION', + WORLD_RENOWN = 'WORLD_RENOWN', + FLAG_SET = 'FLAG_SET', + BOSS_DEFEATED = 'BOSS_DEFEATED', + LOCATION_DISCOVERED = 'LOCATION_DISCOVERED', +} +``` + +Später möglich: + +```text +FACTION_REPUTATION +NPC_RELATION +HAS_EQUIPMENT +BAG_OWNED +MONSTER_KILL_COUNT +ACHIEVEMENT_COMPLETED +EVENT_ACTIVE +TIME_WINDOW +``` + +--- + +# 20. Condition Struktur + +Conditions sollten möglichst generisch gespeichert werden. + +Beispiel: + +```ts +GameCondition { + type: GameConditionType; + key?: string; + operator?: ComparisonOperator; + value?: string | number | boolean; +} +``` + +Beispiel: + +```json +{ + "type": "REGION_REPUTATION", + "key": "graufurt", + "operator": "GTE", + "value": 3 +} +``` + +--- + +# 21. Condition Evaluation + +Eine zentrale serverseitige Komponente wertet Conditions aus. + +```ts +GameConditionService { + evaluate( + characterId: string, + conditions: GameCondition[], + ): Promise; +} +``` + +Sie kann wiederverwendet werden für: + +- Dialoge +- Shop-Angebote +- Quests +- Materialtausch +- Reiseverbindungen +- Ortsinteraktionen +- Bosszugänge +- spätere Events + +Grundsatz: + +> **Keine separaten Condition-Systeme für jedes Feature bauen.** + +--- + +# 22. NPC Interaction API + +Der Client soll NPC-Logik nicht selbst auswerten. + +Mögliche API-Endpunkte: + +```text +GET /api/locations/:locationId/npcs +GET /api/npcs/:npcId +GET /api/npcs/:npcId/interaction +POST /api/npcs/:npcId/dialogue/:nodeId/respond +``` + +Zusätzlich über separate Fachmodule: + +```text +GET /api/npcs/:npcId/shop +POST /api/npcs/:npcId/shop/purchase + +GET /api/npcs/:npcId/exchange +POST /api/npcs/:npcId/exchange +``` + +Questaktionen können über das Questmodul laufen. + +--- + +# 23. NPC Interaction Response + +Ein NPC-Interaction-Response kann enthalten: + +```ts +interface NpcInteractionDto { + npc: { + id: string; + key: string; + name: string; + title?: string; + portraitPath: string; + }; + + dialogue?: DialogueNodeDto; + + availableActions: NpcActionDto[]; +} +``` + +Beispiel Actions: + +```text +TALK +OPEN_SHOP +OPEN_EXCHANGE +VIEW_QUESTS +``` + +Der Server entscheidet, welche Actions aktuell verfügbar sind. + +--- + +# 24. NPCs in der lokalen Ortsansicht + +NPCs werden in der lokalen Ortsansicht sichtbar dargestellt. + +Ein NPC-Eintrag zeigt mindestens: + +- Portrait oder Artwork +- Name +- Titel/Rolle +- Interaktionsstatus + +Mögliche Marker: + +```text +! neue Quest +? Quest abgabebereit +¤ Händler +↔ Materialtausch +● neue Dialoginformation +``` + +Die UI soll nicht jeden Capability-Typ als eigenes dauerhaftes Symbol erzwingen. + +Nur relevante Aktionen werden gezeigt. + +--- + +# 25. NPC-Verfügbarkeit + +V1 sind NPCs grundsätzlich dauerhaft verfügbar, solange: + +```text +enabled = true +``` + +Später können Conditions für NPC-Sichtbarkeit ergänzt werden. + +Beispiele: + +- NPC erscheint erst nach Quest +- NPC stirbt oder verschwindet +- anderer NPC ersetzt ihn +- NPC wechselt Ort +- Event-NPC erscheint temporär + +Dafür kann später ergänzt werden: + +```ts +NpcDefinition.visibilityConditions +``` + +Für V1 nicht erforderlich. + +--- + +# 26. Beispiel – Borin + +```text +Borin +Händler der Grenzwacht +Ort: Graufurt +``` + +Capabilities: + +```text +DIALOGUE +MERCHANT +RESOURCE_EXCHANGE +BAG_MERCHANT +``` + +Funktionen: + +```text +Shop +├── Heiltrank +├── einfache Ausrüstung +├── Fellbeutel +└── größere Taschen über Ruf + +Exchange +├── Aschenfell +├── zähes Fell +└── weitere Tiermaterialien +``` + +Dialogzustände: + +```text +Erste Begegnung +→ reservierter Standarddialog + +Quest-Einführung aktiv +→ „Garrick schickt dich also.“ + +Graufurt-Ruf >= 2 +→ freundlicherer Dialog + +Graufurt-Ruf >= 4 +→ Spezialangebot verfügbar +``` + +--- + +# 27. Beispiel – Elyra + +```text +Elyra, die verbannte Jägerin +Ort: Verfallener Jägerschrein +``` + +Capabilities: + +```text +DIALOGUE +QUEST_GIVER +QUEST_TURN_IN +RESOURCE_EXCHANGE +REPUTATION_MERCHANT +LORE +``` + +Mögliche Funktionen: + +```text +Quests +├── Jagd auf Dämmerwölfe +├── Schwarzmähnen-Spur +└── Graufang + +Exchange +├── Dämmerfell +├── Giftdrüse +└── Elite-Trophäen + +Shop +├── Heiltrank +├── Gegengift +├── Bestienbeutel +└── Dämmerjäger-Items +``` + +--- + +# 28. Beispiel – Bruder Caelan + +```text +Bruder Caelan +Letzter Hüter +Ort: Kapelle der letzten Wacht +``` + +Capabilities: + +```text +DIALOGUE +QUEST_GIVER +QUEST_TURN_IN +REPUTATION_MERCHANT +LORE +``` + +Mögliche Besonderheiten: + +- reagiert auf entdeckte Ruinen +- erklärt Untote und Siegelbruchstücke +- bietet spezielle Waren nach Sir Varos +- besitzt Storydialoge zum Knochenfürsten + +--- + +# 29. Technische Modulstruktur + +Empfohlene Backend-Struktur: + +```text +apps/api/src/ +├── npcs/ +│ ├── entities/ +│ │ ├── npc-definition.entity.ts +│ │ ├── npc-dialogue-profile.entity.ts +│ │ ├── dialogue-node.entity.ts +│ │ └── character-npc-state.entity.ts +│ │ +│ ├── npc.service.ts +│ ├── npc.controller.ts +│ └── npc.module.ts +│ +├── shops/ +│ ├── entities/ +│ │ ├── npc-shop.entity.ts +│ │ └── shop-offer.entity.ts +│ └── ... +│ +├── reputation/ +│ ├── entities/ +│ │ └── character-reputation.entity.ts +│ └── ... +│ +├── exchanges/ +│ ├── entities/ +│ │ ├── npc-exchange-profile.entity.ts +│ │ └── exchange-rule.entity.ts +│ └── ... +│ +├── quests/ +│ └── npc-quest-assignment.entity.ts +│ +└── conditions/ + ├── game-condition.types.ts + ├── game-condition.service.ts + └── game-condition.service.spec.ts +``` + +--- + +# 30. Verantwortlichkeiten der Services + +## `NpcService` + +Verantwortlich für: + +- NPCs eines Ortes laden +- NPC-Definition laden +- aktuell verfügbare Interaktionen bestimmen +- relevanten Dialog ermitteln + +Nicht verantwortlich für: + +- Kauftransaktionen +- Rufberechnung +- Questfortschritt +- Itemtransfer + +--- + +## `GameConditionService` + +Verantwortlich für: + +- Conditions serverseitig auswerten +- wiederverwendbare Freischaltlogik + +--- + +## `ShopService` + +Verantwortlich für: + +- verfügbare Angebote bestimmen +- Conditions prüfen +- Preise validieren +- Kauf atomar durchführen + +--- + +## `ExchangeService` + +Verantwortlich für: + +- Materialbesitz prüfen +- Taschen-/Inventarbezug beachten +- Items entfernen +- Silber/Ruf gewähren +- Transaktion atomar durchführen + +--- + +# 31. Transaktionsregeln + +Folgende Aktionen müssen serverseitig und atomar ausgeführt werden: + +```text +Shop Purchase +Material Exchange +Quest Reward +Item Grant +Reputation Grant +``` + +Beispiel Materialtausch: + +```text +Material prüfen +→ Material entfernen +→ Silber gewähren +→ Regionalruf gewähren +→ Weltruhm gewähren +→ Commit +``` + +Ein Teilzustand darf nicht persistiert werden. + +--- + +# 32. Seeds + +NPC-Content wird über reproduzierbare Seeds angelegt. + +Empfohlene Dateien: + +```text +apps/api/src/database/seeds/ +├── npcs.seed.ts +├── npc-dialogues.seed.ts +├── npc-shops.seed.ts +├── npc-exchanges.seed.ts +└── npc-quest-assignments.seed.ts +``` + +Seeds müssen idempotent sein. + +Fachliche Verknüpfungen verwenden stabile Keys. + +--- + +# 33. Tests + +Mindestens folgende Fälle sollten getestet werden: + +## NPC + +- NPCs eines Ortes werden korrekt geladen +- deaktivierte NPCs erscheinen nicht +- NPC besitzt mehrere Capabilities gleichzeitig + +## Dialog + +- Standarddialog wird angezeigt +- höherpriorisierter Questdialog überschreibt Standarddialog +- Rufdialog erscheint erst bei erfüllter Condition + +## Shop + +- Rufanforderung wird serverseitig geprüft +- Questanforderung wird geprüft +- gesperrtes Item kann nicht direkt über API gekauft werden + +## Exchange + +- benötigte Materialien werden geprüft +- Materialien werden entfernt +- Silber wird vergeben +- Ruf wird vergeben +- Transaktion bleibt atomar + +## Quest + +- ein NPC kann Questgeber sein +- anderer NPC kann Questfortschritt auslösen +- dritter oder erster NPC kann Questabschluss übernehmen + +--- + +# 34. V1 Scope + +Für die erste Implementierung wird bewusst nur ein kleiner Teil benötigt. + +## Implementieren + +```text +NpcDefinition +NpcService +NpcController +NpcQuestAssignment +NpcShop +ShopOffer +NpcExchangeProfile +ExchangeRule +GameConditionService + +Conditions: +- QUEST_ACTIVE +- QUEST_COMPLETED +- REGION_REPUTATION +- WORLD_RENOWN +- FLAG_SET +``` + +Dialogsystem zunächst einfach: + +```text +priorisierte DialogNodes ++ Conditions ++ OPEN_SHOP ++ OPEN_EXCHANGE ++ START_QUEST ++ COMPLETE_QUEST +``` + +--- + +# 35. Noch nicht für V1 implementieren + +Noch nicht notwendig: + +- komplexe persönliche NPC-Beziehungen +- Romance +- NPC-Tagesabläufe +- freie NPC-Bewegung +- KI-gesteuerte NPCs +- prozedurale Dialoge +- Sprachausgabe +- NPC-Inventare als physische Simulation +- dynamische Preise +- Verhandlungssystem +- Diebstahl +- Begleiter +- NPC-Tod mit komplexen Weltfolgen +- Echtzeit-Weltzustände + +`CharacterNpcState` kann vorbereitet, aber erst später vollständig genutzt werden. + +--- + +# 36. UI-Prinzip + +Ein NPC-Screen oder NPC-Panel soll weiterhin den visuellen Grundsätzen von Ashen Realms folgen. + +NPCs werden über: + +- großes Portrait oder Artwork +- Namen +- Titel +- Dialogtext +- klar sichtbare aktuelle Aktionen + +präsentiert. + +Nicht geeignet: + +- reine Tabellenansicht +- generische Admin-Card +- zehn gleichgewichtete Buttons + +Der NPC soll wie ein Teil der Welt wirken. + +--- + +# 37. Architekturregeln + +1. **NPC-Funktionen werden über Composition modelliert, nicht über Klassenvererbung.** +2. **NPC-Definition und Player-State bleiben getrennt.** +3. **Ruf ist nicht automatisch persönliche NPC-Beziehung.** +4. **Ein NPC darf mehrere Rollen gleichzeitig besitzen.** +5. **Questgeber und Questabgabe sind getrennte Zuordnungen.** +6. **Shopangebote besitzen eigene Conditions.** +7. **Materialtausch ist ein eigenes System und kein normaler Shopverkauf.** +8. **Conditions werden zentral wiederverwendbar ausgewertet.** +9. **Der Client entscheidet nicht über Freischaltungen.** +10. **Alle relevanten Transaktionen sind serverautoritativ und atomar.** +11. **Content verwendet stabile fachliche Keys.** +12. **NPC-Spezialverhalten soll möglichst datengetrieben entstehen.** + +--- + +# 38. Zielbild + +Das NPC-System soll langfristig folgende Interaktionen ermöglichen, ohne für jeden NPC neuen Spezialcode zu schreiben: + +```text +Spieler spricht mit NPC + ↓ +Server prüft Quest-, Ruf- und Weltzustand + ↓ +passender Dialog wird gewählt + ↓ +verfügbare Aktionen werden angezeigt + ↓ +Spieler kann z. B. +├── Quest annehmen +├── Quest abgeben +├── Materialien eintauschen +├── Shop öffnen +├── Tasche kaufen +└── Lore lesen +``` + +Der gleiche technische Unterbau kann für Borin, Elyra, Bruder Caelan und zukünftige NPCs wiederverwendet werden. + +--- + +# 39. Kurzfassung + +Ashen Realms verwendet ein gemeinsames NPC-Grundmodell. + +NPC-Typen wie Händler oder Questgeber werden **nicht** über Vererbung umgesetzt. + +Stattdessen besitzt ein NPC kombinierbare Funktionen: + +```text +NPC ++ Dialogue ++ Quests ++ Shop ++ Resource Exchange ++ Reputation Conditions ++ Bag Offers ++ Player State +``` + +Ruf bleibt ein separates Charakter-Progressionssystem. + +Persönliche NPC-Beziehung kann später zusätzlich ergänzt werden. + +Eine zentrale Condition Engine verbindet: + +- NPCs +- Dialoge +- Quests +- Shops +- Ruf +- Materialtausch +- Taschen +- Weltfortschritt + +Der zentrale Grundsatz lautet: + +> **NPCs sind datengetriebene Charaktere mit kombinierbaren Interaktionen – keine voneinander getrennten Spezialklassen.** diff --git a/docs/playable-slices/Ashen Realms – Playable Slice 0.6.5_ Renown & Reputation Foundation.md b/docs/playable-slices/Ashen Realms – Playable Slice 0.6.5_ Renown & Reputation Foundation.md new file mode 100644 index 0000000..2a79a4b --- /dev/null +++ b/docs/playable-slices/Ashen Realms – Playable Slice 0.6.5_ Renown & Reputation Foundation.md @@ -0,0 +1,1618 @@ +# Ashen Realms – Playable Slice 0.6.5: Renown & Reputation Foundation + +**Status:** Ready for implementation +**Prerequisite:** Playable Slice 0.6 – Full First Combat +**Scope:** Replace classical XP/Level progression with the foundational Renown, Regional Reputation and trophy-based reward model +**Next Slice:** Updated Playable Slice 0.7 – Complete Verbrannte Straße + +--- + +# 1. Goal + +Playable Slice 0.6.5 replaces the remaining classical RPG progression assumptions before additional content is built on top of them. + +The old model: + +```text +kill monster +→ receive XP +→ receive Silver +→ fill XP bar +→ gain Level +``` + +must no longer be the progression model of Ashen Realms. + +The new model is: + +```text +hunt +→ fight +→ receive equipment / trade goods / trophies +→ collect loot +→ return to civilization +→ turn in or sell loot +→ gain Silver + Regional Reputation +→ unlock better opportunities +→ complete meaningful milestones +→ gain Renown +→ improve equipment +→ overcome stronger content +``` + +The core rule becomes: + +> **Renown opens doors. Equipment provides power.** + +This slice must establish this as the technical foundation before Slice 0.7 adds the complete Verbrannte Straße reward loop. + +--- + +# 2. Design principles + +The implementation must follow these rules. + +## 2.1 No classical XP + +Ashen Realms no longer uses classical experience points. + +Remove or migrate existing concepts such as: + +```text +XP +experience +experiencePoints +experienceReward +XP reward +level-up through XP +``` + +Normal combat must never award global progression points simply because a monster died. + +--- + +## 2.2 Renown is not renamed XP + +Renown represents the character's overall significance and accomplishments in the world. + +Renown comes primarily from meaningful milestones. + +Examples: + +```text +discover important location +complete important quest +reach important regional reputation rank +defeat Elite for first time +defeat regional Boss for first time +complete major story objective +``` + +Repeatedly killing weak monsters must not be an efficient way to increase Renown. + +--- + +## 2.3 Equipment remains the main source of power + +The existing Ashen Realms balance philosophy remains: + +```text +approximately 20% base/global progression +approximately 80% equipment +``` + +Combat Power continues to represent actual combat strength. + +Renown does not replace Combat Power. + +A well-equipped character with lower Renown may be stronger than a poorly equipped character with higher Renown. + +--- + +# 3. Global Renown + +Replace the visible player Level with: + +# Renown + +The first full Vertical Slice is designed around: + +```text +Renown 1–15 +``` + +Instead of the previous Level 1–7 progression. + +Target distribution: + +| Region | Recommended Renown | +|---|---:| +| Aschenfelder | 1–5 | +| Dämmerwald | 5–10 | +| Vergessene Ruinen | 10–15 | + +These ranges are recommendations. + +They are not access requirements. + +A player may enter more dangerous areas early. + +Actual danger remains determined primarily by equipment, Combat Power and enemy strength. + +--- + +# 4. Renown power curve + +Do not increase total character power merely because there are now more progression steps. + +The previous total base-stat progression from approximately: + +```text +100 HP / 6 Attack +``` + +to: + +```text +148 HP / 12 Attack +``` + +should be distributed across Renown 1–15. + +Use the following V1 reference: + +| Renown | Base HP | Base Attack | +|---:|---:|---:| +| 1 | 100 | 6 | +| 2 | 104 | 6 | +| 3 | 107 | 7 | +| 4 | 111 | 7 | +| 5 | 114 | 8 | +| 6 | 118 | 8 | +| 7 | 121 | 9 | +| 8 | 125 | 9 | +| 9 | 128 | 9 | +| 10 | 132 | 10 | +| 11 | 135 | 10 | +| 12 | 139 | 11 | +| 13 | 142 | 11 | +| 14 | 145 | 11 | +| 15 | 148 | 12 | + +These values are balancing references and may later be tuned through playtesting. + +Do not increase equipment power budgets as part of this slice. + +--- + +# 5. Renown milestones + +Renown progression must be milestone-based. + +Create a reusable concept for Renown milestones. + +Conceptually: + +```text +RenownMilestoneDefinition +CharacterRenownMilestone +``` + +Possible definition fields: + +```text +id +key +name +description +renownReward +repeatable +enabled +``` + +Player completion should record: + +```text +characterId +milestoneId +completedAt +timesCompleted +``` + +For normal V1 progression: + +```text +repeatable = false +``` + +for almost all Renown milestones. + +The exact numeric implementation may either: + +A) accumulate Renown points from milestones and derive Renown Rank from thresholds + +or + +B) use milestone progress to advance Renown Rank directly. + +Prefer the simplest design that fits the existing architecture. + +However: + +> Normal monster kills must never directly grant Renown. + +--- + +# 6. Renown progression target for the Vertical Slice + +The content should eventually produce approximately the following progression. + +## Aschenfelder + +```text +Renown 1 +→ Renown 2 +→ Renown 3 +→ Renown 4 +→ Renown 5 +``` + +Possible milestone themes: + +### Renown 1 +Game start. + +### Renown 2 +First meaningful success outside Graufurt. + +Examples: + +```text +first successful hunt +first meaningful trophy returned +Verbrannte Straße established +``` + +### Renown 3 +The player becomes known to the Grenzwacht. + +Examples: + +```text +reach first meaningful Grenzwacht reputation rank +complete important Wachtposten objective +``` + +### Renown 4 +Player proves capable against stronger Aschenfelder threats. + +Examples: + +```text +defeat first Elite +discover Aschengrube +``` + +### Renown 5 +Major Aschenfelder accomplishment. + +Examples: + +```text +defeat Hauptmann der Aschenbande +complete central Aschenfelder progression +``` + +Do not implement all of these content milestones in Slice 0.6.5. + +Implement the system so later slices can define them through data. + +--- + +# 7. Regional Reputation + +Renown and Regional Reputation are separate systems. + +## Renown answers: + +> How far has this character progressed through the world? + +## Regional Reputation answers: + +> How much has this character done for these people? + +Regional Reputation may be farmed through repeatable regional gameplay. + +Renown generally may not. + +--- + +# 8. Reputation factions + +Create a reusable faction/reputation model. + +For the current Vertical Slice plan for at least: + +```text +Grenzwacht +Dämmerjäger +Letzte Wacht +``` + +Only the Grenzwacht needs to be seeded and technically usable in this slice. + +Stable key: + +```text +border-guard +``` + +Possible future keys: + +```text +dusk-hunters +last-watch +``` + +Do not implement the later regions' content yet. + +--- + +# 9. Reputation data model + +Introduce: + +```text +ReputationFaction +CharacterReputation +``` + +Conceptually: + +## ReputationFaction + +```text +id +key +name +description +regionKey +enabled +``` + +## CharacterReputation + +```text +id +characterId +factionId +reputation +createdAt +updatedAt +``` + +Required database constraint: + +```text +UNIQUE(characterId, factionId) +``` + +Reputation must be server-authoritative. + +--- + +# 10. Reputation ranks + +Regional Reputation uses named ranks. + +Use the following V1 baseline: + +| Reputation | Rank | +|---:|---| +| 0 | Stranger | +| 100 | Tolerated | +| 250 | Known | +| 500 | Recognized | +| 800 | Trusted | +| 1200 | Esteemed | + +These thresholds are initial balancing values. + +They may later be adjusted without changing the underlying architecture. + +Internally define stable rank keys, for example: + +```text +STRANGER +TOLERATED +KNOWN +RECOGNIZED +TRUSTED +ESTEEMED +``` + +Do not hardcode reputation rank logic separately in shops, quests and UI. + +Create one authoritative reputation-rank resolver. + +--- + +# 11. ReputationService + +Introduce a dedicated backend service. + +Conceptually: + +```ts +grantReputation( + characterId: string, + factionKey: string, + amount: number, +): Promise +``` + +The result should contain enough information for later UI feedback. + +Example: + +```ts +interface ReputationGrantResult { + factionKey: string; + previousReputation: number; + newReputation: number; + previousRank: string; + newRank: string; + rankChanged: boolean; +} +``` + +Also support reading the player's current reputation. + +Conceptually: + +```ts +getCharacterReputation(characterId: string): Promise +``` + +--- + +# 12. RenownService + +Introduce a dedicated Renown service. + +Conceptually: + +```ts +completeMilestone( + characterId: string, + milestoneKey: string, +): Promise +``` + +The service must verify: + +```text +milestone exists +milestone is enabled +whether it was already completed +whether it is repeatable +reward is server-defined +resulting Renown +resulting Renown rank +``` + +Calling a non-repeatable milestone twice must not grant its reward twice. + +--- + +# 13. Character migration + +Inspect the current Character model. + +If it already contains: + +```text +level +experience +xp +``` + +migrate the player progression model. + +The final Character domain should no longer rely on classical XP. + +At minimum the character must expose: + +```text +Renown +``` + +The exact persistence model may be: + +```text +renown +``` + +or: + +```text +renownPoints +renownRank +``` + +depending on the milestone implementation chosen. + +Do not retain an unused player Level merely for compatibility. + +If existing local development data only contains temporary demo characters, a simple migration such as: + +```text +old Level 1 → Renown 1 +old Level 2 → corresponding Renown baseline +``` + +is sufficient. + +Do not over-engineer migration of disposable development data. + +--- + +# 14. Remove requiredLevel from equipment + +Inspect the existing ItemDefinition. + +Classical: + +```text +requiredLevel +``` + +must no longer act as the standard equipment restriction. + +Core rule: + +> **If the player earns an item, the player may use it.** + +A strong item acquired unusually early may be equipped. + +Reputation later restricts merchant availability. + +It does not restrict an already-owned item's equipment eligibility. + +Remove existing equipment validation based purely on player Level. + +--- + +# 15. Reward model migration + +Inspect existing combat victory and loot logic. + +Old flow: + +```text +Combat WON +→ XP +→ direct Silver +→ item loot +``` + +New flow: + +```text +Combat WON +→ equipment roll +→ trade goods +→ trophies +→ optional milestone event +``` + +No normal combat victory automatically grants: + +```text +XP +Renown +generic Silver +``` + +unless the monster explicitly has a lore-valid direct currency drop. + +That must remain an exception rather than the default system. + +--- + +# 16. Item reward roles + +Extend the item/content model only as much as needed to support: + +```text +EQUIPMENT +TRADE_GOOD +TROPHY +QUEST_ITEM +CONSUMABLE +``` + +Reuse the existing ItemDefinition where practical. + +Do not create separate complex inheritance trees for every loot type. + +At minimum Trade Goods and Trophies must support: + +```text +stacking +quantity +icon +name +description +``` + +The existing inventory model should remain the source of player-owned items. + +--- + +# 17. Trade Goods + +Trade Goods primarily represent economic monster loot. + +Examples for future Slice 0.7: + +```text +Aschenfell +Zähes Fell +stolen goods +animal remains +``` + +They later convert into Silver. + +Trade Goods do not inherently grant Renown. + +Some may optionally be valid Reputation turn-ins if explicitly configured. + +--- + +# 18. Trophies + +Trophies represent proof that the character defeated a relevant enemy. + +Examples: + +```text +Räuberabzeichen +Plündererabzeichen +Elite trophy +Boss trophy +``` + +Trophies may later produce: + +```text +Silver +Regional Reputation +first-time Renown milestone +``` + +These rewards must be defined through content data. + +Not through special-case controller logic. + +--- + +# 19. Turn-in foundation + +Create a reusable content definition for future trophy/material turn-ins. + +Conceptually: + +```text +TurnInDefinition +``` + +Suggested minimal fields: + +```text +id +key +itemDefinitionId +factionId +silverRewardPerItem +reputationRewardPerItem +repeatable +enabled +``` + +Optional later fields may include: + +```text +firstTurnInMilestoneKey +minimumQuantity +maximumQuantity +``` + +Do not add these unless actually needed. + +The important requirement is: + +```text +item +→ faction +→ Silver reward +→ Reputation reward +``` + +is data-driven. + +--- + +# 20. Turn-in service + +Implement the domain service now even if the full NPC merchant UI comes later. + +Conceptually: + +```ts +turnIn( + characterId: string, + turnInKey: string, + quantity: number, +): Promise +``` + +The server must: + +```text +load definition +↓ +verify character inventory quantity +↓ +calculate reward +↓ +remove submitted items +↓ +grant Silver +↓ +grant Regional Reputation +↓ +evaluate optional milestone +↓ +commit transaction +``` + +The operation must be atomic. + +If any step fails: + +```text +no items are lost +no Silver is granted +no Reputation is granted +``` + +--- + +# 21. Minimal Slice 0.6.5 content + +Seed only enough content to prove the architecture. + +## Faction + +```text +Grenzwacht +key: border-guard +``` + +## Trade Good + +```text +Aschenfell +``` + +## Trophy + +```text +Räuberabzeichen +``` + +## Example Turn-In definitions + +Initial development values: + +### Aschenfell + +```text +1 Aschenfell +→ 4 Silver +→ 1 Grenzwacht Reputation +``` + +### Räuberabzeichen + +```text +1 Räuberabzeichen +→ 12 Silver +→ 4 Grenzwacht Reputation +``` + +These values are balancing placeholders. + +Keep them in persisted content/seed data so they can be tuned without modifying business logic. + +--- + +# 22. Loot integration + +Connect the new loot types to the current reward pipeline. + +If the currently implemented Slice 0.5/0.6 enemies already grant XP or direct Silver: + +migrate those rewards now. + +For the minimal current enemy set: + +## Aschenratte + +Should be capable of dropping: + +```text +Aschenfell +``` + +## Straßenräuber + +Should be capable of dropping: + +```text +Räuberabzeichen +``` + +Equipment drops already implemented may remain. + +Do not add the full Slice 0.7 loot tables yet. + +Slice 0.7 will expand this content. + +--- + +# 23. No Grenzmarken + +Do not implement or preserve Grenzmarken as the normal guaranteed-progression currency. + +The role previously intended for Grenzmarken is now primarily handled by: + +```text +Regional Reputation ++ +Silver +``` + +Future targeted merchant progression becomes: + +```text +play content +→ collect trophies +→ gain Reputation + Silver +→ unlock shop offer by Reputation +→ buy with Silver +``` + +instead of: + +```text +play content +→ gain Grenzmarken +→ buy item with Grenzmarken +``` + +Existing Grenzmarken code that has already been implemented should be removed or migrated unless another explicitly documented purpose exists. + +--- + +# 24. Silver + +Silver remains the global normal currency. + +Character Silver persistence may remain unchanged if already implemented correctly. + +The important change is its source. + +Old: + +```text +monster dies +→ automatically add Silver +``` + +New: + +```text +monster dies +→ loot has economic value +→ player later sells / turns in loot +→ receive Silver +``` + +This slice may use the TurnInService to prove Silver gain server-side. + +Do not implement a complete economy. + +--- + +# 25. API + +Expose minimal read APIs for the new progression system. + +Conceptually: + +```http +GET /api/renown +GET /api/reputation +``` + +A combined character endpoint may also expose these values if that matches the existing architecture better. + +For turn-ins: + +```http +POST /api/turn-ins +``` + +Example request: + +```json +{ + "turnInKey": "bandit-insignia-border-guard", + "quantity": 3 +} +``` + +Do not accept: + +```text +silverReward +reputationReward +faction reward +Renown reward +item value +``` + +from the client. + +All reward values come from persisted server content. + +--- + +# 26. Frontend progression display + +Replace visible player Level in the persistent UI with: + +```text +Renown 1 +``` + +The Topbar should expose: + +```text +character name +Renown +HP +Silver +``` + +Do not show all Regional Reputation values permanently in the Topbar. + +Regional Reputation is contextual information. + +--- + +# 27. Reputation UI foundation + +Add a minimal reusable reputation display component. + +Conceptually: + +```text +Grenzwacht +Known +320 / 500 Reputation +``` + +It should support: + +```text +faction name +current rank +current reputation +next threshold +progress visualization +``` + +This component does not need its final dedicated Reputation screen yet. + +It will later be reusable in: + +```text +Character screen +Merchant +NPC dialog +Region information +Quest rewards +``` + +--- + +# 28. Renown UI foundation + +Renown should visibly appear as a character progression value. + +Do not recreate a classical always-filling monster XP bar. + +If progress toward the next Renown rank is shown, the UI should communicate that progress comes from accomplishments. + +Possible presentation: + +```text +Renown 3 + +Progress toward Renown 4: +2 / 3 major accomplishments +``` + +or another milestone-oriented presentation. + +Do not display: + +```text +742 / 1000 XP +``` + +with only a renamed label. + +--- + +# 29. Rank-up feedback + +When Regional Reputation crosses a rank threshold, the API result must expose this. + +The frontend should be capable of later displaying: + +```text +Grenzwacht reputation increased. + +New rank: +Known +``` + +Similarly, when a Renown milestone results in a new Renown rank: + +```text +Renown increased to 3. +``` + +Keep feedback visually appropriate to the existing Ashen Realms UI. + +Do not implement large mobile-game-style reward popups. + +--- + +# 30. Server authority + +The server is authoritative for: + +```text +Renown +completed milestones +Regional Reputation +Reputation ranks +Turn-In definitions +inventory quantities +Silver rewards +Reputation rewards +item removal +merchant eligibility later +``` + +The client only requests actions and displays resulting state. + +--- + +# 31. Transactions + +The following operations must be transactional: + +## Turn-In + +```text +consume items ++ +grant Silver ++ +grant Reputation ++ +optional milestone completion +``` + +## Milestone completion + +A non-repeatable milestone must not grant duplicate Renown due to: + +```text +double click +retry +parallel request +race condition +``` + +Use database constraints and/or transactional validation where appropriate. + +--- + +# 32. Required database migration + +Create proper TypeORM migrations. + +Do not use: + +```text +synchronize: true +``` + +The migration should cover whichever of the following are required by the final implementation: + +```text +Character Renown fields +ReputationFaction +CharacterReputation +Reputation rank content if persisted +RenownMilestoneDefinition +CharacterRenownMilestone +TurnInDefinition +ItemType changes +stackable item support +removal of obsolete XP fields +removal of requiredLevel +``` + +If dropping old fields in the same migration would make development migration unnecessarily risky, use staged migrations. + +Prefer correctness over schema cleverness. + +--- + +# 33. Existing XP migration + +Search the entire repository for: + +```text +xp +XP +experience +experienceReward +levelUp +requiredLevel +playerLevel +characterLevel +``` + +Every occurrence must be reviewed. + +Do not blindly replace: + +```text +level +``` + +because monster/content difficulty levels may still exist internally. + +Player Level progression must disappear. + +Monster Level may remain as internal balancing metadata if useful. + +Visible danger should continue to use: + +```text +Weak +Match +Strong +Very Dangerous +Deadly +``` + +rather than relying only on a number. + +--- + +# 34. Existing Silver reward migration + +Search for: + +```text +silverMin +silverMax +silverReward +grantSilver +``` + +Identify which uses represent: + +```text +direct monster reward +``` + +versus legitimate economy logic. + +Direct generic monster-Silver rewards must be migrated to loot value. + +Do not remove Character Silver itself. + +--- + +# 35. Existing tests + +Migrate obsolete tests such as: + +```text +monster grants XP +character levels after enough XP +item rejected because requiredLevel is too high +combat directly grants normal Silver +``` + +Replace with tests covering the new model. + +--- + +# 36. Required backend tests + +At minimum verify: + +## Renown + +- normal monster kill grants no Renown +- milestone can grant Renown +- non-repeatable milestone cannot reward twice +- Renown rank is calculated correctly +- character base stats use Renown rather than old Level + +## Reputation + +- character starts at 0 Reputation for unknown faction +- Reputation grant persists +- rank resolver returns correct rank +- crossing threshold reports `rankChanged` +- Reputation cannot be authoritatively supplied by client + +## Turn-In + +- valid Turn-In consumes correct item quantity +- valid Turn-In grants configured Silver +- valid Turn-In grants configured Reputation +- insufficient quantity rejects transaction +- failed Turn-In consumes nothing +- failed Turn-In grants nothing +- multi-item quantity reward is calculated server-side + +## Equipment + +- owned equipment can be equipped without Level requirement +- reputation is not required to equip owned loot + +--- + +# 37. Required frontend tests + +Verify at minimum: + +- Topbar displays Renown instead of Level +- no XP value is displayed +- Reputation component renders faction and rank +- Reputation threshold progress renders correctly +- Turn-In request contains only authoritative action input +- rank-change response can be represented in UI state + +--- + +# 38. Existing Combat must remain unchanged + +Do not rewrite Slice 0.6 combat mechanics. + +These must continue to work: + +```text +ATTACK +HEAVY_STRIKE +SHIELD_BASH +DEFEND +POTION +Telegraphing +Interrupt +Combat Log +Enemy Intent +``` + +The new progression system begins primarily after combat resolution. + +Combat itself remains deterministic and server-authoritative. + +--- + +# 39. Explicit non-goals + +Do not implement yet: + +```text +full merchant UI +NPC dialogue integration +quests +bag capacity system +crafting +professions +player trading +auction house +reputation decay +daily reputation caps +daily quests +faction wars +multiple competing factions +discount systems +dynamic prices +prestige Renown +achievements system +complete Dämmerwald Reputation +complete Ruins Reputation +``` + +Do not prematurely implement all 15 Renown ranks as unique content. + +This slice builds the system. + +Later slices provide the content. + +--- + +# 40. Update Slice 0.7 assumptions + +After this slice, Playable Slice 0.7 must no longer implement the following old rewards: + +```text +XP +automatic normal Silver +Grenzmarken +``` + +The Complete Verbrannte Straße loop should instead become: + +```text +hunt +→ fight +→ equipment / Trade Goods / Trophies +→ inventory +→ become stronger through equipment +→ accumulate turn-in loot +``` + +Examples: + +## Aschenratte + +Old: + +```text +4–7 Silver +8 XP +60% Aschenfell +``` + +New direction: + +```text +Aschenfell / Trade Goods +small equipment chance +``` + +## Straßenräuber + +Old: + +```text +9–15 Silver +16 XP +equipment +``` + +New direction: + +```text +Räuberabzeichen +possible Trade Goods +equipment +``` + +## Verkohlter Plünderer + +Do not award: + +```text +35 XP +Grenzmarke +``` + +Use: + +```text +valuable Trophy +better equipment chance +possible future first-kill milestone +``` + +Exact reward quantities belong in the updated Slice 0.7 content definition. + +--- + +# 41. Update future merchant assumptions + +The previously planned Grenzmarken merchant must eventually be migrated. + +Future shop progression should use: + +```text +Silver price ++ +required Regional Reputation rank +``` + +Example: + +```text +Aschenklinge + +Price: +180 Silver + +Requirement: +Grenzwacht – Recognized +``` + +The requirement controls purchasing. + +It does not control equipping an already-owned Aschenklinge. + +--- + +# 42. Architecture rule + +Keep the solution data-driven. + +Do not create code such as: + +```ts +if (item.key === 'bandit-insignia') { + reputation += 4; +} +``` + +Instead: + +```text +ItemDefinition ++ +TurnInDefinition ++ +ReputationFaction +``` + +must define the relationship. + +Likewise Renown milestones should be content definitions rather than scattered controller conditions where practical. + +--- + +# 43. First complete future progression loop + +This slice prepares the game for the eventual Aschenfelder loop: + +```text +Graufurt / Südtor +↓ +travel +↓ +Verbrannte Straße +↓ +hunt +↓ +combat +↓ +collect Trade Goods + Trophies +↓ +continue hunting +↓ +inventory / bag fills +↓ +return to safe location +↓ +turn in hunting haul +↓ +Silver + Grenzwacht Reputation +↓ +higher Reputation rank +↓ +new merchant offer +↓ +buy equipment with Silver +↓ +Combat Power increases +↓ +stronger enemy becomes realistic +↓ +complete major milestone +↓ +Renown increases +``` + +This is the progression identity the architecture must support. + +--- + +# 44. Definition of Done + +Playable Slice 0.6.5 is complete when: + +- classical player XP no longer exists in the active progression flow +- visible player Level has been replaced by Renown +- Renown supports the V1 target range of 1–15 +- existing total base-stat power progression has been redistributed across Renown 1–15 +- normal monster kills do not directly grant Renown +- Renown milestones exist as a reusable server-authoritative concept +- Grenzwacht exists as the first Reputation faction +- Character Reputation is persisted +- Reputation ranks are calculated centrally +- Trade Goods and Trophies can exist as inventory loot +- Turn-In definitions are data-driven +- a Turn-In can atomically consume loot and grant Silver + Reputation +- Aschenfell and Räuberabzeichen prove the new content model +- generic direct XP rewards are removed +- generic direct monster-Silver rewards are removed from currently implemented content +- `requiredLevel` no longer blocks owned equipment +- Grenzmarken are not introduced as the normal Aschenfelder progression currency +- UI displays Renown instead of Level +- UI has a reusable Regional Reputation presentation +- existing Slice 0.6 combat still works +- TypeORM migrations succeed +- backend tests pass +- frontend tests pass +- project-wide search shows no active classical XP progression remaining + +--- + +# 45. Handoff + +After this slice, continue with an **updated**: + +```text +Playable Slice 0.7 – Complete Verbrannte Straße +``` + +Slice 0.7 must build its rewards directly on: + +```text +Equipment +Trade Goods +Trophies +Regional Reputation preparation +Renown milestones +``` + +and must not reintroduce: + +```text +XP +automatic generic Silver rewards +Grenzmarken +``` + +The progression foundation is now: + +> **Renown opens doors. Equipment provides power.** + +> **Drops create excitement. Reputation guarantees long-term progress.** + +> **Monsters provide loot. Civilization gives that loot economic and social value.** \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-20-playable-slice-0.5-first-upgrade.md b/docs/superpowers/plans/2026-08-20-playable-slice-0.5-first-upgrade.md new file mode 100644 index 0000000..c6d0d4a --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-playable-slice-0.5-first-upgrade.md @@ -0,0 +1,3992 @@ +# Playable Slice 0.5 – First Upgrade 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:** Make a looted item equippable so it measurably changes the character's effective combat stats — the first item-driven progression loop (`Straßenräuber besiegen → Räuberklinge erhalten → ausrüsten → stärker im nächsten Kampf`). + +**Architecture:** Reuse the Slice 0.4 `ItemDefinition`/`CharacterItem` entities unchanged. Add one new entity, `CharacterEquipment` (one row per equipped slot per character). Introduce `CharacterStatsService` as the single authoritative source of effective character stats (base stats + equipped item bonuses), and make it the *only* place `CombatService` and `CharactersService` read player power from — replacing the Slice 0.3 `CharacterCombatStatsService` shortcut. Add `EquipmentModule` (equip/replace, `GET/POST /api/equipment`) and `InventoryModule` (`GET /api/inventory`) as new NestJS modules. On the frontend, add an `InventoryStore` (Angular signals, mirroring the existing `WorldStore`/`CombatStore` pattern) and an `/inventory` page with an item grid, a detail/comparison panel, and an equipment overview — wired into the existing side nav and reward screen. + +**Tech Stack:** NestJS + TypeORM + PostgreSQL (`apps/api`), Angular (standalone components, signals, Vitest) (`apps/web`). + +**Spec:** `docs/playable-slices/Ashen Realms – Playable Slice 0.5_ First Upgrade.md` + +## Global Constraints + +- Server is authoritative for: ownership, equipped state, slot validity, level requirement, effective HP/attack/weapon damage/armor, Combat Power, combat snapshot stats (spec §53). Angular never computes these. +- The client sends `characterItemId` to equip — never `itemDefinition.id` (spec §13–14). +- Replacing an equipped item never deletes the previous `CharacterItem` (spec §15). +- Equipment cannot change during an active combat → `CHARACTER_IN_COMBAT` (spec §46). Do **not** add a travel restriction — nothing in the current UI blocks inventory during travel, and the spec explicitly forbids assuming this (spec §47). +- Combat Power (`HP/10 + attack×2 + weaponDamage×2 + armor×1.5`) stays internal — never added to a response DTO or the UI (spec §21). +- Seed changes must stay idempotent: never duplicate the starting sword, never delete an earned item, never reset equipped state (spec §55). +- No sell/merchant/durability/crafting/socketing/drag-and-drop/filters/stash — do not build any of it (spec §4). +- Backend tests: Jest, `apps/api`. Frontend tests: Vitest via Angular's `TestBed`, `apps/web` — use `vi.fn()`/`vi` from `'vitest'`, not `jest.fn()`. +- Follow the established domain-error pattern: one `.errors.ts` file per module, exporting an `HttpException` subclass with a `code` field plus small factory functions (see `apps/api/src/combat/combat.errors.ts`). +- Controllers resolve "the current character" via the `DEMO_CHARACTER_ID` constant from `apps/api/src/demo/demo-character.constants.ts` — there is no auth yet. Follow the existing pattern exactly (see `apps/api/src/travel/travel.controller.ts`). + +--- + +## File Structure + +**Backend — new files:** +- `apps/api/src/database/migrations/1789000000000-CreateEquipment.ts` + `apps/api/src/database/migrations/equipment.migration.spec.ts` +- `apps/api/src/equipment/entities/character-equipment.entity.ts` +- `apps/api/src/equipment/equipment.errors.ts` +- `apps/api/src/equipment/equipment.service.ts` + `.spec.ts` +- `apps/api/src/equipment/equipment.controller.ts` +- `apps/api/src/equipment/equipment.module.ts` +- `apps/api/src/equipment/dto/equip-item.dto.ts` +- `apps/api/src/characters/character-stats.service.ts` + `.spec.ts` +- `apps/api/src/inventory/inventory.service.ts` + `.spec.ts` +- `apps/api/src/inventory/inventory.controller.ts` +- `apps/api/src/inventory/inventory.module.ts` +- `apps/api/src/combat/combat-equipment-integration.spec.ts` + +**Backend — modified files:** +- `apps/api/src/characters/characters.module.ts` (swap `CharacterCombatStatsService` → `CharacterStatsService`) +- `apps/api/src/characters/characters.service.ts` + `.spec.ts` (effective attack/HP) +- `apps/api/src/combat/combat.service.ts` + `.spec.ts`, `apps/api/src/combat/combat.module.ts` (consume `CharacterStatsService`) +- `apps/api/src/app.module.ts` (register `EquipmentModule`, `InventoryModule`) +- `apps/api/src/demo/demo-character.constants.ts` (two stable seed IDs) +- `apps/api/src/database/seeds/vertical-slice.seed.ts` + `.spec.ts` (starting sword as real equipped item) + +**Backend — deleted files:** +- `apps/api/src/characters/character-combat-stats.service.ts` + `.spec.ts` + +**Frontend — new files:** +- `apps/web/src/app/features/inventory/inventory.store.ts` + `.spec.ts` +- `apps/web/src/app/features/inventory/inventory-detail-panel.component.ts` + `.html` + `.scss` + `.spec.ts` +- `apps/web/src/app/features/inventory/inventory-page.component.ts` + `.html` + `.scss` + `.spec.ts` + +**Frontend — modified files:** +- `apps/web/src/app/core/api/game-api.models.ts` (inventory/equipment types) +- `apps/web/src/app/core/api/game-api.service.ts` (3 new HTTP calls) +- `apps/web/src/app/app.routes.ts` (`/inventory` route) +- `apps/web/src/app/layout/side-navigation/side-navigation.component.html` (enable Inventar) +- `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` + `.html` + `.spec.ts` (Inventar öffnen button) + +--- + +## Task 1: `character_equipment` table and entity + +**Files:** +- Create: `apps/api/src/equipment/entities/character-equipment.entity.ts` +- Create: `apps/api/src/database/migrations/1789000000000-CreateEquipment.ts` +- Create: `apps/api/src/database/migrations/equipment.migration.spec.ts` + +**Interfaces:** +- Produces: `CharacterEquipment` entity (`id`, `characterId`, `slot: EquipmentSlot`, `characterItemId`, `createdAt`, `updatedAt`, relations `character`, `characterItem`). Table `character_equipment` with `UNIQUE(character_id, slot)` and `UNIQUE(character_item_id)`. + +- [ ] **Step 1: Write the failing migration-metadata test** + +Create `apps/api/src/database/migrations/equipment.migration.spec.ts` (this project's migration specs assert TypeORM entity metadata rather than running against a live DB — see `encounter-status.migration.spec.ts`): + +```ts +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity'; + +describe('character_equipment schema', () => { + it('stores slot as a non-nullable equipment_slot_enum column', () => { + const metadata = getMetadataArgsStorage(); + const column = metadata.columns.find( + (candidate) => + candidate.target === CharacterEquipment && candidate.propertyName === 'slot', + ); + + expect(column).toBeDefined(); + expect(column?.options.type).toBe('enum'); + expect(column?.options.enumName).toBe('equipment_slot_enum'); + expect(column?.options.nullable).toBeFalsy(); + }); + + it('enforces one equipped item per character per slot', () => { + const metadata = getMetadataArgsStorage(); + const index = metadata.indices.find( + (candidate) => + candidate.target === CharacterEquipment && + candidate.columns?.includes('characterId') && + candidate.columns?.includes('slot'), + ); + + expect(index).toBeDefined(); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); + }); + + it('forbids one CharacterItem from occupying more than one equipment slot', () => { + const metadata = getMetadataArgsStorage(); + const index = metadata.indices.find( + (candidate) => + candidate.target === CharacterEquipment && + candidate.columns?.length === 1 && + candidate.columns?.includes('characterItemId'), + ); + + expect(index).toBeDefined(); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npm run test --workspace=@ashen-realms/api -- equipment.migration.spec.ts` +Expected: FAIL — `Cannot find module '../../equipment/entities/character-equipment.entity'`. + +- [ ] **Step 3: Create the entity** + +```ts +// apps/api/src/equipment/entities/character-equipment.entity.ts +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { EquipmentSlot } from '../../items/equipment-slot.enum'; + +/** + * One equipped item in one slot for one character (spec §12). + * + * `characterItemId` must belong to `characterId` — enforced by + * `EquipmentService`, never by the client (spec §13). + */ +@Entity({ name: 'character_equipment' }) +@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], { + unique: true, +}) +@Index('IDX_character_equipment_character_item', ['characterItemId'], { + unique: true, +}) +export class CharacterEquipment { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ + name: 'slot', + type: 'enum', + enum: EquipmentSlot, + enumName: 'equipment_slot_enum', + }) + slot!: EquipmentSlot; + + @Column({ name: 'character_item_id', type: 'uuid' }) + characterItemId!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @ManyToOne(() => Character, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => CharacterItem, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_item_id' }) + characterItem!: CharacterItem; +} +``` + +- [ ] **Step 4: Run the metadata test again** + +Run: `npm run test --workspace=@ashen-realms/api -- equipment.migration.spec.ts` +Expected: PASS (all 3 assertions). + +- [ ] **Step 5: Write the migration** + +```ts +// apps/api/src/database/migrations/1789000000000-CreateEquipment.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateEquipment1789000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Reuses the "equipment_slot_enum" type created by CreateLootAndRewards. + await queryRunner.query(`CREATE TABLE "character_equipment" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "character_id" uuid NOT NULL, + "slot" "equipment_slot_enum" NOT NULL, + "character_item_id" uuid NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_equipment" PRIMARY KEY ("id"), + CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX "IDX_character_equipment_character_item"', + ); + await queryRunner.query( + 'DROP INDEX "IDX_character_equipment_character_slot"', + ); + await queryRunner.query('DROP TABLE "character_equipment"'); + } +} +``` + +- [ ] **Step 6: Compile the migration** + +Run: `npm run build --workspace=@ashen-realms/api` +Expected: builds cleanly (no TypeScript errors). + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/equipment/entities/character-equipment.entity.ts apps/api/src/database/migrations/1789000000000-CreateEquipment.ts apps/api/src/database/migrations/equipment.migration.spec.ts +git commit -m "feat(api): add character_equipment table and entity" +``` + +--- + +## Task 2: Equipment domain errors + +**Files:** +- Create: `apps/api/src/equipment/equipment.errors.ts` + +**Interfaces:** +- Produces: `EquipmentDomainError`, and factories `characterItemNotFound()`, `itemNotOwned()`, `itemNotEquippable()`, `itemLevelRequirementNotMet()`, `invalidEquipmentSlot()`, `characterInCombat()`, plus re-exported `characterNotFound()`. + +- [ ] **Step 1: Write the file** + +```ts +// apps/api/src/equipment/equipment.errors.ts +import { HttpException, HttpStatus } from '@nestjs/common'; + +export type EquipmentErrorCode = + | 'CHARACTER_ITEM_NOT_FOUND' + | 'ITEM_NOT_OWNED' + | 'ITEM_NOT_EQUIPPABLE' + | 'ITEM_LEVEL_REQUIREMENT_NOT_MET' + | 'INVALID_EQUIPMENT_SLOT' + | 'CHARACTER_IN_COMBAT'; + +export class EquipmentDomainError extends HttpException { + constructor( + public readonly code: EquipmentErrorCode, + status: HttpStatus, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +export function characterItemNotFound(): EquipmentDomainError { + return new EquipmentDomainError( + 'CHARACTER_ITEM_NOT_FOUND', + HttpStatus.NOT_FOUND, + 'This item could not be found.', + ); +} + +export function itemNotOwned(): EquipmentDomainError { + return new EquipmentDomainError( + 'ITEM_NOT_OWNED', + HttpStatus.FORBIDDEN, + 'This item does not belong to the character.', + ); +} + +export function itemNotEquippable(): EquipmentDomainError { + return new EquipmentDomainError( + 'ITEM_NOT_EQUIPPABLE', + HttpStatus.BAD_REQUEST, + 'This item cannot be equipped.', + ); +} + +export function itemLevelRequirementNotMet(): EquipmentDomainError { + return new EquipmentDomainError( + 'ITEM_LEVEL_REQUIREMENT_NOT_MET', + HttpStatus.BAD_REQUEST, + "The character does not meet this item's level requirement.", + ); +} + +// Defensive: slot is always derived from the item definition server-side, so +// this is unreachable in practice (spec §27 still names it explicitly). +export function invalidEquipmentSlot(): EquipmentDomainError { + return new EquipmentDomainError( + 'INVALID_EQUIPMENT_SLOT', + HttpStatus.BAD_REQUEST, + 'This item does not target a valid equipment slot.', + ); +} + +export function characterInCombat(): EquipmentDomainError { + return new EquipmentDomainError( + 'CHARACTER_IN_COMBAT', + HttpStatus.CONFLICT, + 'Equipment cannot be changed during an active combat.', + ); +} + +export { characterNotFound } from '../travel/travel.errors'; +``` + +- [ ] **Step 2: Compile** + +Run: `npm run build --workspace=@ashen-realms/api` +Expected: builds cleanly. + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/equipment/equipment.errors.ts +git commit -m "feat(api): add equipment domain errors" +``` + +--- + +## Task 3: `CharacterStatsService` + +**Files:** +- Create: `apps/api/src/characters/character-stats.service.ts` +- Test: `apps/api/src/characters/character-stats.service.spec.ts` + +**Interfaces:** +- Consumes: `CharacterEquipment` entity (Task 1), `Character` entity. +- Produces: `EffectiveCharacterStats { maxHp, currentHp, attack, weaponDamage, armor, combatPower }` and `CharacterStatsService.calculate(character: Character, scope?: Pick): Promise`. Later tasks (EquipmentService, CombatService, CharactersService) call `calculate()` and read these exact field names. + +- [ ] **Step 1: Write the failing tests (spec §42)** + +```ts +// apps/api/src/characters/character-stats.service.spec.ts +import { DataSource } from 'typeorm'; +import { CharacterStatsService } from './character-stats.service'; +import { Character } from './entities/character.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; + +type EquippedFixture = { + slot: EquipmentSlot; + item: Partial; +}; + +function fakeScope(equipped: EquippedFixture[]): Pick { + const rows = equipped.map((entry) => ({ + slot: entry.slot, + characterItem: { + itemDefinition: { + weaponDamage: 0, + bonusHp: 0, + bonusAttack: 0, + bonusArmor: 0, + ...entry.item, + }, + }, + })); + return { + getRepository: () => ({ find: async () => rows }) as never, + } as unknown as Pick; +} + +function character(overrides: Partial = {}): Character { + return { + id: 'character-1', + baseHp: 100, + baseAttack: 6, + currentHp: 90, + ...overrides, + } as Character; +} + +describe('CharacterStatsService', () => { + const service = new CharacterStatsService({} as DataSource); + + it('derives stats from the starting weapon alone', async () => { + const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]); + + const stats = await service.calculate(character(), scope); + + expect(stats.attack).toBe(6); + expect(stats.weaponDamage).toBe(8); + expect(stats.maxHp).toBe(100); + expect(stats.armor).toBe(0); + }); + + it('applies Räuberklinge\'s weapon damage and bonus attack', async () => { + const scope = fakeScope([ + { slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } }, + ]); + + const stats = await service.calculate(character(), scope); + + expect(stats.attack).toBe(7); + expect(stats.weaponDamage).toBe(11); + }); + + it('sums bonusArmor across multiple equipped armor pieces', async () => { + const scope = fakeScope([ + { slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }, + { slot: EquipmentSlot.CHEST, item: { bonusArmor: 7 } }, + ]); + + const stats = await service.calculate(character(), scope); + + expect(stats.armor).toBe(10); + }); + + it('sums bonusHp across equipped items on top of base HP', async () => { + const scope = fakeScope([ + { slot: EquipmentSlot.HEAD, item: { bonusHp: 5 } }, + { slot: EquipmentSlot.CHEST, item: { bonusHp: 10 } }, + ]); + + const stats = await service.calculate(character(), scope); + + expect(stats.maxHp).toBe(115); + }); + + it('reports weaponDamage as 0 when no weapon is equipped', async () => { + const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]); + + const stats = await service.calculate(character(), scope); + + expect(stats.weaponDamage).toBe(0); + }); + + it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => { + const scope = fakeScope([ + { slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } }, + { slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } }, + ]); + + const stats = await service.calculate(character(), scope); + + // maxHp=105, attack=7, weaponDamage=11, armor=3 + expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5); + }); + + it('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); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/api -- character-stats.service.spec.ts` +Expected: FAIL — `Cannot find module './character-stats.service'`. + +- [ ] **Step 3: Implement the service** + +```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 { Character } from './entities/character.entity'; + +export interface EffectiveCharacterStats { + maxHp: number; + currentHp: number; + attack: number; + weaponDamage: number; + armor: number; + combatPower: number; +} + +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) {} + + 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: character.currentHp, + attack, + weaponDamage, + armor, + combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5, + }; + } +} +``` + +- [ ] **Step 4: Run the tests again** + +Run: `npm run test --workspace=@ashen-realms/api -- character-stats.service.spec.ts` +Expected: PASS (all 7 tests). + +- [ ] **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): add CharacterStatsService as authoritative effective-stat source" +``` + +--- + +## Task 4: Retire `CharacterCombatStatsService`; wire `CharacterStatsService` into `CharactersModule` + +**Files:** +- Modify: `apps/api/src/characters/characters.module.ts` +- Modify: `apps/api/src/characters/characters.service.ts` +- Modify: `apps/api/src/characters/characters.service.spec.ts` +- Delete: `apps/api/src/characters/character-combat-stats.service.ts` +- Delete: `apps/api/src/characters/character-combat-stats.service.spec.ts` + +**Interfaces:** +- Consumes: `CharacterStatsService.calculate()` (Task 3). +- Produces: `CharactersService.getDemoCharacter()` unchanged response shape, but `attack`/`maxHp` are now the *effective* values (spec §17 — remove the Slice 0.3 shortcut). + +- [ ] **Step 1: Update the failing test first** + +Edit `apps/api/src/characters/characters.service.spec.ts` — replace every `new CharactersService(repository)` call with a version that also passes a fake `CharacterStatsService`, and assert it is invoked: + +```ts +import { NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants'; +import { CharacterStatsService } from './character-stats.service'; +import { Character } from './entities/character.entity'; +import { CharactersService } from './characters.service'; + +function fakeCharacterStats( + overrides: Partial<{ maxHp: number; attack: number }> = {}, +): CharacterStatsService { + return { + calculate: jest.fn().mockResolvedValue({ + maxHp: overrides.maxHp ?? 100, + currentHp: 100, + attack: overrides.attack ?? 6, + weaponDamage: 8, + armor: 0, + combatPower: 0, + }), + } as unknown as CharacterStatsService; +} + +describe('CharactersService', () => { + it('returns the demo character with effective attack/HP and its location summary', async () => { + const repository = { + findOne: jest.fn().mockResolvedValue({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + currentHp: 100, + baseHp: 100, + baseAttack: 6, + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + }, + }), + } as unknown as Repository; + const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 }); + const service = new CharactersService(repository, characterStats); + + 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, + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + }, + }); + expect(characterStats.calculate).toHaveBeenCalledWith( + expect.objectContaining({ id: DEMO_CHARACTER_ID }), + ); + }); + + it('exposes the persisted silver so the HUD never has to guess', async () => { + const repository = { + findOne: jest.fn().mockResolvedValue({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 24, + silver: 18, + currentHp: 100, + baseHp: 100, + baseAttack: 6, + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + }, + }), + } as unknown as Repository; + const service = new CharactersService(repository, fakeCharacterStats()); + + await expect(service.getDemoCharacter()).resolves.toEqual( + expect.objectContaining({ experience: 24, silver: 18 }), + ); + }); + + it('reports a missing demo seed as not found', async () => { + const repository = { + findOne: jest.fn().mockResolvedValue(null), + } as unknown as Repository; + const service = new CharactersService(repository, fakeCharacterStats()); + + await expect(service.getDemoCharacter()).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/api -- characters.service.spec.ts` +Expected: FAIL — `CharactersService` constructor only accepts one argument; effective stats not yet used. + +- [ ] **Step 3: Update `CharactersService`** + +```ts +// apps/api/src/characters/characters.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { CharacterStatsService } from './character-stats.service'; +import { Character } from './entities/character.entity'; + +@Injectable() +export class CharactersService { + constructor( + @InjectRepository(Character) + private readonly characters: Repository, + private readonly characterStats: CharacterStatsService, + ) {} + + async getDemoCharacter() { + const character = await this.characters.findOne({ + where: { id: DEMO_CHARACTER_ID }, + relations: { currentLocation: true }, + }); + + if (!character) { + throw new NotFoundException('Demo character has not been seeded'); + } + + const stats = await this.characterStats.calculate(character); + + return { + id: character.id, + name: character.name, + level: character.level, + experience: character.experience, + silver: character.silver, + currentHp: character.currentHp, + maxHp: stats.maxHp, + attack: stats.attack, + currentLocation: { + id: character.currentLocation.id, + key: character.currentLocation.key, + name: character.currentLocation.name, + }, + }; + } +} +``` + +- [ ] **Step 4: Update `CharactersModule`** + +```ts +// apps/api/src/characters/characters.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharacterStatsService } from './character-stats.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], + exports: [CharacterStatsService], +}) +export class CharactersModule {} +``` + +- [ ] **Step 5: Delete the retired service and its spec** + +```bash +git rm apps/api/src/characters/character-combat-stats.service.ts apps/api/src/characters/character-combat-stats.service.spec.ts +``` + +- [ ] **Step 6: Run the full characters test suite** + +Run: `npm run test --workspace=@ashen-realms/api -- characters` +Expected: PASS. + +- [ ] **Step 7: Build** + +Run: `npm run build --workspace=@ashen-realms/api` +Expected: builds cleanly (confirms no other file still imports `CharacterCombatStatsService`; Task 5 fixes `combat.service.ts`, so this build will still fail until Task 5 completes — that's expected and fine at this checkpoint if it fails only on `combat.service.ts`/`combat.module.ts`). + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/characters/characters.module.ts apps/api/src/characters/characters.service.ts apps/api/src/characters/characters.service.spec.ts +git commit -m "feat(api): retire CharacterCombatStatsService; characters/me returns effective stats" +``` + +--- + +## Task 5: Wire `CharacterStatsService` into `CombatService` + +**Files:** +- Modify: `apps/api/src/combat/combat.service.ts` +- Modify: `apps/api/src/combat/combat.module.ts` +- Modify: `apps/api/src/combat/combat.service.spec.ts` + +**Interfaces:** +- Consumes: `CharacterStatsService.calculate(character, manager)` (Task 3), exported by `CharactersModule` (Task 4). +- Produces: `CombatService`'s constructor now takes `CharacterStatsService` instead of `CharacterCombatStatsService`; `startCombat`'s player snapshot is computed via `calculate()` inside the existing transaction. + +- [ ] **Step 1: Update the test fakes first** + +In `apps/api/src/combat/combat.service.spec.ts`: +- Replace the import `import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';` with `import { CharacterStatsService } from '../characters/character-stats.service';`. +- Add this helper near the other `fake*` helpers: + +```ts +function fakeCharacterStats(): CharacterStatsService { + return { + calculate: jest.fn(async (character: Character) => ({ + maxHp: character.baseHp, + currentHp: character.currentHp, + attack: character.baseAttack, + weaponDamage: 8, + armor: 6, + combatPower: 0, + })), + } as unknown as CharacterStatsService; +} +``` + +- In `createService()`, replace `const characterCombatStats = new CharacterCombatStatsService();` with `const characterCombatStats = fakeCharacterStats();` (keep the local variable name — it's passed positionally into `new CombatService(...)` unchanged). +- In the `rewards` describe block, replace each of the four `new CharacterCombatStatsService()` call sites (inline inside `new CombatService(dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), new CharacterCombatStatsService(), rewards)`) with `fakeCharacterStats()`. + +These fakes reproduce the exact old constants (`weaponDamage: 8, armor: 6`) so every existing assertion (e.g. `playerState: { attack: 6, weaponDamage: 8, armor: 6 }`) keeps passing unchanged — this step only swaps *which service* supplies those numbers, not the numbers themselves. + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts` +Expected: FAIL — `Cannot find module '../characters/character-stats.service'` (doesn't exist as an import target yet in this file) or a leftover reference to the deleted `CharacterCombatStatsService` import. + +- [ ] **Step 3: Update `CombatService`** + +In `apps/api/src/combat/combat.service.ts`: +- Replace the import: `import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';` → `import { CharacterStatsService } from '../characters/character-stats.service';`. +- Rename the constructor parameter and its type: + +```ts + constructor( + private readonly dataSource: DataSource, + private readonly travelService: TravelService, + private readonly combatEngine: CombatEngineService, + private readonly characterStats: CharacterStatsService, + private readonly combatRewards: CombatRewardService, + ) {} +``` + +- In `startCombat`, replace the synchronous call with an awaited, transaction-scoped call (this is the only behavioral line-change in this file): + +```ts + const playerStats = await this.characterStats.calculate(character, manager); +``` + +(This line sits where `const playerStats = this.characterCombatStats.getStats(character);` used to be, inside the `this.dataSource.transaction(async (manager) => { ... })` callback — pass `manager`, not `this.dataSource`, so the read is scoped to the same transaction as everything else in `startCombat`.) + +- [ ] **Step 4: Update `CombatModule`** + +In `apps/api/src/combat/combat.module.ts`, `CharactersModule` is already imported and now exports `CharacterStatsService` (Task 4) instead of `CharacterCombatStatsService` — no import-list change needed in this file, since `CombatService` resolves `CharacterStatsService` through Nest's DI via the already-imported `CharactersModule`. + +- [ ] **Step 5: Run the combat test suite** + +Run: `npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts` +Expected: PASS (all existing tests, unchanged assertions). + +- [ ] **Step 6: Full API build** + +Run: `npm run build --workspace=@ashen-realms/api` +Expected: builds cleanly — this confirms no remaining reference to the deleted `CharacterCombatStatsService` anywhere in the codebase. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts +git commit -m "feat(api): combat snapshots now use CharacterStatsService" +``` + +--- + +## Task 6: `EquipmentService`, `EquipmentController`, `EquipmentModule` + +**Files:** +- Create: `apps/api/src/equipment/dto/equip-item.dto.ts` +- Create: `apps/api/src/equipment/equipment.service.ts` +- Test: `apps/api/src/equipment/equipment.service.spec.ts` +- Create: `apps/api/src/equipment/equipment.controller.ts` +- Create: `apps/api/src/equipment/equipment.module.ts` +- Modify: `apps/api/src/app.module.ts` + +**Interfaces:** +- Consumes: `CharacterEquipment` entity (Task 1), equipment errors (Task 2), `CharacterStatsService` (Task 3, exported by `CharactersModule`). +- Produces: `EquipmentResponseDto { slots: Record, stats: { maxHp, attack, weaponDamage, armor } }`, `EquipmentService.getEquipment(characterId)`, `EquipmentService.equip(characterId, characterItemId)`. Routes `GET /api/equipment`, `POST /api/equipment`. Later frontend tasks consume this exact JSON shape. + +- [ ] **Step 1: Write the failing service tests (spec §43)** + +```ts +// apps/api/src/equipment/equipment.service.spec.ts +import { DataSource, EntityManager, EntityTarget } from 'typeorm'; +import { CharacterStatsService } from '../characters/character-stats.service'; +import { Character } from '../characters/entities/character.entity'; +import { CombatStatus } from '../combat/combat-status.enum'; +import { Combat } from '../combat/entities/combat.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; +import { ItemType } from '../items/item-type.enum'; +import { CharacterEquipment } from './entities/character-equipment.entity'; +import { EquipmentDomainError } from './equipment.errors'; +import { EquipmentService } from './equipment.service'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002'; +const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001'; +const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002'; +const BANDIT_HOOD_ITEM_ID = '70000000-0000-4000-8000-000000000003'; + +interface State { + characters: Character[]; + itemDefinitions: ItemDefinition[]; + characterItems: CharacterItem[]; + characterEquipment: CharacterEquipment[]; + combats: Combat[]; +} + +class FakeRepository { + constructor( + private readonly state: State, + private readonly target: EntityTarget, + private readonly dataSource: FakeDataSource, + ) {} + + findOne(options: { + where: Partial; + relations?: Record; + lock?: { mode: string }; + }): Promise { + const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null; + return Promise.resolve(row ? this.withRelations(row, options.relations) : null); + } + + findOneBy(where: Partial): Promise { + return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); + } + + find(options: { where: Partial; relations?: Record }): Promise { + const matched = this.rows().filter((row) => this.matches(row, options.where)); + return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations))); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + if (!entity.id) { + entity.id = this.dataSource.nextId(this.targetName()); + } + const rows = this.rows(); + const index = rows.findIndex((row) => row.id === entity.id); + if (index === -1) { + rows.push(entity); + } else { + rows[index] = entity; + } + return Promise.resolve(entity); + } + + private withRelations(row: T, relations?: Record): T { + if (!relations) { + return row; + } + const copy = { ...row } as T & Record; + if (this.target === CharacterItem && relations['itemDefinition']) { + const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId; + copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId); + } + if (this.target === CharacterEquipment && relations['characterItem']) { + const characterItemId = (row as unknown as CharacterEquipment).characterItemId; + const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId); + copy['characterItem'] = characterItem + ? { + ...characterItem, + itemDefinition: this.state.itemDefinitions.find( + (d) => d.id === characterItem.itemDefinitionId, + ), + } + : undefined; + } + return copy as T; + } + + private rows(): T[] { + if (this.target === Character) return this.state.characters as T[]; + if (this.target === ItemDefinition) return this.state.itemDefinitions as T[]; + if (this.target === CharacterItem) return this.state.characterItems as T[]; + if (this.target === CharacterEquipment) return this.state.characterEquipment as T[]; + if (this.target === Combat) return this.state.combats as T[]; + throw new Error(`Unsupported repository ${this.targetName()}`); + } + + private matches(row: T, where: Partial): boolean { + return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + } + + private targetName(): string { + return typeof this.target === 'function' ? this.target.name : 'EntitySchema'; + } +} + +class FakeDataSource { + private readonly idCounters = new Map(); + constructor(public state: State) {} + + getRepository(target: EntityTarget) { + return new FakeRepository(this.state, target, this); + } + + async transaction(work: (manager: EntityManager) => Promise): Promise { + return work({ + getRepository: (target: EntityTarget) => + this.getRepository(target), + } as unknown as EntityManager); + } + + nextId(targetName: string): string { + const next = (this.idCounters.get(targetName) ?? 0) + 1; + this.idCounters.set(targetName, next); + return `${targetName.toLowerCase()}-generated-${next}`; + } +} + +function itemDefinition(overrides: Partial = {}): ItemDefinition { + return { + id: 'def-worn-sword', + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + description: '', + type: ItemType.WEAPON, + equipmentSlot: EquipmentSlot.WEAPON, + rarity: ItemRarity.COMMON, + tier: 1, + requiredLevel: 1, + weaponDamage: 8, + bonusHp: 0, + bonusAttack: 0, + bonusArmor: 0, + sellPrice: 0, + iconPath: '/images/items/worn-short-sword.png', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as ItemDefinition; +} + +function character(overrides: Partial = {}): Character { + return { + id: CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + ...overrides, + } as Character; +} + +function createHarness(state: Partial = {}) { + const fullState: State = { + characters: [character()], + itemDefinitions: [], + characterItems: [], + characterEquipment: [], + combats: [], + ...state, + }; + const dataSource = new FakeDataSource(fullState); + const characterStats = new CharacterStatsService(dataSource as unknown as DataSource); + const service = new EquipmentService(dataSource as unknown as DataSource, characterStats); + return { state: fullState, service }; +} + +async function expectEquipmentDomainError(promise: Promise, code: string): Promise { + let error: unknown; + try { + await promise; + } catch (cause) { + error = cause; + } + expect(error).toBeInstanceOf(EquipmentDomainError); + if (!(error instanceof EquipmentDomainError)) { + throw new Error('Expected EquipmentDomainError'); + } + expect(error.code).toBe(code); +} + +describe('EquipmentService', () => { + describe('equip', () => { + it('equips an owned weapon into the WEAPON slot', async () => { + const wornSword = itemDefinition(); + const { state, service } = createHarness({ + itemDefinitions: [wornSword], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + ], + }); + + const result = await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID); + + expect(result.slots.WEAPON).toEqual({ + characterItemId: WORN_SWORD_ITEM_ID, + item: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: 'COMMON', + iconPath: '/images/items/worn-short-sword.png', + }, + }); + expect(state.characterEquipment).toHaveLength(1); + }); + + it('replaces the equipped weapon without deleting the old CharacterItem', async () => { + const wornSword = itemDefinition(); + const banditBlade = itemDefinition({ + id: 'def-bandit-blade', + key: 'bandit-blade', + name: 'Räuberklinge', + weaponDamage: 11, + bonusAttack: 1, + iconPath: '/images/items/bandit-blade.png', + }); + const { state, service } = createHarness({ + itemDefinitions: [wornSword, banditBlade], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + { + id: BANDIT_BLADE_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: banditBlade.id, + quantity: 1, + } as CharacterItem, + ], + characterEquipment: [ + { + id: 'equip-1', + characterId: CHARACTER_ID, + slot: EquipmentSlot.WEAPON, + characterItemId: WORN_SWORD_ITEM_ID, + } as CharacterEquipment, + ], + }); + + const result = await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + + expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID); + expect(state.characterEquipment).toHaveLength(1); + expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined(); + }); + + it('rejects equipping an item owned by a different character', async () => { + const wornSword = itemDefinition(); + const { service } = createHarness({ + characters: [character(), character({ id: OTHER_CHARACTER_ID })], + itemDefinitions: [wornSword], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: OTHER_CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + ], + }); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID), + 'ITEM_NOT_OWNED', + ); + }); + + it('rejects equipping an unknown CharacterItem id', async () => { + const { service } = createHarness(); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, 'unknown-item'), + 'CHARACTER_ITEM_NOT_FOUND', + ); + }); + + it('rejects equipping an item above the character level', async () => { + const highLevelHelm = itemDefinition({ + id: BANDIT_HOOD_ITEM_ID, + key: 'bandit-hood', + equipmentSlot: EquipmentSlot.HEAD, + requiredLevel: 5, + }); + const { service } = createHarness({ + itemDefinitions: [highLevelHelm], + characterItems: [ + { + id: BANDIT_HOOD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: highLevelHelm.id, + quantity: 1, + } as CharacterItem, + ], + }); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID), + 'ITEM_LEVEL_REQUIREMENT_NOT_MET', + ); + }); + + it('rejects equipping a non-equippable item', async () => { + const material = itemDefinition({ + id: 'def-ash-pelt', + key: 'ash-pelt', + type: ItemType.MATERIAL, + equipmentSlot: null, + }); + const { service } = createHarness({ + itemDefinitions: [material], + characterItems: [ + { + id: 'item-ash-pelt', + characterId: CHARACTER_ID, + itemDefinitionId: material.id, + quantity: 1, + } as CharacterItem, + ], + }); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, 'item-ash-pelt'), + 'ITEM_NOT_EQUIPPABLE', + ); + }); + + it('never produces two equipped weapons when the same slot is equipped repeatedly', async () => { + const wornSword = itemDefinition(); + const banditBlade = itemDefinition({ + id: 'def-bandit-blade', + key: 'bandit-blade', + weaponDamage: 11, + bonusAttack: 1, + iconPath: '/images/items/bandit-blade.png', + }); + const { state, service } = createHarness({ + itemDefinitions: [wornSword, banditBlade], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + { + id: BANDIT_BLADE_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: banditBlade.id, + quantity: 1, + } as CharacterItem, + ], + }); + + // Sequential repeats stand in for the concurrent case here (a real race + // is guarded by the DB's UNIQUE(character_id, slot) constraint from + // Task 1, which a synchronous fake repository cannot exercise). + await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID); + await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + + expect(state.characterEquipment).toHaveLength(1); + expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID); + }); + + it('rejects equipping while the character has an active combat', async () => { + const wornSword = itemDefinition(); + const { service } = createHarness({ + itemDefinitions: [wornSword], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: wornSword.id, + quantity: 1, + } as CharacterItem, + ], + combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat], + }); + + await expectEquipmentDomainError( + service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID), + 'CHARACTER_IN_COMBAT', + ); + }); + }); + + describe('getEquipment', () => { + it('returns empty slots and base stats when nothing is equipped', async () => { + const { service } = createHarness(); + + const result = await service.getEquipment(CHARACTER_ID); + + expect(result.slots).toEqual({ + WEAPON: null, + HEAD: null, + CHEST: null, + HANDS: null, + LEGS: null, + FEET: null, + AMULET: null, + }); + expect(result.stats).toEqual({ maxHp: 100, attack: 6, weaponDamage: 0, armor: 0 }); + }); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/api -- equipment.service.spec.ts` +Expected: FAIL — `Cannot find module './equipment.service'`. + +- [ ] **Step 3: Write the DTO** + +```ts +// apps/api/src/equipment/dto/equip-item.dto.ts +import { IsUUID } from 'class-validator'; + +export class EquipItemDto { + @IsUUID() + characterItemId!: string; +} +``` + +- [ ] **Step 4: Implement `EquipmentService`** + +```ts +// apps/api/src/equipment/equipment.service.ts +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { CharacterStatsService } from '../characters/character-stats.service'; +import { Character } from '../characters/entities/character.entity'; +import { CombatStatus } from '../combat/combat-status.enum'; +import { Combat } from '../combat/entities/combat.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; +import { CharacterEquipment } from './entities/character-equipment.entity'; +import { + characterInCombat, + characterItemNotFound, + characterNotFound, + itemLevelRequirementNotMet, + itemNotEquippable, + itemNotOwned, +} from './equipment.errors'; + +export interface EquipmentSlotItemDto { + characterItemId: string; + item: { + key: string; + name: string; + rarity: ItemRarity; + iconPath: string; + }; +} + +export type EquipmentSlotsDto = Record; + +export interface EquipmentStatsDto { + maxHp: number; + attack: number; + weaponDamage: number; + armor: number; +} + +export interface EquipmentResponseDto { + slots: EquipmentSlotsDto; + stats: EquipmentStatsDto; +} + +type RepositoryScope = Pick; + +@Injectable() +export class EquipmentService { + constructor( + private readonly dataSource: DataSource, + private readonly characterStats: CharacterStatsService, + ) {} + + async getEquipment(characterId: string): Promise { + const character = await this.dataSource + .getRepository(Character) + .findOneBy({ id: characterId }); + if (!character) { + throw characterNotFound(); + } + return this.buildResponse(character, this.dataSource); + } + + /** + * Equips (or replaces) one slot for `characterId` with `characterItemId` + * (spec §14, §28). Runs in one transaction: the old item is unequipped by + * being overwritten, never deleted (spec §15). + */ + async equip(characterId: string, characterItemId: string): Promise { + return this.dataSource.transaction(async (manager) => { + const characters = manager.getRepository(Character); + const combats = manager.getRepository(Combat); + const characterItems = manager.getRepository(CharacterItem); + const equipmentRepo = manager.getRepository(CharacterEquipment); + + const character = await characters.findOne({ + where: { id: characterId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!character) { + throw characterNotFound(); + } + + const activeCombat = await combats.findOne({ + where: { characterId, status: CombatStatus.ACTIVE }, + }); + if (activeCombat) { + throw characterInCombat(); + } + + const characterItem = await characterItems.findOne({ + where: { id: characterItemId }, + relations: { itemDefinition: true }, + }); + if (!characterItem) { + throw characterItemNotFound(); + } + if (characterItem.characterId !== characterId) { + throw itemNotOwned(); + } + + const definition = characterItem.itemDefinition; + if (!definition.equipmentSlot) { + throw itemNotEquippable(); + } + if (definition.requiredLevel > character.level) { + throw itemLevelRequirementNotMet(); + } + + const existing = await equipmentRepo.findOne({ + where: { characterId, slot: definition.equipmentSlot }, + lock: { mode: 'pessimistic_write' }, + }); + if (existing) { + existing.characterItemId = characterItem.id; + await equipmentRepo.save(existing); + } else { + await equipmentRepo.save( + equipmentRepo.create({ + characterId, + slot: definition.equipmentSlot, + characterItemId: characterItem.id, + }), + ); + } + + return this.buildResponse(character, manager); + }); + } + + private async buildResponse( + character: Character, + scope: RepositoryScope, + ): Promise { + const equipped = await scope.getRepository(CharacterEquipment).find({ + where: { characterId: character.id }, + relations: { characterItem: { itemDefinition: true } }, + }); + + const slots = Object.fromEntries( + Object.values(EquipmentSlot).map((slot) => [slot, null]), + ) as EquipmentSlotsDto; + + for (const row of equipped) { + const definition = row.characterItem.itemDefinition; + slots[row.slot] = { + characterItemId: row.characterItemId, + item: { + key: definition.key, + name: definition.name, + rarity: definition.rarity, + iconPath: definition.iconPath, + }, + }; + } + + const stats = await this.characterStats.calculate(character, scope); + + return { + slots, + stats: { + maxHp: stats.maxHp, + attack: stats.attack, + weaponDamage: stats.weaponDamage, + armor: stats.armor, + }, + }; + } +} +``` + +- [ ] **Step 5: Run the tests again** + +Run: `npm run test --workspace=@ashen-realms/api -- equipment.service.spec.ts` +Expected: PASS (all 10 tests). + +- [ ] **Step 6: Add the controller and module** + +```ts +// apps/api/src/equipment/equipment.controller.ts +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { EquipItemDto } from './dto/equip-item.dto'; +import { EquipmentResponseDto, EquipmentService } from './equipment.service'; + +@Controller('equipment') +export class EquipmentController { + constructor(private readonly equipmentService: EquipmentService) {} + + @Get() + getEquipment(): Promise { + return this.equipmentService.getEquipment(DEMO_CHARACTER_ID); + } + + @Post() + equip(@Body() request: EquipItemDto): Promise { + return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId); + } +} +``` + +```ts +// apps/api/src/equipment/equipment.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharactersModule } from '../characters/characters.module'; +import { Character } from '../characters/entities/character.entity'; +import { Combat } from '../combat/entities/combat.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { CharacterEquipment } from './entities/character-equipment.entity'; +import { EquipmentController } from './equipment.controller'; +import { EquipmentService } from './equipment.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]), + CharactersModule, + ], + controllers: [EquipmentController], + providers: [EquipmentService], + exports: [EquipmentService], +}) +export class EquipmentModule {} +``` + +- [ ] **Step 7: Register `EquipmentModule` in `AppModule`** + +```ts +// apps/api/src/app.module.ts +import { Module } from '@nestjs/common'; +import { CharactersModule } from './characters/characters.module'; +import { CombatModule } from './combat/combat.module'; +import { DatabaseModule } from './database/database.module'; +import { EquipmentModule } from './equipment/equipment.module'; +import { HealthModule } from './health/health.module'; +import { HuntingModule } from './hunting/hunting.module'; +import { TravelModule } from './travel/travel.module'; +import { WorldModule } from './world/world.module'; + +@Module({ + imports: [ + DatabaseModule, + HealthModule, + CharactersModule, + TravelModule, + WorldModule, + HuntingModule, + CombatModule, + EquipmentModule, + ], +}) +export class AppModule {} +``` + +(`InventoryModule` is added to this same list in Task 7.) + +- [ ] **Step 8: Build** + +Run: `npm run build --workspace=@ashen-realms/api` +Expected: builds cleanly. + +- [ ] **Step 9: Commit** + +```bash +git add apps/api/src/equipment apps/api/src/app.module.ts +git commit -m "feat(api): add equipment API (GET/POST /api/equipment)" +``` + +--- + +## Task 7: `InventoryService`, `InventoryController`, `InventoryModule` + +**Files:** +- Create: `apps/api/src/inventory/inventory.service.ts` +- Test: `apps/api/src/inventory/inventory.service.spec.ts` +- Create: `apps/api/src/inventory/inventory.controller.ts` +- Create: `apps/api/src/inventory/inventory.module.ts` +- Modify: `apps/api/src/app.module.ts` + +**Interfaces:** +- Consumes: `CharacterItem`, `CharacterEquipment` (Task 1) via `@InjectRepository`. +- Produces: `InventoryResponseDto { items: InventoryItemDto[] }` where `InventoryItemDto = { id, quantity, equipped, item: { key, name, rarity, equipmentSlot, requiredLevel, weaponDamage, bonusAttack, bonusHp, bonusArmor, iconPath } }`. Route `GET /api/inventory`. + +- [ ] **Step 1: Write the failing tests (spec §44)** + +```ts +// apps/api/src/inventory/inventory.service.spec.ts +import { Repository } from 'typeorm'; +import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; +import { ItemType } from '../items/item-type.enum'; +import { InventoryService } from './inventory.service'; + +const CHARACTER_ID = 'character-1'; + +function characterItem(overrides: Partial = {}): CharacterItem { + return { + id: 'item-1', + characterId: CHARACTER_ID, + itemDefinitionId: 'def-1', + quantity: 1, + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + itemDefinition: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: ItemRarity.COMMON, + type: ItemType.WEAPON, + equipmentSlot: EquipmentSlot.WEAPON, + requiredLevel: 1, + weaponDamage: 8, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/worn-short-sword.png', + }, + ...overrides, + } as CharacterItem; +} + +describe('InventoryService', () => { + it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => { + const items = [ + characterItem({ id: 'item-1', quantity: 1 }), + characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }), + ]; + const characterItems = { + find: jest.fn().mockResolvedValue(items), + } as unknown as Repository; + const equipment = { + find: jest.fn().mockResolvedValue([ + { characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment, + ]), + } as unknown as Repository; + const service = new InventoryService(characterItems, equipment); + + const result = await service.getInventory(CHARACTER_ID); + + expect(characterItems.find).toHaveBeenCalledWith({ + where: { characterId: CHARACTER_ID }, + relations: { itemDefinition: true }, + order: { createdAt: 'ASC' }, + }); + expect(result.items).toEqual([ + { + id: 'item-1', + quantity: 1, + equipped: true, + item: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 8, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/worn-short-sword.png', + }, + }, + { + id: 'item-2', + quantity: 3, + equipped: false, + item: expect.objectContaining({ key: 'worn-short-sword' }), + }, + ]); + }); + + it('returns an empty list when the character owns nothing', async () => { + const characterItems = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + const equipment = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + const service = new InventoryService(characterItems, equipment); + + await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] }); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/api -- inventory.service.spec.ts` +Expected: FAIL — `Cannot find module './inventory.service'`. + +- [ ] **Step 3: Implement `InventoryService`** + +```ts +// apps/api/src/inventory/inventory.service.ts +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; + +export interface InventoryItemDto { + id: string; + quantity: number; + equipped: boolean; + item: { + key: string; + name: string; + rarity: ItemRarity; + equipmentSlot: EquipmentSlot | null; + requiredLevel: number; + weaponDamage: number; + bonusAttack: number; + bonusHp: number; + bonusArmor: number; + iconPath: string; + }; +} + +export interface InventoryResponseDto { + items: InventoryItemDto[]; +} + +@Injectable() +export class InventoryService { + constructor( + @InjectRepository(CharacterItem) + private readonly characterItems: Repository, + @InjectRepository(CharacterEquipment) + private readonly equipment: Repository, + ) {} + + async getInventory(characterId: string): Promise { + const [items, equipped] = await Promise.all([ + this.characterItems.find({ + where: { characterId }, + relations: { itemDefinition: true }, + order: { createdAt: 'ASC' }, + }), + this.equipment.find({ where: { characterId } }), + ]); + const equippedIds = new Set(equipped.map((row) => row.characterItemId)); + + return { + items: items.map((characterItem) => ({ + id: characterItem.id, + quantity: characterItem.quantity, + equipped: equippedIds.has(characterItem.id), + item: { + key: characterItem.itemDefinition.key, + name: characterItem.itemDefinition.name, + rarity: characterItem.itemDefinition.rarity, + equipmentSlot: characterItem.itemDefinition.equipmentSlot, + requiredLevel: characterItem.itemDefinition.requiredLevel, + weaponDamage: characterItem.itemDefinition.weaponDamage, + bonusAttack: characterItem.itemDefinition.bonusAttack, + bonusHp: characterItem.itemDefinition.bonusHp, + bonusArmor: characterItem.itemDefinition.bonusArmor, + iconPath: characterItem.itemDefinition.iconPath, + }, + })), + }; + } +} +``` + +- [ ] **Step 4: Run the tests again** + +Run: `npm run test --workspace=@ashen-realms/api -- inventory.service.spec.ts` +Expected: PASS. + +- [ ] **Step 5: Add the controller and module** + +```ts +// apps/api/src/inventory/inventory.controller.ts +import { Controller, Get } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { InventoryResponseDto, InventoryService } from './inventory.service'; + +@Controller('inventory') +export class InventoryController { + constructor(private readonly inventoryService: InventoryService) {} + + @Get() + getInventory(): Promise { + return this.inventoryService.getInventory(DEMO_CHARACTER_ID); + } +} +``` + +```ts +// apps/api/src/inventory/inventory.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { InventoryController } from './inventory.controller'; +import { InventoryService } from './inventory.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])], + controllers: [InventoryController], + providers: [InventoryService], +}) +export class InventoryModule {} +``` + +- [ ] **Step 6: Register `InventoryModule` in `AppModule`** + +```ts +// apps/api/src/app.module.ts +import { Module } from '@nestjs/common'; +import { CharactersModule } from './characters/characters.module'; +import { CombatModule } from './combat/combat.module'; +import { DatabaseModule } from './database/database.module'; +import { EquipmentModule } from './equipment/equipment.module'; +import { HealthModule } from './health/health.module'; +import { HuntingModule } from './hunting/hunting.module'; +import { InventoryModule } from './inventory/inventory.module'; +import { TravelModule } from './travel/travel.module'; +import { WorldModule } from './world/world.module'; + +@Module({ + imports: [ + DatabaseModule, + HealthModule, + CharactersModule, + TravelModule, + WorldModule, + HuntingModule, + CombatModule, + EquipmentModule, + InventoryModule, + ], +}) +export class AppModule {} +``` + +- [ ] **Step 7: Build** + +Run: `npm run build --workspace=@ashen-realms/api` +Expected: builds cleanly. + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/inventory apps/api/src/app.module.ts +git commit -m "feat(api): add inventory API (GET /api/inventory)" +``` + +--- + +## Task 8: Combat + equipment integration proof (spec §45, §60) + +**Files:** +- Create: `apps/api/src/combat/combat-equipment-integration.spec.ts` + +**Interfaces:** +- Consumes: real `CombatService` (Task 5), real `EquipmentService` (Task 6), real `CharacterStatsService` (Task 3) composed over one shared fake `DataSource`. + +This is the critical proof: equipping Räuberklinge must make a *later* combat deal more damage, not just change a stored number. It composes the real services together (not mocks) the same way `combat.service.spec.ts`'s existing reward tests already compose `CombatService` + `CombatRewardService` over one shared fake `DataSource`. + +- [ ] **Step 1: Write the failing integration test** + +```ts +// apps/api/src/combat/combat-equipment-integration.spec.ts +import { DataSource, EntityManager, EntityTarget } from 'typeorm'; +import { CharacterStatsService } from '../characters/character-stats.service'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; +import { EquipmentService } from '../equipment/equipment.service'; +import { Hunt } from '../hunting/entities/hunt.entity'; +import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; +import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum'; +import { HuntStatus } from '../hunting/hunt-status.enum'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { EquipmentSlot } from '../items/equipment-slot.enum'; +import { ItemRarity } from '../items/item-rarity.enum'; +import { ItemType } from '../items/item-type.enum'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { CombatRewardService } from '../rewards/combat-reward.service'; +import { TravelService } from '../travel/travel.service'; +import { CombatAction } from './combat-action.enum'; +import { CombatEngineService } from './combat-engine.service'; +import { CombatService } from './combat.service'; +import { CombatEvent } from './entities/combat-event.entity'; +import { Combat } from './entities/combat.entity'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const HUNT_ID = '20000000-0000-4000-8000-000000000001'; +const MONSTER_ID = '40000000-0000-4000-8000-000000000001'; +const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001'; +const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002'; +const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001'; +const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002'; + +interface FakeState { + characters: Character[]; + hunts: Hunt[]; + huntEncounters: HuntEncounter[]; + monsters: MonsterDefinition[]; + combats: Combat[]; + combatEvents: CombatEvent[]; + itemDefinitions: ItemDefinition[]; + characterItems: CharacterItem[]; + characterEquipment: CharacterEquipment[]; +} + +class FakeRepository { + constructor( + private readonly state: FakeState, + private readonly target: EntityTarget, + private readonly dataSource: FakeDataSource, + ) {} + + findOne(options: { + where: Partial; + relations?: Record; + lock?: { mode: string }; + }): Promise { + const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null; + return Promise.resolve(row ? this.withRelations(row, options.relations) : null); + } + + findOneBy(where: Partial): Promise { + return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); + } + + find(options: { + where: Partial; + relations?: Record; + order?: Partial>; + }): Promise { + const matched = this.rows().filter((row) => this.matches(row, options.where)); + return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations))); + } + + count(options: { where: Partial }): Promise { + return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + if (!entity.id) { + entity.id = this.dataSource.nextId(this.targetName()); + } + const rows = this.rows(); + const index = rows.findIndex((row) => row.id === entity.id); + if (index === -1) { + rows.push(entity); + } else { + rows[index] = entity; + } + return Promise.resolve(entity); + } + + private withRelations(row: T, relations?: Record): T { + if (!relations) { + return row; + } + const copy = { ...row } as T & Record; + if (this.target === CharacterItem && relations['itemDefinition']) { + const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId; + copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId); + } + if (this.target === CharacterEquipment && relations['characterItem']) { + const characterItemId = (row as unknown as CharacterEquipment).characterItemId; + const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId); + copy['characterItem'] = characterItem + ? { + ...characterItem, + itemDefinition: this.state.itemDefinitions.find( + (d) => d.id === characterItem.itemDefinitionId, + ), + } + : undefined; + } + return copy as T; + } + + private rows(): T[] { + if (this.target === Character) return this.state.characters as T[]; + if (this.target === Hunt) return this.state.hunts as T[]; + if (this.target === HuntEncounter) return this.state.huntEncounters as T[]; + if (this.target === MonsterDefinition) return this.state.monsters as T[]; + if (this.target === Combat) return this.state.combats as T[]; + if (this.target === CombatEvent) return this.state.combatEvents as T[]; + if (this.target === ItemDefinition) return this.state.itemDefinitions as T[]; + if (this.target === CharacterItem) return this.state.characterItems as T[]; + if (this.target === CharacterEquipment) return this.state.characterEquipment as T[]; + throw new Error(`Unsupported repository ${this.targetName()}`); + } + + private matches(row: T, where: Partial): boolean { + return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + } + + private targetName(): string { + return typeof this.target === 'function' ? this.target.name : 'EntitySchema'; + } +} + +class FakeDataSource { + private readonly idCounters = new Map(); + constructor(public state: FakeState) {} + + getRepository(target: EntityTarget) { + return new FakeRepository(this.state, target, this); + } + + async transaction(work: (manager: EntityManager) => Promise): Promise { + return work({ + getRepository: (target: EntityTarget) => + this.getRepository(target), + } as unknown as EntityManager); + } + + nextId(targetName: string): string { + const next = (this.idCounters.get(targetName) ?? 0) + 1; + this.idCounters.set(targetName, next); + return `${targetName.toLowerCase()}-generated-${next}`; + } +} + +function character(overrides: Partial = {}): Character { + return { + id: CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + currentLocationId: 'location-1', + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + ...overrides, + } as Character; +} + +function monster(overrides: Partial = {}): MonsterDefinition { + return { + id: MONSTER_ID, + key: 'road-bandit', + name: 'Straßenräuber', + level: 2, + maxHp: 75, + attack: 9, + armor: 5, + experienceReward: 16, + silverMin: 9, + silverMax: 15, + artworkPath: '/images/monsters/road-bandit.png', + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + ...overrides, + } as MonsterDefinition; +} + +function hunt(overrides: Partial = {}): Hunt { + return { + id: HUNT_ID, + characterId: CHARACTER_ID, + locationId: 'location-1', + status: HuntStatus.ACTIVE, + createdAt: new Date('2026-08-18T09:00:00.000Z'), + ...overrides, + } as Hunt; +} + +function encounter(id: string, overrides: Partial = {}): HuntEncounter { + return { + id, + huntId: HUNT_ID, + monsterDefinitionId: MONSTER_ID, + position: 0, + status: HuntEncounterStatus.AVAILABLE, + createdAt: new Date('2026-08-18T09:00:00.000Z'), + ...overrides, + } as HuntEncounter; +} + +function itemDefinition(overrides: Partial = {}): ItemDefinition { + return { + id: WORN_SWORD_DEFINITION_ID, + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + description: '', + type: ItemType.WEAPON, + equipmentSlot: EquipmentSlot.WEAPON, + rarity: ItemRarity.COMMON, + tier: 1, + requiredLevel: 1, + weaponDamage: 8, + bonusHp: 0, + bonusAttack: 0, + bonusArmor: 0, + sellPrice: 0, + iconPath: '/images/items/worn-short-sword.png', + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + ...overrides, + } as ItemDefinition; +} + +function fakeTravelService(): TravelService { + return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService; +} + +function fakeRewardService(): CombatRewardService { + return { + grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }), + loadRewards: jest.fn().mockResolvedValue(null), + } as unknown as CombatRewardService; +} + +function createHarness() { + const state: FakeState = { + characters: [character()], + hunts: [hunt()], + huntEncounters: [], + monsters: [monster()], + combats: [], + combatEvents: [], + itemDefinitions: [ + itemDefinition(), + itemDefinition({ + id: BANDIT_BLADE_DEFINITION_ID, + key: 'bandit-blade', + name: 'Räuberklinge', + weaponDamage: 11, + bonusAttack: 1, + iconPath: '/images/items/bandit-blade.png', + }), + ], + characterItems: [ + { + id: WORN_SWORD_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: WORN_SWORD_DEFINITION_ID, + quantity: 1, + createdAt: new Date(), + updatedAt: new Date(), + } as CharacterItem, + { + id: BANDIT_BLADE_ITEM_ID, + characterId: CHARACTER_ID, + itemDefinitionId: BANDIT_BLADE_DEFINITION_ID, + quantity: 1, + createdAt: new Date(), + updatedAt: new Date(), + } as CharacterItem, + ], + characterEquipment: [ + { + id: 'equip-1', + characterId: CHARACTER_ID, + slot: EquipmentSlot.WEAPON, + characterItemId: WORN_SWORD_ITEM_ID, + createdAt: new Date(), + updatedAt: new Date(), + } as CharacterEquipment, + ], + }; + const dataSource = new FakeDataSource(state); + const characterStats = new CharacterStatsService(dataSource as unknown as DataSource); + const equipmentService = new EquipmentService(dataSource as unknown as DataSource, characterStats); + const combatService = new CombatService( + dataSource as unknown as DataSource, + fakeTravelService(), + new CombatEngineService(), + characterStats, + fakeRewardService(), + ); + return { state, equipmentService, combatService }; +} + +describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () => { + it('deals more damage against the same monster after the upgrade than before it', async () => { + const { state, combatService, equipmentService } = createHarness(); + + state.huntEncounters.push(encounter('encounter-1')); + const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1'); + const beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK); + const beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp; + + await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + + state.huntEncounters.push(encounter('encounter-2')); + const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2'); + const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK); + const afterDamage = after.monster.maxHp - afterResult.monster.currentHp; + + // (6+8) vs 5 armor -> round(14 * 60/65) = 13 + expect(beforeDamage).toBe(13); + // (7+11) vs 5 armor -> round(18 * 60/65) = 17 + expect(afterDamage).toBe(17); + expect(afterDamage).toBeGreaterThan(beforeDamage); + }); + + it('does not change an already-active combat\'s snapshot when equipment changes mid-fight', async () => { + const { state, combatService, equipmentService } = createHarness(); + state.huntEncounters.push(encounter('encounter-1')); + + const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1'); + await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + const result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK); + + // Still the pre-upgrade damage: the snapshot was taken at startCombat time. + expect(combat.monster.maxHp - result.monster.currentHp).toBe(13); + }); +}); +``` + +- [ ] **Step 2: Run to confirm the test framework wires up correctly, then that it passes** + +Run: `npm run test --workspace=@ashen-realms/api -- combat-equipment-integration.spec.ts` +Expected: PASS (both tests) — if either fails, first check that Tasks 1, 3, 5, and 6 are complete (this test imports all four). + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/combat/combat-equipment-integration.spec.ts +git commit -m "test(api): prove equipping a weapon upgrade increases future combat damage" +``` + +--- + +## Task 9: Starting equipment in the seed (spec §17, §55) + +**Files:** +- Modify: `apps/api/src/demo/demo-character.constants.ts` +- Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts` +- Modify: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts` + +**Interfaces:** +- Produces: two new stable ID constants; the demo character always ends up owning and having equipped `worn-short-sword` via real `CharacterItem`/`CharacterEquipment` rows, idempotently, without ever overwriting a player-earned equip. + +- [ ] **Step 1: Add the stable seed IDs** + +```ts +// apps/api/src/demo/demo-character.constants.ts +export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID = + '10000000-0000-4000-8000-000000000002'; +export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID = + '10000000-0000-4000-8000-000000000003'; +``` + +- [ ] **Step 2: Write the failing seed test** + +Edit `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`: +- Add imports: `CharacterItem` from `'../../items/entities/character-item.entity'` and `CharacterEquipment` from `'../../equipment/entities/character-equipment.entity'`, and `DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID` from `'../../demo/demo-character.constants'`. +- Extend `createDataSource(...)` with two more optional-defaulted parameters and branches: + +```ts +function createDataSource( + locationRepository: InMemoryRepository, + connectionRepository: InMemoryRepository, + characterRepository: InMemoryRepository, + monsterRepository: InMemoryRepository, + locationMonsterRepository: InMemoryRepository, + itemRepository: InMemoryRepository = new InMemoryRepository(), + lootTableRepository: InMemoryRepository = new InMemoryRepository(), + lootEntryRepository: InMemoryRepository = new InMemoryRepository(), + characterItemRepository: InMemoryRepository = new InMemoryRepository(), + characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(), +): DataSource { + return { + getRepository: jest.fn((entity: unknown) => { + if (entity === LocationDefinition) return locationRepository; + if (entity === LocationConnection) return connectionRepository; + if (entity === Character) return characterRepository; + if (entity === MonsterDefinition) return monsterRepository; + if (entity === LocationMonster) return locationMonsterRepository; + if (entity === ItemDefinition) return itemRepository; + if (entity === LootTable) return lootTableRepository; + if (entity === LootTableEntry) return lootEntryRepository; + if (entity === CharacterItem) return characterItemRepository; + if (entity === CharacterEquipment) return characterEquipmentRepository; + + throw new Error('Unexpected repository'); + }), + } as unknown as DataSource; +} +``` + +- Add a new `describe` block at the end of the file (before the final closing of the outer `describe`): + +```ts + it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + expect(characterItemRepository.rows).toHaveLength(1); + expect(characterItemRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + quantity: 1, + }), + ); + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + slot: 'WEAPON', + characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + }), + ); + }); + + it('never re-equips the starting sword once the player has equipped different gear', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + await seedVisibleVerticalSlice(dataSource); + // Simulate the player having equipped earned loot instead. + characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id'; + + await seedVisibleVerticalSlice(dataSource); + + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe( + 'earned-bandit-blade-item-id', + ); + expect(characterItemRepository.rows).toHaveLength(1); + }); +``` + +- [ ] **Step 3: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/api -- vertical-slice.seed.spec.ts` +Expected: FAIL — `characterItemRepository.rows` is empty (seed doesn't create the row yet). + +- [ ] **Step 4: Update the seed** + +In `apps/api/src/database/seeds/vertical-slice.seed.ts`: +- Add imports: `CharacterItem` from `'../../items/entities/character-item.entity'`, `CharacterEquipment` from `'../../equipment/entities/character-equipment.entity'`, `EquipmentSlot` from `'../../items/equipment-slot.enum'`, and `DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID`, `DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID` from `'../../demo/demo-character.constants'` (alongside the existing `DEMO_CHARACTER_ID` import). +- After the existing `if (!existing) { await characterRepository.insert({...}); }` block, unconditionally (so it also backfills a demo character seeded before Slice 0.5), add: + +```ts + const characterItemRepository = dataSource.getRepository(CharacterItem); + const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment); + + const existingStartingSword = await characterItemRepository.findOneBy({ + id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + }); + if (!existingStartingSword) { + await characterItemRepository.insert({ + id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + quantity: 1, + }); + } + + const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({ + characterId: DEMO_CHARACTER_ID, + slot: EquipmentSlot.WEAPON, + }); + if (!existingWeaponEquipment) { + await characterEquipmentRepository.insert({ + id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID, + characterId: DEMO_CHARACTER_ID, + slot: EquipmentSlot.WEAPON, + characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + }); + } +``` + +- [ ] **Step 5: Run the tests again** + +Run: `npm run test --workspace=@ashen-realms/api -- vertical-slice.seed.spec.ts` +Expected: PASS (all tests, including the two new ones). + +- [ ] **Step 6: Run the full API suite and build** + +Run: `npm run test --workspace=@ashen-realms/api && npm run build --workspace=@ashen-realms/api` +Expected: all green, clean build. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/demo/demo-character.constants.ts apps/api/src/database/seeds/vertical-slice.seed.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts +git commit -m "feat(api): seed the starting sword as a real, equipped CharacterItem" +``` + +--- + +## Task 10: Frontend API models and client methods + +**Files:** +- Modify: `apps/web/src/app/core/api/game-api.models.ts` +- Modify: `apps/web/src/app/core/api/game-api.service.ts` + +**Interfaces:** +- Produces: `EquipmentSlot`, `InventoryItem`, `InventoryResponse`, `EquipmentSlotItem`, `EquipmentSlots`, `EquipmentStats`, `EquipmentResponse` types, and `GameApiService.getInventory()`, `getEquipment()`, `equipItem(characterItemId)`. + +- [ ] **Step 1: Add the models** + +Append to `apps/web/src/app/core/api/game-api.models.ts`: + +```ts +export type EquipmentSlot = + | 'WEAPON' + | 'HEAD' + | 'CHEST' + | 'HANDS' + | 'LEGS' + | 'FEET' + | 'AMULET'; + +export interface InventoryItem { + id: string; + quantity: number; + equipped: boolean; + item: { + key: string; + name: string; + rarity: ItemRarity; + equipmentSlot: EquipmentSlot | null; + requiredLevel: number; + weaponDamage: number; + bonusAttack: number; + bonusHp: number; + bonusArmor: number; + iconPath: string; + }; +} + +export interface InventoryResponse { + items: InventoryItem[]; +} + +export interface EquipmentSlotItem { + characterItemId: string; + item: { + key: string; + name: string; + rarity: ItemRarity; + iconPath: string; + }; +} + +export type EquipmentSlots = Record; + +export interface EquipmentStats { + maxHp: number; + attack: number; + weaponDamage: number; + armor: number; +} + +export interface EquipmentResponse { + slots: EquipmentSlots; + stats: EquipmentStats; +} +``` + +- [ ] **Step 2: Add the client methods** + +Edit `apps/web/src/app/core/api/game-api.service.ts` — add the three new imports to the existing `import { ... } from './game-api.models';` block (`EquipmentResponse`, `InventoryResponse`) and append these methods inside the class: + +```ts + getInventory(): Observable { + return this.http.get('/api/inventory'); + } + + getEquipment(): Observable { + return this.http.get('/api/equipment'); + } + + equipItem(characterItemId: string): Observable { + return this.http.post('/api/equipment', { characterItemId }); + } +``` + +- [ ] **Step 3: Build** + +Run: `npm run build --workspace=@ashen-realms/web` +Expected: builds cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/core/api/game-api.service.ts +git commit -m "feat(web): add inventory/equipment API models and client methods" +``` + +--- + +## Task 11: `InventoryStore` + +**Files:** +- Create: `apps/web/src/app/features/inventory/inventory.store.ts` +- Test: `apps/web/src/app/features/inventory/inventory.store.spec.ts` + +**Interfaces:** +- Consumes: `GameApiService.getInventory/getEquipment/equipItem` (Task 10), `WorldStore.refreshCharacter()` (existing). +- Produces: `InventoryStore` with readonly signals `inventory`, `equipment`, `selectedItemId`, `loading`, `equipping`, `error`; methods `load()`, `selectItem(id)`, `selectedItem()`, `equip(characterItemId)`. Task 13's page component and Task 12's detail panel consume these exact names. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/web/src/app/features/inventory/inventory.store.spec.ts +import { TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import type { EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { WorldStore } from '../world/world.store'; +import { InventoryStore } from './inventory.store'; + +const inventory: InventoryResponse = { + items: [ + { + id: 'item-sword', + quantity: 1, + equipped: true, + item: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 8, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/worn-short-sword.png', + }, + }, + { + id: 'item-blade', + quantity: 1, + equipped: false, + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 11, + bonusAttack: 1, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/bandit-blade.png', + }, + }, + ], +}; + +const equipment: EquipmentResponse = { + slots: { + WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } }, + HEAD: null, + CHEST: null, + HANDS: null, + LEGS: null, + FEET: null, + AMULET: null, + }, + stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 }, +}; + +const equippedAfter: EquipmentResponse = { + ...equipment, + slots: { ...equipment.slots, WEAPON: { characterItemId: 'item-blade', item: { key: 'bandit-blade', name: 'Räuberklinge', rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png' } } }, + stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 }, +}; + +describe('InventoryStore', () => { + let api: { + getInventory: ReturnType; + getEquipment: ReturnType; + equipItem: ReturnType; + }; + let worldStore: { refreshCharacter: ReturnType }; + let store: InventoryStore; + + beforeEach(() => { + api = { + getInventory: vi.fn(() => of(inventory)), + getEquipment: vi.fn(() => of(equipment)), + equipItem: vi.fn(() => of(equippedAfter)), + }; + worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; + + TestBed.configureTestingModule({ + providers: [ + InventoryStore, + { provide: GameApiService, useValue: api }, + { provide: WorldStore, useValue: worldStore }, + ], + }); + store = TestBed.inject(InventoryStore); + }); + + it('loads inventory and equipment together', async () => { + await store.load(); + + expect(store.inventory()).toEqual(inventory); + expect(store.equipment()).toEqual(equipment); + }); + + it('selects an item by id', async () => { + await store.load(); + + store.selectItem('item-blade'); + + expect(store.selectedItemId()).toBe('item-blade'); + expect(store.selectedItem()).toEqual(inventory.items[1]); + }); + + it('equips the selected item, refreshes inventory/equipment, and refreshes the character HUD', async () => { + await store.load(); + + await store.equip('item-blade'); + + expect(api.equipItem).toHaveBeenCalledWith('item-blade'); + expect(store.equipment()).toEqual(equippedAfter); + expect(worldStore.refreshCharacter).toHaveBeenCalledOnce(); + }); + + it('surfaces a German message for a known equip error', async () => { + await store.load(); + api.equipItem.mockReturnValue( + throwError(() => new HttpErrorResponse({ error: { code: 'ITEM_LEVEL_REQUIREMENT_NOT_MET' }, status: 400 })), + ); + + await store.equip('item-blade'); + + expect(store.error()).toBe('Du erfüllst die Stufenanforderung nicht.'); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/web -- inventory.store.spec.ts` +Expected: FAIL — `Cannot find module './inventory.store'`. + +- [ ] **Step 3: Implement the store** + +```ts +// apps/web/src/app/features/inventory/inventory.store.ts +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, signal } from '@angular/core'; +import { firstValueFrom, forkJoin } from 'rxjs'; +import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { WorldStore } from '../world/world.store'; + +const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.'; + +// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`. +const EQUIPMENT_ERROR_MESSAGES: Readonly> = { + CHARACTER_ITEM_NOT_FOUND: 'Dieser Gegenstand konnte nicht gefunden werden.', + ITEM_NOT_OWNED: 'Dieser Gegenstand gehört dir nicht.', + ITEM_NOT_EQUIPPABLE: 'Dieser Gegenstand kann nicht ausgerüstet werden.', + ITEM_LEVEL_REQUIREMENT_NOT_MET: 'Du erfüllst die Stufenanforderung nicht.', + INVALID_EQUIPMENT_SLOT: 'Dieser Ausrüstungsplatz ist ungültig.', + CHARACTER_IN_COMBAT: 'Ausrüstung kann während eines Kampfes nicht geändert werden.', +}; + +@Injectable({ providedIn: 'root' }) +export class InventoryStore { + private readonly inventoryState = signal(null); + private readonly equipmentState = signal(null); + private readonly selectedItemIdState = signal(null); + private readonly loadingState = signal(false); + private readonly equippingState = signal(false); + private readonly errorState = signal(null); + + readonly inventory = this.inventoryState.asReadonly(); + readonly equipment = this.equipmentState.asReadonly(); + readonly selectedItemId = this.selectedItemIdState.asReadonly(); + readonly loading = this.loadingState.asReadonly(); + readonly equipping = this.equippingState.asReadonly(); + readonly error = this.errorState.asReadonly(); + + constructor( + private readonly api: GameApiService, + private readonly worldStore: WorldStore, + ) {} + + async load(): Promise { + this.loadingState.set(true); + this.errorState.set(null); + try { + const { inventory, equipment } = await firstValueFrom( + forkJoin({ inventory: this.api.getInventory(), equipment: this.api.getEquipment() }), + ); + this.inventoryState.set(inventory); + this.equipmentState.set(equipment); + } catch (error) { + this.errorState.set(this.toErrorMessage(error)); + } finally { + this.loadingState.set(false); + } + } + + selectItem(characterItemId: string | null): void { + this.selectedItemIdState.set(characterItemId); + } + + selectedItem(): InventoryItem | null { + const id = this.selectedItemIdState(); + if (!id) { + return null; + } + return this.inventoryState()?.items.find((item) => item.id === id) ?? null; + } + + /** Equips an item, then refreshes inventory/equipment and the character HUD (spec §35, §40). */ + async equip(characterItemId: string): Promise { + if (this.equippingState()) { + return; + } + this.equippingState.set(true); + this.errorState.set(null); + try { + const equipment = await firstValueFrom(this.api.equipItem(characterItemId)); + this.equipmentState.set(equipment); + const inventory = await firstValueFrom(this.api.getInventory()); + this.inventoryState.set(inventory); + await this.worldStore.refreshCharacter(); + } catch (error) { + this.errorState.set(this.toErrorMessage(error)); + } finally { + this.equippingState.set(false); + } + } + + private toErrorMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + const code = (error.error as { code?: string } | null)?.code; + return (code && EQUIPMENT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE; + } + return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE; + } +} +``` + +- [ ] **Step 4: Run the tests again** + +Run: `npm run test --workspace=@ashen-realms/web -- inventory.store.spec.ts` +Expected: PASS (all 4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/app/features/inventory/inventory.store.ts apps/web/src/app/features/inventory/inventory.store.spec.ts +git commit -m "feat(web): add InventoryStore" +``` + +--- + +## Task 12: Item detail/comparison panel component + +**Files:** +- Create: `apps/web/src/app/features/inventory/inventory-detail-panel.component.ts` +- Create: `apps/web/src/app/features/inventory/inventory-detail-panel.component.html` +- Create: `apps/web/src/app/features/inventory/inventory-detail-panel.component.scss` +- Test: `apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts` + +**Interfaces:** +- Consumes: `InventoryItem` (Task 10), `RARITY_LABELS` (existing, from `apps/web/src/app/shared/item-card/item-card.component.ts`). +- Produces: `InventoryDetailPanelComponent` with inputs `item`, `equippedItemInSlot`, `characterLevel`, `busy`, and output `equip: OutputEmitterRef`. Task 13 consumes these exact input/output names. + +Deliberately a **separate** component from `ItemCardComponent`: that component's own spec (`item-card.component.spec.ts`) asserts *no* button and *no* "Anlegen" text exist on it — extending it would break that contract for no reason, since it's reused as-is for grid tiles (Task 13). + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import type { InventoryItem } from '../../core/api/game-api.models'; +import { InventoryDetailPanelComponent } from './inventory-detail-panel.component'; + +const wornSword: InventoryItem = { + id: 'item-sword', + quantity: 1, + equipped: true, + item: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 8, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/worn-short-sword.png', + }, +}; + +const banditBlade: InventoryItem = { + id: 'item-blade', + quantity: 1, + equipped: false, + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 11, + bonusAttack: 1, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/bandit-blade.png', + }, +}; + +async function setup(overrides: { + item?: InventoryItem | null; + equippedItemInSlot?: InventoryItem | null; + characterLevel?: number; + busy?: boolean; +}) { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ imports: [InventoryDetailPanelComponent] }).compileComponents(); + const fixture = TestBed.createComponent(InventoryDetailPanelComponent); + fixture.componentRef.setInput('item', overrides.item ?? null); + fixture.componentRef.setInput('equippedItemInSlot', overrides.equippedItemInSlot ?? null); + fixture.componentRef.setInput('characterLevel', overrides.characterLevel ?? 1); + fixture.componentRef.setInput('busy', overrides.busy ?? false); + fixture.detectChanges(); + return fixture; +} + +describe('InventoryDetailPanelComponent', () => { + it('shows a placeholder when nothing is selected', async () => { + const fixture = await setup({ item: null }); + expect((fixture.nativeElement as HTMLElement).querySelector('[data-detail-empty]')).not.toBeNull(); + }); + + it('shows Ausgerüstet for the currently equipped item, with no equip button', async () => { + const fixture = await setup({ item: wornSword }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-detail-equipped]')?.textContent).toContain('Ausgerüstet'); + expect(element.querySelector('[data-detail-equip]')).toBeNull(); + }); + + it('shows the stat comparison against the equipped item in the same slot', async () => { + const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword }); + const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? ''; + + expect(text).toContain('11'); + expect(text).toContain('+3'); + expect(text).toContain('+1'); + }); + + it('shows a disabled Benötigt Stufe X button when the level requirement is not met', async () => { + const fixture = await setup({ item: { ...banditBlade, item: { ...banditBlade.item, requiredLevel: 5 } }, characterLevel: 1 }); + const button = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-equip]'); + + expect(button?.textContent).toContain('Benötigt Stufe 5'); + expect(button?.disabled).toBe(true); + }); + + it('emits equip with the characterItemId when Ausrüsten is clicked', async () => { + const fixture = await setup({ item: banditBlade }); + const emitted: string[] = []; + fixture.componentInstance.equip.subscribe((id: string) => emitted.push(id)); + + (fixture.nativeElement as HTMLElement).querySelector('[data-detail-equip]')?.click(); + + expect(emitted).toEqual(['item-blade']); + }); + + it('disables the equip button while busy', async () => { + const fixture = await setup({ item: banditBlade, busy: true }); + const button = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-equip]'); + + expect(button?.disabled).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/web -- inventory-detail-panel.component.spec.ts` +Expected: FAIL — `Cannot find module './inventory-detail-panel.component'`. + +- [ ] **Step 3: Implement the component** + +```ts +// apps/web/src/app/features/inventory/inventory-detail-panel.component.ts +import { Component, computed, input, output } from '@angular/core'; +import type { EquipmentSlot, 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', +}; + +interface StatRow { + label: string; + value: number; + diff: number | null; +} + +type StatKey = 'weaponDamage' | 'bonusAttack' | 'bonusHp' | 'bonusArmor'; +const STAT_LABELS: ReadonlyArray<{ label: string; key: StatKey }> = [ + { label: 'Waffenschaden', key: 'weaponDamage' }, + { label: 'Angriff', key: 'bonusAttack' }, + { label: 'Leben', key: 'bonusHp' }, + { label: 'Rüstung', key: 'bonusArmor' }, +]; + +/** Selected-item details and equip comparison (spec §32–37). */ +@Component({ + selector: 'app-inventory-detail-panel', + templateUrl: './inventory-detail-panel.component.html', + styleUrl: './inventory-detail-panel.component.scss', +}) +export class InventoryDetailPanelComponent { + readonly item = input(null); + readonly equippedItemInSlot = input(null); + readonly characterLevel = input(1); + readonly busy = input(false); + readonly equip = output(); + + protected readonly rarityLabel = computed(() => { + const item = this.item(); + return item ? RARITY_LABELS[item.item.rarity] : ''; + }); + + protected readonly slotLabel = computed(() => { + const slot = this.item()?.item.equipmentSlot; + return slot ? SLOT_LABELS[slot] : null; + }); + + protected readonly statRows = computed(() => { + const item = this.item(); + if (!item) { + return []; + } + const compareTo = this.equippedItemInSlot(); + const comparable = compareTo && compareTo.id !== item.id ? compareTo.item : null; + + return STAT_LABELS.map(({ label, key }) => ({ + label, + value: item.item[key], + diff: comparable ? item.item[key] - comparable[key] : null, + })).filter((row) => row.value > 0 || (row.diff ?? 0) !== 0); + }); + + protected readonly isEquippable = computed(() => !!this.item()?.item.equipmentSlot); + + protected readonly meetsLevelRequirement = computed(() => { + const item = this.item(); + return item ? item.item.requiredLevel <= this.characterLevel() : true; + }); + + protected onEquip(): void { + const item = this.item(); + if (item) { + this.equip.emit(item.id); + } + } +} +``` + +- [ ] **Step 4: Write the template** + +```html + +@if (item(); as item) { +
+
+ +
+

{{ item.item.name }}

+

{{ rarityLabel() }}

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

{{ slot }} · Stufe {{ item.item.requiredLevel }}

+ } +
+
+ + @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 if (!meetsLevelRequirement()) { + + } @else { + + } +
+
+} @else { +

Wähle einen Gegenstand aus deinem Inventar.

+} +``` + +- [ ] **Step 5: Write the stylesheet** + +```scss +// apps/web/src/app/features/inventory/inventory-detail-panel.component.scss +:host { + 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); +} + +.inventory-detail__header { + display: flex; + gap: var(--ar-space-3); + align-items: center; + margin-block-end: var(--ar-space-3); +} + +.inventory-detail__icon { + inline-size: 4rem; + block-size: 4rem; + padding: var(--ar-space-1); + border: 1px solid var(--ar-border); + background: linear-gradient(180deg, #1b1f22, #0d1012); + 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; +} + +.inventory-detail__rarity { + 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; + 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 { + display: flex; + justify-content: space-between; +} + +.inventory-detail__stat dt { + color: var(--ar-text-muted); +} + +.inventory-detail__stat dd { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; +} + +.inventory-detail__diff--positive { + color: var(--ar-success); +} + +.inventory-detail__diff--negative { + color: var(--ar-danger); +} + +.inventory-detail__actions { + display: flex; + justify-content: center; +} + +.inventory-detail__equipped { + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; +} + +.inventory-detail__note { + color: var(--ar-text-muted); + font-style: italic; +} + +.inventory-detail__equip { + inline-size: 100%; + padding: var(--ar-space-2) var(--ar-space-4); + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-sm); + color: var(--ar-text); + background: linear-gradient(180deg, #263b4b, #17232d); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1rem; +} + +.inventory-detail__equip:hover:not(:disabled) { + border-color: #d6b26b; + background: linear-gradient(180deg, #315067, #1a2c3a); +} + +.inventory-detail__equip:disabled { + border-color: var(--ar-border); + color: var(--ar-text-muted); + background: #1a1c1d; +} + +.inventory-detail__empty { + padding: var(--ar-space-4); + color: var(--ar-text-muted); + font-style: italic; + text-align: center; +} +``` + +- [ ] **Step 6: Run the tests again** + +Run: `npm run test --workspace=@ashen-realms/web -- inventory-detail-panel.component.spec.ts` +Expected: PASS (all 6 tests). + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/features/inventory/inventory-detail-panel.component.ts apps/web/src/app/features/inventory/inventory-detail-panel.component.html apps/web/src/app/features/inventory/inventory-detail-panel.component.scss apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts +git commit -m "feat(web): add inventory item detail/comparison panel" +``` + +--- + +## Task 13: Inventory page (grid + equipment overview + effective stats) + +**Files:** +- Create: `apps/web/src/app/features/inventory/inventory-page.component.ts` +- Create: `apps/web/src/app/features/inventory/inventory-page.component.html` +- Create: `apps/web/src/app/features/inventory/inventory-page.component.scss` +- Test: `apps/web/src/app/features/inventory/inventory-page.component.spec.ts` + +**Interfaces:** +- Consumes: `InventoryStore` (Task 11), `InventoryDetailPanelComponent` (Task 12), `ItemCardComponent` (existing), `WorldStore` (existing, for `character()?.level`). +- Produces: `InventoryPageComponent`, selector `app-inventory-page`. Task 14 routes `/inventory` to it. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/web/src/app/features/inventory/inventory-page.component.spec.ts +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import type { CharacterResponse, EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models'; +import { WorldStore } from '../world/world.store'; +import { InventoryPageComponent } from './inventory-page.component'; +import { InventoryStore } from './inventory.store'; + +const inventory: InventoryResponse = { + items: [ + { + id: 'item-sword', + quantity: 1, + equipped: true, + item: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 8, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/worn-short-sword.png', + }, + }, + { + id: 'item-blade', + quantity: 1, + equipped: false, + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 11, + bonusAttack: 1, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/bandit-blade.png', + }, + }, + ], +}; + +const equipment: EquipmentResponse = { + slots: { + WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } }, + HEAD: null, + CHEST: null, + HANDS: null, + LEGS: null, + FEET: null, + AMULET: null, + }, + stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 }, +}; + +const character: CharacterResponse = { + id: 'character-1', + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + currentHp: 100, + maxHp: 100, + attack: 6, + currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' }, +}; + +async function setup() { + const inventoryStore = { + inventory: signal(inventory), + equipment: signal(equipment), + selectedItemId: signal(null), + loading: signal(false), + equipping: signal(false), + error: signal(null), + load: vi.fn(() => Promise.resolve()), + selectItem: vi.fn(), + selectedItem: vi.fn(() => null), + equip: vi.fn(() => Promise.resolve()), + }; + const worldStore = { character: signal(character) }; + + await TestBed.configureTestingModule({ + imports: [InventoryPageComponent], + providers: [ + { provide: InventoryStore, useValue: inventoryStore }, + { provide: WorldStore, useValue: worldStore }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(InventoryPageComponent); + fixture.detectChanges(); + return { fixture, inventoryStore }; +} + +describe('InventoryPageComponent', () => { + it('loads the inventory on init', async () => { + const { inventoryStore } = await setup(); + expect(inventoryStore.load).toHaveBeenCalledOnce(); + }); + + it('renders one tile per owned item', async () => { + const { fixture } = await setup(); + const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot'); + expect(tiles.length).toBe(2); + }); + + it('marks the equipped item with a badge', async () => { + const { fixture } = await setup(); + expect((fixture.nativeElement as HTMLElement).querySelector('[data-slot-equipped]')).not.toBeNull(); + }); + + it('selects an item when its tile is clicked', async () => { + const { fixture, inventoryStore } = await setup(); + (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot')[1].click(); + + expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade'); + }); + + it('shows the equipment overview with all seven slots and empty ones as Leer', async () => { + const { fixture } = await setup(); + const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? ''; + + expect(text).toContain('Waffe'); + expect(text).toContain('Abgenutztes Kurzschwert'); + expect(text).toContain('Kopf'); + expect(text).toContain('Leer'); + }); + + it('shows the effective stats summary from the equipment response', async () => { + const { fixture } = await setup(); + const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? ''; + + expect(text).toContain('100'); + expect(text).toContain('6'); + expect(text).toContain('8'); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/web -- inventory-page.component.spec.ts` +Expected: FAIL — `Cannot find module './inventory-page.component'`. + +- [ ] **Step 3: Implement the component** + +```ts +// apps/web/src/app/features/inventory/inventory-page.component.ts +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 { WorldStore } from '../world/world.store'; +import { InventoryDetailPanelComponent } from './inventory-detail-panel.component'; +import { InventoryStore } from './inventory.store'; + +const SLOT_ORDER: readonly EquipmentSlot[] = [ + 'WEAPON', + 'HEAD', + 'CHEST', + 'HANDS', + 'LEGS', + 'FEET', + 'AMULET', +]; + +const SLOT_LABELS: Readonly> = { + WEAPON: 'Waffe', + HEAD: 'Kopf', + CHEST: 'Brust', + HANDS: 'Handschuhe', + LEGS: 'Beine', + FEET: 'Stiefel', + AMULET: 'Amulett', +}; + +@Component({ + selector: 'app-inventory-page', + imports: [ItemCardComponent, 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 slotOrder = SLOT_ORDER; + protected readonly slotLabels = SLOT_LABELS; + + protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1); + + protected readonly equippedItemInSelectedSlot = computed(() => { + const selected = this.inventoryStore.selectedItem(); + if (!selected?.item.equipmentSlot) { + return null; + } + return ( + this.inventoryStore.inventory()?.items.find( + (item) => item.equipped && item.item.equipmentSlot === selected.item.equipmentSlot, + ) ?? null + ); + }); + + ngOnInit(): void { + void this.inventoryStore.load(); + } + + protected selectItem(itemId: string): void { + this.inventoryStore.selectItem(itemId); + } + + protected async equipSelected(characterItemId: string): Promise { + await this.inventoryStore.equip(characterItemId); + } + + protected retry(): void { + void this.inventoryStore.load(); + } +} +``` + +- [ ] **Step 4: Write the template** + +```html + +
+ @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.

+ } +
+ + + } + + @if (inventoryStore.error(); as error) { + + } +
+``` + +- [ ] **Step 5: Write the stylesheet** + +```scss +// apps/web/src/app/features/inventory/inventory-page.component.scss +:host { + display: block; +} + +.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; + justify-content: space-between; + padding-block: var(--ar-space-1); + border-block-end: 1px solid rgb(85 74 57 / 0.4); + font-size: var(--ar-font-sm); +} + +.inventory-page__equipment-slot-label { + color: var(--ar-text-muted); +} + +.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 { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; +} + +.inventory-page__notice { + padding: var(--ar-space-4); + color: var(--ar-text-muted); + text-align: center; +} + +.inventory-page__notice--error { + color: var(--ar-danger); +} + +@media (width < 960px) { + .inventory-page { + grid-template-columns: 1fr; + } +} +``` + +- [ ] **Step 6: Run the tests again** + +Run: `npm run test --workspace=@ashen-realms/web -- inventory-page.component.spec.ts` +Expected: PASS (all 6 tests). + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/features/inventory/inventory-page.component.ts apps/web/src/app/features/inventory/inventory-page.component.html apps/web/src/app/features/inventory/inventory-page.component.scss apps/web/src/app/features/inventory/inventory-page.component.spec.ts +git commit -m "feat(web): add inventory page with grid, detail panel, and equipment overview" +``` + +--- + +## Task 14: Route and side-nav wiring + +**Files:** +- Modify: `apps/web/src/app/app.routes.ts` +- Modify: `apps/web/src/app/layout/side-navigation/side-navigation.component.html` + +**Interfaces:** +- Consumes: `InventoryPageComponent` (Task 13). +- Produces: `/inventory` route; enabled "Inventar" nav button. + +- [ ] **Step 1: Register the route** + +```ts +// apps/web/src/app/app.routes.ts +import { Routes } from '@angular/router'; +import { AppShellComponent } from './layout/app-shell/app-shell.component'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'world' }, + { + path: '', + component: AppShellComponent, + children: [ + { + path: 'world', + loadComponent: () => + import('./features/world/world-page.component').then( + (module) => module.WorldPageComponent, + ), + }, + { + path: 'hunt', + loadComponent: () => + import('./features/hunting/hunt-page/hunt-page.component').then( + (module) => module.HuntPageComponent, + ), + }, + { + path: 'combat/:combatId', + loadComponent: () => + import('./features/combat/combat-page/combat-page.component').then( + (module) => module.CombatPageComponent, + ), + }, + { + path: 'inventory', + loadComponent: () => + import('./features/inventory/inventory-page.component').then( + (module) => module.InventoryPageComponent, + ), + }, + ], + }, + { path: '**', redirectTo: 'world' }, +]; +``` + +- [ ] **Step 2: Enable the nav button** + +In `apps/web/src/app/layout/side-navigation/side-navigation.component.html`, replace the disabled Inventar button: + +```html + +``` + +- [ ] **Step 3: Build** + +Run: `npm run build --workspace=@ashen-realms/web` +Expected: builds cleanly. + +- [ ] **Step 4: Manual check** + +Run the dev server (`npm run start --workspace=@ashen-realms/web`, and the API alongside it), open the app in a browser, click "Inventar" in the side nav, and confirm the page loads without a console error. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/app/app.routes.ts apps/web/src/app/layout/side-navigation/side-navigation.component.html +git commit -m "feat(web): route and enable the Inventar nav entry" +``` + +--- + +## Task 15: Reward screen "Inventar öffnen" button + +**Files:** +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.html` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts` + +**Interfaces:** +- Produces: `CombatPageComponent.goToInventory()`. Spec §41: shown only on the victory screen, does not auto-equip anything. + +- [ ] **Step 1: Extend the failing test** + +Add to `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`, next to the existing `'navigates to /hunt from the victory screen'` test: + +```ts + it('navigates to /inventory from the victory screen', async () => { + const fixture = await setup({ ...activeCombat, status: 'WON' }); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('[data-combat-to-inventory]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/inventory']); + }); +``` + +- [ ] **Step 2: Run to confirm failure** + +Run: `npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts` +Expected: FAIL — `data-combat-to-inventory` not found. + +- [ ] **Step 3: Add the method** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.ts`, next to `goToHunt()`: + +```ts + protected goToInventory(): void { + void this.router.navigate(['/inventory']); + } +``` + +- [ ] **Step 4: Add the button** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.html`, inside the `@if (combat.status === 'WON')` block, right after the existing `rewards` section and before the `Zur Jagd` button: + +```html +
+ + +
+``` + +Remove the now-duplicated standalone `Zur Jagd` button that previously sat directly under the rewards section (the one moved into `.outcome__actions` above replaces it — do not leave two). + +- [ ] **Step 5: Add a small style for the two-button row** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.scss`, next to the existing `.outcome__button` rule: + +```scss +.outcome__actions { + display: flex; + gap: var(--ar-space-2); + justify-content: center; + margin-block-start: var(--ar-space-2); +} + +.outcome__actions .outcome__button { + margin-block-start: 0; +} +``` + +- [ ] **Step 6: Run the tests again** + +Run: `npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts` +Expected: PASS (all tests, including the new one and the pre-existing `Zur Jagd` navigation test). + +- [ ] **Step 7: Build** + +Run: `npm run build --workspace=@ashen-realms/web` +Expected: builds cleanly. + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/app/features/combat/combat-page/combat-page.component.ts apps/web/src/app/features/combat/combat-page/combat-page.component.html apps/web/src/app/features/combat/combat-page/combat-page.component.scss apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +git commit -m "feat(web): add Inventar öffnen button to the victory screen" +``` + +--- + +## Task 16: Full-loop verification (spec §56–§60) + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full backend suite** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: all tests pass, including `character-stats.service.spec.ts`, `equipment.service.spec.ts`, `inventory.service.spec.ts`, `combat-equipment-integration.spec.ts`, `vertical-slice.seed.spec.ts`, and the untouched pre-existing suites. + +- [ ] **Step 2: Run the full frontend suite** + +Run: `npm run test --workspace=@ashen-realms/web` +Expected: all tests pass, including the four new inventory spec files and the updated `combat-page.component.spec.ts`. + +- [ ] **Step 3: Build both apps** + +Run: `npm run build --workspace=@ashen-realms/api && npm run build --workspace=@ashen-realms/web` +Expected: both build cleanly. + +- [ ] **Step 4: Run and verify the migration** + +Run: `npm run db:migrate` +Expected: `CreateEquipment1789000000000` applies cleanly on top of the existing schema. + +- [ ] **Step 5: Run the seed and verify idempotency** + +Run: `npm run db:seed` twice in a row. +Expected: second run makes no destructive changes — demo character still has exactly one `worn-short-sword` `CharacterItem` and one `WEAPON` `CharacterEquipment` row (or whatever the player has since equipped, if this is a shared dev DB with prior play). + +- [ ] **Step 6: Manual browser walkthrough** + +With the API and web dev servers running, walk the full loop from spec §56/§57: +- Open `/inventory` fresh: see `Abgenutztes Kurzschwert` owned and marked `Ausgerüstet` in the `WEAPON` slot. +- Travel to Verbrannte Straße, hunt, fight a `Straßenräuber` until `Räuberklinge` drops. +- On the victory screen, click `Inventar öffnen`. +- Select `Räuberklinge`; confirm the comparison shows `11 Waffenschaden (+3)` and `+1 Angriff` (or `(+1)` depending on layout) against the equipped sword. +- Click `Ausrüsten`; confirm `Räuberklinge` becomes `Ausgerüstet`, `Abgenutztes Kurzschwert` becomes selectable/unequipped, and the equipment-overview stats update. +- Refresh the page; confirm `Räuberklinge` is still shown as equipped. +- Start a new hunt/combat; confirm the player deals visibly more damage than before the upgrade. +- Verify error cases surface as in-shell messages (no browser `alert`): attempt equipping while an active combat exists (`CHARACTER_IN_COMBAT`), and confirm the equip button/flow is blocked or errors cleanly rather than throwing an unhandled exception. + +- [ ] **Step 7: Report completion** + +Once every check above passes, Playable Slice 0.5 satisfies its Definition of Done (spec §56) and acceptance criteria (spec §58). No commit needed for this task — it is verification of the prior 15 commits.