# 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.