From 2cbb7cb2356dd177295a78fc41c3ef39eac2f885 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:08:51 +0200 Subject: [PATCH 01/20] docs: add Playable Slice 0.5 implementation plan --- ...-08-20-playable-slice-0.5-first-upgrade.md | 3992 +++++++++++++++++ 1 file changed, 3992 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-20-playable-slice-0.5-first-upgrade.md 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. From 59e88bcf9c863e14f6e20e7021cbb50e46500726 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:13:19 +0200 Subject: [PATCH 02/20] feat(api): add character_equipment table and entity --- .../1789000000000-CreateEquipment.ts | 34 +++++++++++ .../migrations/equipment.migration.spec.ts | 52 ++++++++++++++++ .../entities/character-equipment.entity.ts | 59 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 apps/api/src/database/migrations/1789000000000-CreateEquipment.ts create mode 100644 apps/api/src/database/migrations/equipment.migration.spec.ts create mode 100644 apps/api/src/equipment/entities/character-equipment.entity.ts diff --git a/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts b/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts new file mode 100644 index 0000000..424bea7 --- /dev/null +++ b/apps/api/src/database/migrations/1789000000000-CreateEquipment.ts @@ -0,0 +1,34 @@ +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"'); + } +} diff --git a/apps/api/src/database/migrations/equipment.migration.spec.ts b/apps/api/src/database/migrations/equipment.migration.spec.ts new file mode 100644 index 0000000..fdd59c7 --- /dev/null +++ b/apps/api/src/database/migrations/equipment.migration.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity'; + +describe('character_equipment schema', () => { + it('stores slot as a non-nullable equipment_slot_enum column', () => { + const metadata = getMetadataArgsStorage(); + const column = metadata.columns.find( + (candidate) => + candidate.target === CharacterEquipment && candidate.propertyName === 'slot', + ); + + expect(column).toBeDefined(); + expect(column?.options.type).toBe('enum'); + expect(column?.options.enumName).toBe('equipment_slot_enum'); + expect(column?.options.nullable).toBeFalsy(); + }); + + it('enforces one equipped item per character per slot', () => { + const metadata = getMetadataArgsStorage(); + const index = metadata.indices.find( + (candidate) => + candidate.target === CharacterEquipment && + candidate.columns?.includes('characterId') && + candidate.columns?.includes('slot'), + ); + + expect(index).toBeDefined(); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); + }); + + it('forbids one CharacterItem from occupying more than one equipment slot', () => { + const metadata = getMetadataArgsStorage(); + const index = metadata.indices.find( + (candidate) => + candidate.target === CharacterEquipment && + candidate.columns?.length === 1 && + candidate.columns?.includes('characterItemId'), + ); + + expect(index).toBeDefined(); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); + }); +}); diff --git a/apps/api/src/equipment/entities/character-equipment.entity.ts b/apps/api/src/equipment/entities/character-equipment.entity.ts new file mode 100644 index 0000000..ef0ae07 --- /dev/null +++ b/apps/api/src/equipment/entities/character-equipment.entity.ts @@ -0,0 +1,59 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { EquipmentSlot } from '../../items/equipment-slot.enum'; + +/** + * One equipped item in one slot for one character (spec §12). + * + * `characterItemId` must belong to `characterId` — enforced by + * `EquipmentService`, never by the client (spec §13). + */ +@Entity({ name: 'character_equipment' }) +@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], { + unique: true, +}) +@Index('IDX_character_equipment_character_item', ['characterItemId'], { + unique: true, +}) +export class CharacterEquipment { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ + name: 'slot', + type: 'enum', + enum: EquipmentSlot, + enumName: 'equipment_slot_enum', + }) + slot!: EquipmentSlot; + + @Column({ name: 'character_item_id', type: 'uuid' }) + characterItemId!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @ManyToOne(() => Character, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => CharacterItem, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_item_id' }) + characterItem!: CharacterItem; +} From 8debb6f5508a31f432079340453137a07b01b79f Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:16:44 +0200 Subject: [PATCH 03/20] feat(api): add equipment domain errors --- apps/api/src/equipment/equipment.errors.ts | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 apps/api/src/equipment/equipment.errors.ts diff --git a/apps/api/src/equipment/equipment.errors.ts b/apps/api/src/equipment/equipment.errors.ts new file mode 100644 index 0000000..2165172 --- /dev/null +++ b/apps/api/src/equipment/equipment.errors.ts @@ -0,0 +1,71 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export type EquipmentErrorCode = + | 'CHARACTER_ITEM_NOT_FOUND' + | 'ITEM_NOT_OWNED' + | 'ITEM_NOT_EQUIPPABLE' + | 'ITEM_LEVEL_REQUIREMENT_NOT_MET' + | 'INVALID_EQUIPMENT_SLOT' + | 'CHARACTER_IN_COMBAT'; + +export class EquipmentDomainError extends HttpException { + constructor( + public readonly code: EquipmentErrorCode, + status: HttpStatus, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +export function characterItemNotFound(): EquipmentDomainError { + return new EquipmentDomainError( + 'CHARACTER_ITEM_NOT_FOUND', + HttpStatus.NOT_FOUND, + 'This item could not be found.', + ); +} + +export function itemNotOwned(): EquipmentDomainError { + return new EquipmentDomainError( + 'ITEM_NOT_OWNED', + HttpStatus.FORBIDDEN, + 'This item does not belong to the character.', + ); +} + +export function itemNotEquippable(): EquipmentDomainError { + return new EquipmentDomainError( + 'ITEM_NOT_EQUIPPABLE', + HttpStatus.BAD_REQUEST, + 'This item cannot be equipped.', + ); +} + +export function itemLevelRequirementNotMet(): EquipmentDomainError { + return new EquipmentDomainError( + 'ITEM_LEVEL_REQUIREMENT_NOT_MET', + HttpStatus.BAD_REQUEST, + "The character does not meet this item's level requirement.", + ); +} + +// Defensive: slot is always derived from the item definition server-side, so +// this is unreachable in practice (spec §27 still names it explicitly). +export function invalidEquipmentSlot(): EquipmentDomainError { + return new EquipmentDomainError( + 'INVALID_EQUIPMENT_SLOT', + HttpStatus.BAD_REQUEST, + 'This item does not target a valid equipment slot.', + ); +} + +export function characterInCombat(): EquipmentDomainError { + return new EquipmentDomainError( + 'CHARACTER_IN_COMBAT', + HttpStatus.CONFLICT, + 'Equipment cannot be changed during an active combat.', + ); +} + +export { characterNotFound } from '../travel/travel.errors'; From 67c29e783ff7d30900a04f9d1af1fdb843be5e25 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:20:44 +0200 Subject: [PATCH 04/20] feat(api): add CharacterStatsService as authoritative effective-stat source --- .../character-stats.service.spec.ts | 115 ++++++++++++++++++ .../src/characters/character-stats.service.ts | 64 ++++++++++ 2 files changed, 179 insertions(+) create mode 100644 apps/api/src/characters/character-stats.service.spec.ts create mode 100644 apps/api/src/characters/character-stats.service.ts diff --git a/apps/api/src/characters/character-stats.service.spec.ts b/apps/api/src/characters/character-stats.service.spec.ts new file mode 100644 index 0000000..45f8417 --- /dev/null +++ b/apps/api/src/characters/character-stats.service.spec.ts @@ -0,0 +1,115 @@ +// 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); + }); +}); diff --git a/apps/api/src/characters/character-stats.service.ts b/apps/api/src/characters/character-stats.service.ts new file mode 100644 index 0000000..5d0e0a1 --- /dev/null +++ b/apps/api/src/characters/character-stats.service.ts @@ -0,0 +1,64 @@ +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, + }; + } +} From e5746dec5c66d55368dfcb6fdb22af54693c7594 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:26:33 +0200 Subject: [PATCH 05/20] feat(api): retire CharacterCombatStatsService; characters/me returns effective stats --- .../character-combat-stats.service.spec.ts | 16 -------- .../character-combat-stats.service.ts | 28 ------------- apps/api/src/characters/characters.module.ts | 6 +-- .../src/characters/characters.service.spec.ts | 40 +++++++++++++------ apps/api/src/characters/characters.service.ts | 8 +++- 5 files changed, 37 insertions(+), 61 deletions(-) delete mode 100644 apps/api/src/characters/character-combat-stats.service.spec.ts delete mode 100644 apps/api/src/characters/character-combat-stats.service.ts diff --git a/apps/api/src/characters/character-combat-stats.service.spec.ts b/apps/api/src/characters/character-combat-stats.service.spec.ts deleted file mode 100644 index 2cb2279..0000000 --- a/apps/api/src/characters/character-combat-stats.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CharacterCombatStatsService } from './character-combat-stats.service'; -import { Character } from './entities/character.entity'; - -describe('CharacterCombatStatsService', () => { - it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => { - const service = new CharacterCombatStatsService(); - const character = { baseHp: 100, baseAttack: 6 } as Character; - - expect(service.getStats(character)).toEqual({ - maxHp: 100, - attack: 6, - weaponDamage: 8, - armor: 6, - }); - }); -}); diff --git a/apps/api/src/characters/character-combat-stats.service.ts b/apps/api/src/characters/character-combat-stats.service.ts deleted file mode 100644 index 665d893..0000000 --- a/apps/api/src/characters/character-combat-stats.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Character } from './entities/character.entity'; - -export interface CharacterCombatStats { - maxHp: number; - attack: number; - weaponDamage: number; - armor: number; -} - -// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants -// stand in for the starting weapon/armor until Slice 0.5 introduces real -// equipment. Replacing them there must not change this method's signature -// or the combat API it feeds (spec §10). -const TEMPORARY_WEAPON_DAMAGE = 8; -const TEMPORARY_ARMOR = 6; - -@Injectable() -export class CharacterCombatStatsService { - getStats(character: Character): CharacterCombatStats { - return { - maxHp: character.baseHp, - attack: character.baseAttack, - weaponDamage: TEMPORARY_WEAPON_DAMAGE, - armor: TEMPORARY_ARMOR, - }; - } -} diff --git a/apps/api/src/characters/characters.module.ts b/apps/api/src/characters/characters.module.ts index f8a46c1..af53989 100644 --- a/apps/api/src/characters/characters.module.ts +++ b/apps/api/src/characters/characters.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { CharacterCombatStatsService } from './character-combat-stats.service'; +import { CharacterStatsService } from './character-stats.service'; import { CharactersController } from './characters.controller'; import { CharactersService } from './characters.service'; import { Character } from './entities/character.entity'; @@ -8,7 +8,7 @@ import { Character } from './entities/character.entity'; @Module({ imports: [TypeOrmModule.forFeature([Character])], controllers: [CharactersController], - providers: [CharactersService, CharacterCombatStatsService], - exports: [CharacterCombatStatsService], + providers: [CharactersService, CharacterStatsService], + exports: [CharacterStatsService], }) export class CharactersModule {} diff --git a/apps/api/src/characters/characters.service.spec.ts b/apps/api/src/characters/characters.service.spec.ts index 63940ed..0cddc32 100644 --- a/apps/api/src/characters/characters.service.spec.ts +++ b/apps/api/src/characters/characters.service.spec.ts @@ -2,11 +2,27 @@ 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 its current location summary', async () => { + it('returns the demo character with effective attack/HP and its location summary', async () => { const repository = { findOne: jest.fn().mockResolvedValue({ id: DEMO_CHARACTER_ID, @@ -20,11 +36,12 @@ describe('CharactersService', () => { currentLocation: { id: SOUTH_GATE_ID, key: 'south-gate', - name: 'S\u00fcdtor von Graufurt', + name: 'Südtor von Graufurt', }, }), } as unknown as Repository; - const service = new CharactersService(repository); + const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 }); + const service = new CharactersService(repository, characterStats); await expect(service.getDemoCharacter()).resolves.toEqual({ id: DEMO_CHARACTER_ID, @@ -33,18 +50,17 @@ describe('CharactersService', () => { experience: 0, silver: 0, currentHp: 100, - maxHp: 100, - attack: 6, + maxHp: 115, + attack: 7, currentLocation: { id: SOUTH_GATE_ID, key: 'south-gate', - name: 'S\u00fcdtor von Graufurt', + name: 'Südtor von Graufurt', }, }); - expect(repository.findOne).toHaveBeenCalledWith({ - where: { id: DEMO_CHARACTER_ID }, - relations: { currentLocation: true }, - }); + expect(characterStats.calculate).toHaveBeenCalledWith( + expect.objectContaining({ id: DEMO_CHARACTER_ID }), + ); }); it('exposes the persisted silver so the HUD never has to guess', async () => { @@ -65,7 +81,7 @@ describe('CharactersService', () => { }, }), } as unknown as Repository; - const service = new CharactersService(repository); + const service = new CharactersService(repository, fakeCharacterStats()); await expect(service.getDemoCharacter()).resolves.toEqual( expect.objectContaining({ experience: 24, silver: 18 }), @@ -76,7 +92,7 @@ describe('CharactersService', () => { const repository = { findOne: jest.fn().mockResolvedValue(null), } as unknown as Repository; - const service = new CharactersService(repository); + const service = new CharactersService(repository, fakeCharacterStats()); await expect(service.getDemoCharacter()).rejects.toBeInstanceOf( NotFoundException, diff --git a/apps/api/src/characters/characters.service.ts b/apps/api/src/characters/characters.service.ts index ce5a099..10ed4dc 100644 --- a/apps/api/src/characters/characters.service.ts +++ b/apps/api/src/characters/characters.service.ts @@ -2,6 +2,7 @@ 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() @@ -9,6 +10,7 @@ export class CharactersService { constructor( @InjectRepository(Character) private readonly characters: Repository, + private readonly characterStats: CharacterStatsService, ) {} async getDemoCharacter() { @@ -21,6 +23,8 @@ export class CharactersService { throw new NotFoundException('Demo character has not been seeded'); } + const stats = await this.characterStats.calculate(character); + return { id: character.id, name: character.name, @@ -28,8 +32,8 @@ export class CharactersService { experience: character.experience, silver: character.silver, currentHp: character.currentHp, - maxHp: character.baseHp, - attack: character.baseAttack, + maxHp: stats.maxHp, + attack: stats.attack, currentLocation: { id: character.currentLocation.id, key: character.currentLocation.key, From f45f4b03cb44e905f4ed53698956907ae99dcd2f Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:32:15 +0200 Subject: [PATCH 06/20] feat(api): combat snapshots now use CharacterStatsService --- apps/api/src/combat/combat.service.spec.ts | 25 ++++++++++++++++------ apps/api/src/combat/combat.service.ts | 6 +++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 5fed426..ffef68b 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -1,5 +1,5 @@ import { DataSource, EntityManager, EntityTarget } from 'typeorm'; -import { CharacterCombatStatsService } from '../characters/character-combat-stats.service'; +import { CharacterStatsService } from '../characters/character-stats.service'; import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; @@ -242,6 +242,19 @@ function createState(overrides: Partial = {}): FakeState { }; } +function fakeCharacterStats(): CharacterStatsService { + return { + calculate: jest.fn(async (character: Character) => ({ + maxHp: character.baseHp, + currentHp: character.currentHp, + attack: character.baseAttack, + weaponDamage: 8, + armor: 6, + combatPower: 0, + })), + } as unknown as CharacterStatsService; +} + function fakeTravelService( status: 'IDLE' | 'TRAVELLING' = 'IDLE', ): TravelService { @@ -271,7 +284,7 @@ function createService( const dataSource = new FakeDataSource(state); const travelService = options.travelService ?? fakeTravelService(); const combatEngine = new CombatEngineService(); - const characterCombatStats = new CharacterCombatStatsService(); + const characterCombatStats = fakeCharacterStats(); const service = new CombatService( dataSource as unknown as DataSource, travelService, @@ -785,7 +798,7 @@ describe('CombatService', () => { dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), - new CharacterCombatStatsService(), + fakeCharacterStats(), rewards, ); @@ -833,7 +846,7 @@ describe('CombatService', () => { dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), - new CharacterCombatStatsService(), + fakeCharacterStats(), rewards, ); @@ -890,7 +903,7 @@ describe('CombatService', () => { dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), - new CharacterCombatStatsService(), + fakeCharacterStats(), rewards, ); @@ -927,7 +940,7 @@ describe('CombatService', () => { dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), - new CharacterCombatStatsService(), + fakeCharacterStats(), fakeRewardService({ // Genuinely write XP/silver through the transaction's manager // before failing, so the assertions below prove the rollback diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index ae16bc5..425f804 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; -import { CharacterCombatStatsService } from '../characters/character-combat-stats.service'; +import { CharacterStatsService } from '../characters/character-stats.service'; import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; @@ -69,7 +69,7 @@ export class CombatService { private readonly dataSource: DataSource, private readonly travelService: TravelService, private readonly combatEngine: CombatEngineService, - private readonly characterCombatStats: CharacterCombatStatsService, + private readonly characterStats: CharacterStatsService, private readonly combatRewards: CombatRewardService, ) {} @@ -126,7 +126,7 @@ export class CombatService { throw invalidHuntEncounter(); } - const playerStats = this.characterCombatStats.getStats(character); + const playerStats = await this.characterStats.calculate(character, manager); const combat = combats.create({ characterId, From 9df740e7d4aa7b3f84e2b1a4f2693b88002d2d36 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:39:23 +0200 Subject: [PATCH 07/20] feat(api): add equipment API (GET/POST /api/equipment) --- apps/api/src/app.module.ts | 2 + apps/api/src/equipment/dto/equip-item.dto.ts | 6 + .../api/src/equipment/equipment.controller.ts | 19 + apps/api/src/equipment/equipment.module.ts | 21 + .../src/equipment/equipment.service.spec.ts | 431 ++++++++++++++++++ apps/api/src/equipment/equipment.service.ts | 168 +++++++ 6 files changed, 647 insertions(+) create mode 100644 apps/api/src/equipment/dto/equip-item.dto.ts create mode 100644 apps/api/src/equipment/equipment.controller.ts create mode 100644 apps/api/src/equipment/equipment.module.ts create mode 100644 apps/api/src/equipment/equipment.service.spec.ts create mode 100644 apps/api/src/equipment/equipment.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c8be7e0..46bf13b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,6 +2,7 @@ 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'; @@ -16,6 +17,7 @@ import { WorldModule } from './world/world.module'; WorldModule, HuntingModule, CombatModule, + EquipmentModule, ], }) export class AppModule {} diff --git a/apps/api/src/equipment/dto/equip-item.dto.ts b/apps/api/src/equipment/dto/equip-item.dto.ts new file mode 100644 index 0000000..69e2530 --- /dev/null +++ b/apps/api/src/equipment/dto/equip-item.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class EquipItemDto { + @IsUUID() + characterItemId!: string; +} diff --git a/apps/api/src/equipment/equipment.controller.ts b/apps/api/src/equipment/equipment.controller.ts new file mode 100644 index 0000000..1a42a90 --- /dev/null +++ b/apps/api/src/equipment/equipment.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { EquipItemDto } from './dto/equip-item.dto'; +import { EquipmentResponseDto, EquipmentService } from './equipment.service'; + +@Controller('equipment') +export class EquipmentController { + constructor(private readonly equipmentService: EquipmentService) {} + + @Get() + getEquipment(): Promise { + return this.equipmentService.getEquipment(DEMO_CHARACTER_ID); + } + + @Post() + equip(@Body() request: EquipItemDto): Promise { + return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId); + } +} diff --git a/apps/api/src/equipment/equipment.module.ts b/apps/api/src/equipment/equipment.module.ts new file mode 100644 index 0000000..48aafe5 --- /dev/null +++ b/apps/api/src/equipment/equipment.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharactersModule } from '../characters/characters.module'; +import { Character } from '../characters/entities/character.entity'; +import { Combat } from '../combat/entities/combat.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { CharacterEquipment } from './entities/character-equipment.entity'; +import { EquipmentController } from './equipment.controller'; +import { EquipmentService } from './equipment.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]), + CharactersModule, + ], + controllers: [EquipmentController], + providers: [EquipmentService], + exports: [EquipmentService], +}) +export class EquipmentModule {} diff --git a/apps/api/src/equipment/equipment.service.spec.ts b/apps/api/src/equipment/equipment.service.spec.ts new file mode 100644 index 0000000..5536a9d --- /dev/null +++ b/apps/api/src/equipment/equipment.service.spec.ts @@ -0,0 +1,431 @@ +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 }); + }); + }); +}); diff --git a/apps/api/src/equipment/equipment.service.ts b/apps/api/src/equipment/equipment.service.ts new file mode 100644 index 0000000..5fd094a --- /dev/null +++ b/apps/api/src/equipment/equipment.service.ts @@ -0,0 +1,168 @@ +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, + }, + }; + } +} From 820c69704f0b8438e6e8ecccc55228ea289e248c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 16:50:50 +0200 Subject: [PATCH 08/20] feat(api): add inventory API (GET /api/inventory) --- apps/api/src/app.module.ts | 2 + .../api/src/inventory/inventory.controller.ts | 13 +++ apps/api/src/inventory/inventory.module.ts | 13 +++ .../src/inventory/inventory.service.spec.ts | 97 +++++++++++++++++++ apps/api/src/inventory/inventory.service.ts | 71 ++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 apps/api/src/inventory/inventory.controller.ts create mode 100644 apps/api/src/inventory/inventory.module.ts create mode 100644 apps/api/src/inventory/inventory.service.spec.ts create mode 100644 apps/api/src/inventory/inventory.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 46bf13b..79d5f80 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ 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'; @@ -18,6 +19,7 @@ import { WorldModule } from './world/world.module'; HuntingModule, CombatModule, EquipmentModule, + InventoryModule, ], }) export class AppModule {} diff --git a/apps/api/src/inventory/inventory.controller.ts b/apps/api/src/inventory/inventory.controller.ts new file mode 100644 index 0000000..781e91c --- /dev/null +++ b/apps/api/src/inventory/inventory.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { InventoryResponseDto, InventoryService } from './inventory.service'; + +@Controller('inventory') +export class InventoryController { + constructor(private readonly inventoryService: InventoryService) {} + + @Get() + getInventory(): Promise { + return this.inventoryService.getInventory(DEMO_CHARACTER_ID); + } +} diff --git a/apps/api/src/inventory/inventory.module.ts b/apps/api/src/inventory/inventory.module.ts new file mode 100644 index 0000000..5092c22 --- /dev/null +++ b/apps/api/src/inventory/inventory.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { InventoryController } from './inventory.controller'; +import { InventoryService } from './inventory.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])], + controllers: [InventoryController], + providers: [InventoryService], +}) +export class InventoryModule {} diff --git a/apps/api/src/inventory/inventory.service.spec.ts b/apps/api/src/inventory/inventory.service.spec.ts new file mode 100644 index 0000000..2d39c00 --- /dev/null +++ b/apps/api/src/inventory/inventory.service.spec.ts @@ -0,0 +1,97 @@ +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: [] }); + }); +}); diff --git a/apps/api/src/inventory/inventory.service.ts b/apps/api/src/inventory/inventory.service.ts new file mode 100644 index 0000000..3eb9a3f --- /dev/null +++ b/apps/api/src/inventory/inventory.service.ts @@ -0,0 +1,71 @@ +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, + }, + })), + }; + } +} From 2859a00597635ba435e0c6e8d638bb939e436de3 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:01:45 +0200 Subject: [PATCH 09/20] test(api): prove equipping a weapon upgrade increases future combat damage --- .../combat-equipment-integration.spec.ts | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 apps/api/src/combat/combat-equipment-integration.spec.ts diff --git a/apps/api/src/combat/combat-equipment-integration.spec.ts b/apps/api/src/combat/combat-equipment-integration.spec.ts new file mode 100644 index 0000000..5c35545 --- /dev/null +++ b/apps/api/src/combat/combat-equipment-integration.spec.ts @@ -0,0 +1,374 @@ +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'); + let beforeDamage = 0; + let beforeResult = before; + for (let round = 0; round < 10 && beforeResult.status === 'ACTIVE'; round += 1) { + beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK); + if (round === 0) { + beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp; + } + } + // Equipment cannot change during an active combat (spec §46), so this + // first fight must be resolved to completion before equipping. + expect(beforeResult.status).not.toBe('ACTIVE'); + + await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + + state.huntEncounters.push(encounter('encounter-2')); + const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2'); + const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK); + const afterDamage = after.monster.maxHp - afterResult.monster.currentHp; + + // (6+8) vs 5 armor -> round(14 * 60/65) = 13 + expect(beforeDamage).toBe(13); + // (7+11) vs 5 armor -> round(18 * 60/65) = 17 + expect(afterDamage).toBe(17); + expect(afterDamage).toBeGreaterThan(beforeDamage); + }); + + it('rejects equipping during an active combat, and never retroactively rewrites a finished combat\'s snapshot', async () => { + const { state, combatService, equipmentService } = createHarness(); + state.huntEncounters.push(encounter('encounter-1')); + + const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1'); + + // Equipment cannot change while this combat is ACTIVE (spec §46). + await expect( + equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID), + ).rejects.toMatchObject({ code: 'CHARACTER_IN_COMBAT' }); + + // Resolve the fight, then equip — the already-finished combat's snapshot + // (status/round) must stay exactly what it was when the fight ended. + let result = combat; + for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) { + result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK); + } + expect(result.status).not.toBe('ACTIVE'); + + await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + + const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id); + expect(reloaded.status).toBe(result.status); + expect(reloaded.round).toBe(result.round); + }); +}); From 6178b88573c1f16c4ef843bca37365ab5b3573d9 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:11:18 +0200 Subject: [PATCH 10/20] feat(api): seed the starting sword as a real, equipped CharacterItem --- .../seeds/vertical-slice.seed.spec.ts | 83 +++++++++++++++++++ .../src/database/seeds/vertical-slice.seed.ts | 38 ++++++++- apps/api/src/demo/demo-character.constants.ts | 4 + 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index c24015f..df32221 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; import { Character } from '../../characters/entities/character.entity'; +import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTable } from '../../loot/entities/loot-table.entity'; @@ -7,6 +9,7 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; @@ -74,6 +77,8 @@ function createDataSource( 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) => { @@ -85,6 +90,8 @@ function createDataSource( 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'); }), @@ -336,4 +343,80 @@ describe('seedVisibleVerticalSlice', () => { ]), ); }); + + it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + expect(characterItemRepository.rows).toHaveLength(1); + expect(characterItemRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + quantity: 1, + }), + ); + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + slot: 'WEAPON', + characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + }), + ); + }); + + it('never re-equips the starting sword once the player has equipped different gear', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + await seedVisibleVerticalSlice(dataSource); + // Simulate the player having equipped earned loot instead. + characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id'; + + await seedVisibleVerticalSlice(dataSource); + + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe( + 'earned-bandit-blade-item-id', + ); + expect(characterItemRepository.rows).toHaveLength(1); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 92add28..8af98bc 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -1,6 +1,13 @@ import { DataSource } from 'typeorm'; -import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants'; +import { + DEMO_CHARACTER_ID, + DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID, + DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, +} from '../../demo/demo-character.constants'; import { Character } from '../../characters/entities/character.entity'; +import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { EquipmentSlot } from '../../items/equipment-slot.enum'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTable } from '../../loot/entities/loot-table.entity'; @@ -11,6 +18,7 @@ import { LocationConnection } from '../../world/entities/location-connection.ent import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { ASH_RAT_LOOT_TABLE_ID, + ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID, } from './item.constants'; import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content'; @@ -205,4 +213,32 @@ export async function seedVisibleVerticalSlice( currentLocationId: southGateId, }); } + + 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, + }); + } } diff --git a/apps/api/src/demo/demo-character.constants.ts b/apps/api/src/demo/demo-character.constants.ts index e79635b..f09bf6e 100644 --- a/apps/api/src/demo/demo-character.constants.ts +++ b/apps/api/src/demo/demo-character.constants.ts @@ -1 +1,5 @@ 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'; From 90094ba1ae4319c8efb2c2993d58176b66750317 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:23:45 +0200 Subject: [PATCH 11/20] fix(api): key starting-sword seed idempotency on its real unique index The CharacterItem idempotency check was looking up by the seed's own literal id instead of the (characterId, itemDefinitionId) unique index that CharacterItem actually enforces. If the demo character had already looted a worn-short-sword naturally, re-running the seed would miss that row and try to insert a colliding duplicate, breaking seeding instead of being a safe no-op. Look up by the real domain key and reuse whatever id is found when wiring up the CharacterEquipment row. --- .../seeds/vertical-slice.seed.spec.ts | 45 +++++++++++++++++++ .../src/database/seeds/vertical-slice.seed.ts | 7 ++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index df32221..102f561 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -419,4 +419,49 @@ describe('seedVisibleVerticalSlice', () => { ); expect(characterItemRepository.rows).toHaveLength(1); }); + + it('reuses a naturally-looted starting sword instead of inserting a duplicate CharacterItem', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const characterItemRepository = new InMemoryRepository(); + const characterEquipmentRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + characterItemRepository, + characterEquipmentRepository, + ); + + // Simulate the demo character having already looted a worn-short-sword + // naturally, under a DB-generated id that differs from the seed's + // stable literal constant. + const naturallyLootedItemId = 'naturally-looted-sword-item-id'; + characterItemRepository.rows.push({ + id: naturallyLootedItemId, + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], + quantity: 1, + }); + + await seedVisibleVerticalSlice(dataSource); + + expect(characterItemRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows).toHaveLength(1); + expect(characterEquipmentRepository.rows[0]).toEqual( + expect.objectContaining({ + characterId: DEMO_CHARACTER_ID, + slot: 'WEAPON', + characterItemId: naturallyLootedItemId, + }), + ); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 8af98bc..febeb07 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -218,8 +218,11 @@ export async function seedVisibleVerticalSlice( const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment); const existingStartingSword = await characterItemRepository.findOneBy({ - id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + characterId: DEMO_CHARACTER_ID, + itemDefinitionId: ITEM_IDS['worn-short-sword'], }); + const startingSwordItemId = + existingStartingSword?.id ?? DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID; if (!existingStartingSword) { await characterItemRepository.insert({ id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, @@ -238,7 +241,7 @@ export async function seedVisibleVerticalSlice( id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID, characterId: DEMO_CHARACTER_ID, slot: EquipmentSlot.WEAPON, - characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID, + characterItemId: startingSwordItemId, }); } } From c994a4c46f9248838126f4dd9bb93b6c7bb0e123 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:27:15 +0200 Subject: [PATCH 12/20] feat(web): add inventory/equipment API models and client methods --- apps/web/src/app/core/api/game-api.models.ts | 55 +++++++++++++++++++ apps/web/src/app/core/api/game-api.service.ts | 14 +++++ 2 files changed, 69 insertions(+) diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index f4ad3e7..a0b1387 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -132,3 +132,58 @@ export interface CombatReward { silver: number; items: CombatRewardItem[]; } + +export type EquipmentSlot = + | 'WEAPON' + | 'HEAD' + | 'CHEST' + | 'HANDS' + | 'LEGS' + | 'FEET' + | 'AMULET'; + +export interface InventoryItem { + id: string; + quantity: number; + equipped: boolean; + item: { + key: string; + name: string; + 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; +} diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index 66c8b56..cf8af06 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -7,7 +7,9 @@ import { CombatAction, CurrentLocationResponse, CurrentTravel, + EquipmentResponse, HuntResult, + InventoryResponse, } from './game-api.models'; @Injectable({ providedIn: 'root' }) @@ -53,4 +55,16 @@ export class GameApiService { performCombatAction(combatId: string, action: CombatAction): Observable { return this.http.post(`/api/combats/${combatId}/actions`, { action }); } + + 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 }); + } } From 10dd83846591f25841189d09c9132cf532dab2ec Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:32:52 +0200 Subject: [PATCH 13/20] feat(web): add InventoryStore --- .../inventory/inventory.store.spec.ts | 131 ++++++++++++++++++ .../app/features/inventory/inventory.store.ts | 96 +++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 apps/web/src/app/features/inventory/inventory.store.spec.ts create mode 100644 apps/web/src/app/features/inventory/inventory.store.ts diff --git a/apps/web/src/app/features/inventory/inventory.store.spec.ts b/apps/web/src/app/features/inventory/inventory.store.spec.ts new file mode 100644 index 0000000..1cc1205 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory.store.spec.ts @@ -0,0 +1,131 @@ +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.'); + }); +}); diff --git a/apps/web/src/app/features/inventory/inventory.store.ts b/apps/web/src/app/features/inventory/inventory.store.ts new file mode 100644 index 0000000..143569e --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory.store.ts @@ -0,0 +1,96 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, signal } from '@angular/core'; +import { firstValueFrom, forkJoin } from 'rxjs'; +import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { WorldStore } from '../world/world.store'; + +const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.'; + +// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`. +const EQUIPMENT_ERROR_MESSAGES: Readonly> = { + 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; + } +} From 973d4e3ab40290f1fec9d7424b3f95172a7507b2 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:39:46 +0200 Subject: [PATCH 14/20] feat(web): add inventory item detail/comparison panel --- .../inventory-detail-panel.component.html | 60 +++++++++ .../inventory-detail-panel.component.scss | 124 ++++++++++++++++++ .../inventory-detail-panel.component.spec.ts | 106 +++++++++++++++ .../inventory-detail-panel.component.ts | 80 +++++++++++ 4 files changed, 370 insertions(+) create mode 100644 apps/web/src/app/features/inventory/inventory-detail-panel.component.html create mode 100644 apps/web/src/app/features/inventory/inventory-detail-panel.component.scss create mode 100644 apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts create mode 100644 apps/web/src/app/features/inventory/inventory-detail-panel.component.ts diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.html b/apps/web/src/app/features/inventory/inventory-detail-panel.component.html new file mode 100644 index 0000000..e981664 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.html @@ -0,0 +1,60 @@ +@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.

+} diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss b/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss new file mode 100644 index 0000000..49fb865 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss @@ -0,0 +1,124 @@ +: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; +} diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts b/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts new file mode 100644 index 0000000..109e9e6 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts @@ -0,0 +1,106 @@ +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); + }); +}); diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts b/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts new file mode 100644 index 0000000..291f1e0 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts @@ -0,0 +1,80 @@ +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); + } + } +} From 1015505f387edbea1629f617baa6bc18261f2625 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:46:09 +0200 Subject: [PATCH 15/20] feat(web): add inventory page with grid, detail panel, and equipment overview --- .../inventory/inventory-page.component.html | 65 ++++++++ .../inventory/inventory-page.component.scss | 133 ++++++++++++++++ .../inventory-page.component.spec.ts | 143 ++++++++++++++++++ .../inventory/inventory-page.component.ts | 69 +++++++++ 4 files changed, 410 insertions(+) create mode 100644 apps/web/src/app/features/inventory/inventory-page.component.html create mode 100644 apps/web/src/app/features/inventory/inventory-page.component.scss create mode 100644 apps/web/src/app/features/inventory/inventory-page.component.spec.ts create mode 100644 apps/web/src/app/features/inventory/inventory-page.component.ts diff --git a/apps/web/src/app/features/inventory/inventory-page.component.html b/apps/web/src/app/features/inventory/inventory-page.component.html new file mode 100644 index 0000000..1d16a21 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.html @@ -0,0 +1,65 @@ + +
+ @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) { + + } +
diff --git a/apps/web/src/app/features/inventory/inventory-page.component.scss b/apps/web/src/app/features/inventory/inventory-page.component.scss new file mode 100644 index 0000000..bc83851 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.scss @@ -0,0 +1,133 @@ +// 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; + } +} diff --git a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts new file mode 100644 index 0000000..0ba100d --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts @@ -0,0 +1,143 @@ +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'); + }); +}); diff --git a/apps/web/src/app/features/inventory/inventory-page.component.ts b/apps/web/src/app/features/inventory/inventory-page.component.ts new file mode 100644 index 0000000..e0b3eda --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.ts @@ -0,0 +1,69 @@ +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(); + } +} From fdd8ae4e4128ee540f5a215b3ed4f4a9d38fb755 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:54:38 +0200 Subject: [PATCH 16/20] test(web): cover the same-slot equipped-item filter in InventoryPageComponent The equippedItemInSelectedSlot computed is the riskiest logic this component owns, but every existing test mocked selectedItem() as null, so the slot-matching branch never ran and a regression to "any equipped item" would have gone unnoticed. Adds a test with three items across two slots that asserts the detail panel receives the same-slot equipped item, not just any equipped item. --- .../inventory-page.component.spec.ts | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts index 0ba100d..47e2a2a 100644 --- a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts +++ b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts @@ -1,8 +1,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { vi } from 'vitest'; -import type { CharacterResponse, EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models'; +import type { CharacterResponse, EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models'; import { WorldStore } from '../world/world.store'; +import { InventoryDetailPanelComponent } from './inventory-detail-panel.component'; import { InventoryPageComponent } from './inventory-page.component'; import { InventoryStore } from './inventory.store'; @@ -70,17 +72,23 @@ const character: CharacterResponse = { currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' }, }; -async function setup() { +interface SetupOptions { + inventoryData?: InventoryResponse; + selectedItemId?: string | null; + selectedItem?: InventoryItem | null; +} + +async function setup(options: SetupOptions = {}) { const inventoryStore = { - inventory: signal(inventory), + inventory: signal(options.inventoryData ?? inventory), equipment: signal(equipment), - selectedItemId: signal(null), + selectedItemId: signal(options.selectedItemId ?? null), loading: signal(false), equipping: signal(false), error: signal(null), load: vi.fn(() => Promise.resolve()), selectItem: vi.fn(), - selectedItem: vi.fn(() => null), + selectedItem: vi.fn(() => options.selectedItem ?? null), equip: vi.fn(() => Promise.resolve()), }; const worldStore = { character: signal(character) }; @@ -140,4 +148,44 @@ describe('InventoryPageComponent', () => { expect(text).toContain('6'); expect(text).toContain('8'); }); + + it('passes the equipped item from the SAME slot as the selection — not just any equipped item — to the detail panel', async () => { + // item-helm (HEAD, equipped) is placed before item-sword (WEAPON, equipped) so that a + // regression which drops the equipmentSlot match (i.e. "find the first equipped item") + // would surface item-helm instead of item-sword, and this test would fail. + const threeItemInventory: InventoryResponse = { + items: [ + { + id: 'item-helm', + quantity: 1, + equipped: true, + item: { + key: 'iron-helm', + name: 'Eiserner Helm', + rarity: 'COMMON', + equipmentSlot: 'HEAD', + requiredLevel: 1, + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 5, + bonusArmor: 2, + iconPath: '/images/items/iron-helm.png', + }, + }, + inventory.items[0], // item-sword, WEAPON, equipped + inventory.items[1], // item-blade, WEAPON, not equipped — this is the selection + ], + }; + + const { fixture } = await setup({ + inventoryData: threeItemInventory, + selectedItemId: 'item-blade', + selectedItem: threeItemInventory.items[2], + }); + + const panel = fixture.debugElement.query(By.directive(InventoryDetailPanelComponent)) + .componentInstance as InventoryDetailPanelComponent; + + expect(panel.equippedItemInSlot()?.id).toBe('item-sword'); + }); }); From 724443de1e382fbe6666d6d86400f99f40849785 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 17:58:32 +0200 Subject: [PATCH 17/20] feat(web): route and enable the Inventar nav entry --- apps/web/src/app/app.routes.ts | 7 +++++++ .../layout/side-navigation/side-navigation.component.html | 7 +++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 8a0c082..c441475 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -28,6 +28,13 @@ export const routes: Routes = [ (module) => module.CombatPageComponent, ), }, + { + path: 'inventory', + loadComponent: () => + import('./features/inventory/inventory-page.component').then( + (module) => module.InventoryPageComponent, + ), + }, ], }, { path: '**', redirectTo: 'world' }, diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.html b/apps/web/src/app/layout/side-navigation/side-navigation.component.html index 563ea58..152eadb 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.html +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.html @@ -39,9 +39,12 @@ +
+ + +
} @else if (combat.status === 'LOST') {
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..c40c78f 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 @@ -490,6 +490,17 @@ margin-block-start: var(--ar-space-2); } +.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; +} + .outcome__button:hover, .combat__notice--error button:hover { border-color: var(--ar-gold); 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..4fe7fdd 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 @@ -275,6 +275,15 @@ describe('CombatPageComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['/hunt']); }); + 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']); + }); + it('shows an error and retries loading the combat', async () => { const fixture = await setup(null); combatStore.error.set('Dieser Kampf wurde nicht gefunden.'); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 4af1f47..79b0c86 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -161,6 +161,10 @@ export class CombatPageComponent implements OnInit { void this.router.navigate(['/hunt']); } + protected goToInventory(): void { + void this.router.navigate(['/inventory']); + } + protected monsterSprite(monsterKey: string, artworkPath: string): string { return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } From 7f031ac1ce6c57d3c84a6de82f9c91cf800da214 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 18:15:30 +0200 Subject: [PATCH 19/20] test(web): update app.spec.ts for the now-enabled Inventar nav button --- apps/web/src/app/app.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 4dd3f38..2ac7b75 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -56,7 +56,14 @@ describe('App', () => { expect(huntButton?.getAttribute('aria-current')).toBeNull(); expect(huntButton?.getAttribute('aria-label')).toBe('Jagd'); - for (const destination of ['quests', 'inventory', 'character']) { + const inventoryButton = element.querySelector( + '[data-navigation="inventory"]', + ); + expect(inventoryButton).not.toBeNull(); + expect(inventoryButton?.disabled).toBe(false); + expect(inventoryButton?.getAttribute('aria-label')).toBe('Inventar'); + + for (const destination of ['quests', 'character']) { expect( element.querySelector(`[data-navigation="${destination}"]`)?.disabled, ).toBe(true); From 0ce3b420e6e50b5da814780e0a2aea2c4a97e557 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 18:44:39 +0200 Subject: [PATCH 20/20] fix: address final-review findings (TopBar hydration, snapshot test, armor doc, inventory refresh test) Fixes 4 Important findings from the final whole-branch review: - /inventory never called WorldStore.load(), leaving the TopBar stuck on "loading" and characterLevel() silently defaulting to 1 for any character above level 1. Mirrors the same guard already used in HuntPageComponent. - The combat/equipment snapshot-immutability integration test asserted only status/round, never the actual playerState snapshot the whole test claims to prove is untouched after a post-fight equip. - Documented (comment only, no behavior change) that the demo character's armor dropping from the old hardcoded 6 to 0 is an intentional, spec-sanctioned tradeoff (Slice 0.5 spec Section19), not a bug. - inventory.store.spec.ts's equip test used an identical inventory fixture before and after equip(), so a regression dropping the post-equip inventory re-fetch would still have passed. Now asserts the refetched fixture is actually reflected. Co-Authored-By: Claude Sonnet 5 --- .../combat-equipment-integration.spec.ts | 4 ++++ .../src/database/seeds/vertical-slice.seed.ts | 7 +++++++ .../inventory/inventory-page.component.spec.ts | 18 ++++++++++++++++-- .../inventory/inventory-page.component.ts | 3 +++ .../features/inventory/inventory.store.spec.ts | 14 ++++++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/api/src/combat/combat-equipment-integration.spec.ts b/apps/api/src/combat/combat-equipment-integration.spec.ts index 5c35545..d74d9db 100644 --- a/apps/api/src/combat/combat-equipment-integration.spec.ts +++ b/apps/api/src/combat/combat-equipment-integration.spec.ts @@ -367,6 +367,10 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); + // The finished combat's playerState snapshot (written once at startCombat) + // must not be retroactively rewritten by equipping after the fight ends. + expect(state.combats[0].playerState).toEqual({ attack: 6, weaponDamage: 8, armor: 0 }); + const reloaded = await combatService.getCombat(CHARACTER_ID, combat.id); expect(reloaded.status).toBe(result.status); expect(reloaded.round).toBe(result.round); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index febeb07..d8ac53e 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -217,6 +217,13 @@ export async function seedVisibleVerticalSlice( const characterItemRepository = dataSource.getRepository(CharacterItem); const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment); + // Starting loadout is weapon-only -- no starter armor piece exists in + // content yet -- so the demo character's effective armor (sum of equipped + // bonusArmor) is 0 until the player loots and equips bandit-hood (+3 + // armor). This is a deliberate tradeoff, not a bug: Slice 0.5 spec §19 + // says to preserve the existing demo balance "as closely as the + // implemented content allows" and explicitly forbids fabricating a full + // starter gear set just to hit the old hardcoded TEMPORARY_ARMOR = 6. const existingStartingSword = await characterItemRepository.findOneBy({ characterId: DEMO_CHARACTER_ID, itemDefinitionId: ITEM_IDS['worn-short-sword'], diff --git a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts index 47e2a2a..4fc928f 100644 --- a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts +++ b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts @@ -76,6 +76,7 @@ interface SetupOptions { inventoryData?: InventoryResponse; selectedItemId?: string | null; selectedItem?: InventoryItem | null; + character?: CharacterResponse | null; } async function setup(options: SetupOptions = {}) { @@ -91,7 +92,10 @@ async function setup(options: SetupOptions = {}) { selectedItem: vi.fn(() => options.selectedItem ?? null), equip: vi.fn(() => Promise.resolve()), }; - const worldStore = { character: signal(character) }; + const worldStore = { + character: signal(options.character === undefined ? character : options.character), + load: vi.fn(() => Promise.resolve()), + }; await TestBed.configureTestingModule({ imports: [InventoryPageComponent], @@ -103,7 +107,7 @@ async function setup(options: SetupOptions = {}) { const fixture = TestBed.createComponent(InventoryPageComponent); fixture.detectChanges(); - return { fixture, inventoryStore }; + return { fixture, inventoryStore, worldStore }; } describe('InventoryPageComponent', () => { @@ -112,6 +116,16 @@ describe('InventoryPageComponent', () => { expect(inventoryStore.load).toHaveBeenCalledOnce(); }); + it('loads the world state on init when no character has been loaded yet (direct navigation/hard refresh)', async () => { + const { worldStore } = await setup({ character: null }); + expect(worldStore.load).toHaveBeenCalledOnce(); + }); + + it('does not call world load again when a character is already present', async () => { + const { worldStore } = await setup(); + expect(worldStore.load).not.toHaveBeenCalled(); + }); + it('renders one tile per owned item', async () => { const { fixture } = await setup(); const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot'); diff --git a/apps/web/src/app/features/inventory/inventory-page.component.ts b/apps/web/src/app/features/inventory/inventory-page.component.ts index e0b3eda..270c754 100644 --- a/apps/web/src/app/features/inventory/inventory-page.component.ts +++ b/apps/web/src/app/features/inventory/inventory-page.component.ts @@ -52,6 +52,9 @@ export class InventoryPageComponent implements OnInit { }); ngOnInit(): void { + if (this.worldStore.character() === null) { + void this.worldStore.load(); + } void this.inventoryStore.load(); } diff --git a/apps/web/src/app/features/inventory/inventory.store.spec.ts b/apps/web/src/app/features/inventory/inventory.store.spec.ts index 1cc1205..2deb123 100644 --- a/apps/web/src/app/features/inventory/inventory.store.spec.ts +++ b/apps/web/src/app/features/inventory/inventory.store.spec.ts @@ -65,6 +65,13 @@ const equippedAfter: EquipmentResponse = { stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 }, }; +const inventoryAfterEquip: InventoryResponse = { + items: [ + { ...inventory.items[0], equipped: false }, + { ...inventory.items[1], equipped: true }, + ], +}; + describe('InventoryStore', () => { let api: { getInventory: ReturnType; @@ -109,12 +116,19 @@ describe('InventoryStore', () => { }); it('equips the selected item, refreshes inventory/equipment, and refreshes the character HUD', async () => { + api.getInventory + .mockReturnValueOnce(of(inventory)) // initial load() + .mockReturnValueOnce(of(inventoryAfterEquip)); // post-equip refetch + await store.load(); await store.equip('item-blade'); expect(api.equipItem).toHaveBeenCalledWith('item-blade'); expect(store.equipment()).toEqual(equippedAfter); + expect(store.inventory()).toEqual(inventoryAfterEquip); + expect(store.inventory()?.items.find((item) => item.id === 'item-sword')?.equipped).toBe(false); + expect(store.inventory()?.items.find((item) => item.id === 'item-blade')?.equipped).toBe(true); expect(worldStore.refreshCharacter).toHaveBeenCalledOnce(); });