Files
ashen-realms/docs/superpowers/plans/2026-08-20-playable-slice-0.5-first-upgrade.md
2026-08-20 16:08:51 +02:00

135 KiB
Raw Blame History

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 §1314).
  • 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 <module>.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 CharacterCombatStatsServiceCharacterStatsService)
  • 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):

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
// 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
// apps/api/src/database/migrations/1789000000000-CreateEquipment.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

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

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      'DROP INDEX "IDX_character_equipment_character_item"',
    );
    await queryRunner.query(
      'DROP INDEX "IDX_character_equipment_character_slot"',
    );
    await queryRunner.query('DROP TABLE "character_equipment"');
  }
}
  • Step 6: Compile the migration

Run: npm run build --workspace=@ashen-realms/api Expected: builds cleanly (no TypeScript errors).

  • Step 7: Commit
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

// 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
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<DataSource, 'getRepository'>): Promise<EffectiveCharacterStats>. Later tasks (EquipmentService, CombatService, CharactersService) call calculate() and read these exact field names.

  • Step 1: Write the failing tests (spec §42)

// 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<ItemDefinition>;
};

function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> {
  const rows = equipped.map((entry) => ({
    slot: entry.slot,
    characterItem: {
      itemDefinition: {
        weaponDamage: 0,
        bonusHp: 0,
        bonusAttack: 0,
        bonusArmor: 0,
        ...entry.item,
      },
    },
  }));
  return {
    getRepository: () => ({ find: async () => rows }) as never,
  } as unknown as Pick<DataSource, 'getRepository'>;
}

function character(overrides: Partial<Character> = {}): Character {
  return {
    id: 'character-1',
    baseHp: 100,
    baseAttack: 6,
    currentHp: 90,
    ...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
// 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<DataSource, 'getRepository'>;

/**
 * Single authoritative source of effective character stats (spec §18).
 * Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut.
 */
@Injectable()
export class CharacterStatsService {
  constructor(private readonly dataSource: DataSource) {}

  async calculate(
    character: Character,
    scope?: RepositoryScope,
  ): Promise<EffectiveCharacterStats> {
    const db = scope ?? this.dataSource;
    const equipped = await db.getRepository(CharacterEquipment).find({
      where: { characterId: character.id },
      relations: { characterItem: { itemDefinition: true } },
    });

    let weaponDamage = 0;
    let bonusHp = 0;
    let bonusAttack = 0;
    let bonusArmor = 0;

    for (const slot of equipped) {
      const definition = slot.characterItem.itemDefinition;
      if (slot.slot === EquipmentSlot.WEAPON) {
        weaponDamage = definition.weaponDamage;
      }
      bonusHp += definition.bonusHp;
      bonusAttack += definition.bonusAttack;
      bonusArmor += definition.bonusArmor;
    }

    const maxHp = character.baseHp + bonusHp;
    const attack = character.baseAttack + bonusAttack;
    const armor = bonusArmor;

    return {
      maxHp,
      currentHp: 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
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:

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<Character>;
    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<Character>;
    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<Character>;
    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
// 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<Character>,
    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
// 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
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
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:
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:
  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):
      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
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<EquipmentSlot, EquipmentSlotItemDto | null>, 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)

// 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<T extends { id: string }> {
  constructor(
    private readonly state: State,
    private readonly target: EntityTarget<T>,
    private readonly dataSource: FakeDataSource,
  ) {}

  findOne(options: {
    where: Partial<T>;
    relations?: Record<string, unknown>;
    lock?: { mode: string };
  }): Promise<T | null> {
    const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
    return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
  }

  findOneBy(where: Partial<T>): Promise<T | null> {
    return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
  }

  find(options: { where: Partial<T>; relations?: Record<string, unknown> }): Promise<T[]> {
    const matched = this.rows().filter((row) => this.matches(row, options.where));
    return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
  }

  create(values: Partial<T>): T {
    return { ...values } as T;
  }

  save(entity: T): Promise<T> {
    if (!entity.id) {
      entity.id = this.dataSource.nextId(this.targetName());
    }
    const rows = this.rows();
    const index = rows.findIndex((row) => row.id === entity.id);
    if (index === -1) {
      rows.push(entity);
    } else {
      rows[index] = entity;
    }
    return Promise.resolve(entity);
  }

  private withRelations(row: T, relations?: Record<string, unknown>): T {
    if (!relations) {
      return row;
    }
    const copy = { ...row } as T & Record<string, unknown>;
    if (this.target === CharacterItem && relations['itemDefinition']) {
      const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
      copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
    }
    if (this.target === CharacterEquipment && relations['characterItem']) {
      const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
      const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
      copy['characterItem'] = characterItem
        ? {
            ...characterItem,
            itemDefinition: this.state.itemDefinitions.find(
              (d) => d.id === characterItem.itemDefinitionId,
            ),
          }
        : undefined;
    }
    return copy as T;
  }

  private rows(): T[] {
    if (this.target === Character) return this.state.characters as T[];
    if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
    if (this.target === CharacterItem) return this.state.characterItems as T[];
    if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
    if (this.target === Combat) return this.state.combats as T[];
    throw new Error(`Unsupported repository ${this.targetName()}`);
  }

  private matches(row: T, where: Partial<T>): boolean {
    return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
  }

  private targetName(): string {
    return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
  }
}

class FakeDataSource {
  private readonly idCounters = new Map<string, number>();
  constructor(public state: State) {}

  getRepository<T extends { id: string }>(target: EntityTarget<T>) {
    return new FakeRepository(this.state, target, this);
  }

  async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
    return work({
      getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
        this.getRepository(target),
    } as unknown as EntityManager);
  }

  nextId(targetName: string): string {
    const next = (this.idCounters.get(targetName) ?? 0) + 1;
    this.idCounters.set(targetName, next);
    return `${targetName.toLowerCase()}-generated-${next}`;
  }
}

function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
  return {
    id: 'def-worn-sword',
    key: 'worn-short-sword',
    name: 'Abgenutztes Kurzschwert',
    description: '',
    type: ItemType.WEAPON,
    equipmentSlot: EquipmentSlot.WEAPON,
    rarity: ItemRarity.COMMON,
    tier: 1,
    requiredLevel: 1,
    weaponDamage: 8,
    bonusHp: 0,
    bonusAttack: 0,
    bonusArmor: 0,
    sellPrice: 0,
    iconPath: '/images/items/worn-short-sword.png',
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  } as ItemDefinition;
}

function character(overrides: Partial<Character> = {}): Character {
  return {
    id: CHARACTER_ID,
    name: 'Aric Duskwalker',
    level: 1,
    baseHp: 100,
    baseAttack: 6,
    currentHp: 100,
    ...overrides,
  } as Character;
}

function createHarness(state: Partial<State> = {}) {
  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<unknown>, code: string): Promise<void> {
  let error: unknown;
  try {
    await promise;
  } catch (cause) {
    error = cause;
  }
  expect(error).toBeInstanceOf(EquipmentDomainError);
  if (!(error instanceof EquipmentDomainError)) {
    throw new Error('Expected EquipmentDomainError');
  }
  expect(error.code).toBe(code);
}

describe('EquipmentService', () => {
  describe('equip', () => {
    it('equips an owned weapon into the WEAPON slot', async () => {
      const wornSword = itemDefinition();
      const { state, service } = createHarness({
        itemDefinitions: [wornSword],
        characterItems: [
          {
            id: WORN_SWORD_ITEM_ID,
            characterId: CHARACTER_ID,
            itemDefinitionId: wornSword.id,
            quantity: 1,
          } as CharacterItem,
        ],
      });

      const result = await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);

      expect(result.slots.WEAPON).toEqual({
        characterItemId: WORN_SWORD_ITEM_ID,
        item: {
          key: 'worn-short-sword',
          name: 'Abgenutztes Kurzschwert',
          rarity: 'COMMON',
          iconPath: '/images/items/worn-short-sword.png',
        },
      });
      expect(state.characterEquipment).toHaveLength(1);
    });

    it('replaces the equipped weapon without deleting the old CharacterItem', async () => {
      const wornSword = itemDefinition();
      const banditBlade = itemDefinition({
        id: 'def-bandit-blade',
        key: 'bandit-blade',
        name: 'Räuberklinge',
        weaponDamage: 11,
        bonusAttack: 1,
        iconPath: '/images/items/bandit-blade.png',
      });
      const { state, service } = createHarness({
        itemDefinitions: [wornSword, banditBlade],
        characterItems: [
          {
            id: WORN_SWORD_ITEM_ID,
            characterId: CHARACTER_ID,
            itemDefinitionId: wornSword.id,
            quantity: 1,
          } as CharacterItem,
          {
            id: BANDIT_BLADE_ITEM_ID,
            characterId: CHARACTER_ID,
            itemDefinitionId: banditBlade.id,
            quantity: 1,
          } as CharacterItem,
        ],
        characterEquipment: [
          {
            id: 'equip-1',
            characterId: CHARACTER_ID,
            slot: EquipmentSlot.WEAPON,
            characterItemId: WORN_SWORD_ITEM_ID,
          } as CharacterEquipment,
        ],
      });

      const result = await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);

      expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
      expect(state.characterEquipment).toHaveLength(1);
      expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined();
    });

    it('rejects equipping an item owned by a different character', async () => {
      const wornSword = itemDefinition();
      const { service } = createHarness({
        characters: [character(), character({ id: OTHER_CHARACTER_ID })],
        itemDefinitions: [wornSword],
        characterItems: [
          {
            id: WORN_SWORD_ITEM_ID,
            characterId: OTHER_CHARACTER_ID,
            itemDefinitionId: wornSword.id,
            quantity: 1,
          } as CharacterItem,
        ],
      });

      await expectEquipmentDomainError(
        service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
        'ITEM_NOT_OWNED',
      );
    });

    it('rejects equipping an unknown CharacterItem id', async () => {
      const { service } = createHarness();

      await expectEquipmentDomainError(
        service.equip(CHARACTER_ID, 'unknown-item'),
        'CHARACTER_ITEM_NOT_FOUND',
      );
    });

    it('rejects equipping an item above the character level', async () => {
      const highLevelHelm = itemDefinition({
        id: BANDIT_HOOD_ITEM_ID,
        key: 'bandit-hood',
        equipmentSlot: EquipmentSlot.HEAD,
        requiredLevel: 5,
      });
      const { service } = createHarness({
        itemDefinitions: [highLevelHelm],
        characterItems: [
          {
            id: BANDIT_HOOD_ITEM_ID,
            characterId: CHARACTER_ID,
            itemDefinitionId: highLevelHelm.id,
            quantity: 1,
          } as CharacterItem,
        ],
      });

      await expectEquipmentDomainError(
        service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID),
        'ITEM_LEVEL_REQUIREMENT_NOT_MET',
      );
    });

    it('rejects equipping a non-equippable item', async () => {
      const material = itemDefinition({
        id: 'def-ash-pelt',
        key: 'ash-pelt',
        type: ItemType.MATERIAL,
        equipmentSlot: null,
      });
      const { service } = createHarness({
        itemDefinitions: [material],
        characterItems: [
          {
            id: 'item-ash-pelt',
            characterId: CHARACTER_ID,
            itemDefinitionId: material.id,
            quantity: 1,
          } as CharacterItem,
        ],
      });

      await expectEquipmentDomainError(
        service.equip(CHARACTER_ID, 'item-ash-pelt'),
        'ITEM_NOT_EQUIPPABLE',
      );
    });

    it('never produces two equipped weapons when the same slot is equipped repeatedly', async () => {
      const wornSword = itemDefinition();
      const banditBlade = itemDefinition({
        id: 'def-bandit-blade',
        key: 'bandit-blade',
        weaponDamage: 11,
        bonusAttack: 1,
        iconPath: '/images/items/bandit-blade.png',
      });
      const { state, service } = createHarness({
        itemDefinitions: [wornSword, banditBlade],
        characterItems: [
          {
            id: WORN_SWORD_ITEM_ID,
            characterId: CHARACTER_ID,
            itemDefinitionId: wornSword.id,
            quantity: 1,
          } as CharacterItem,
          {
            id: BANDIT_BLADE_ITEM_ID,
            characterId: CHARACTER_ID,
            itemDefinitionId: banditBlade.id,
            quantity: 1,
          } as CharacterItem,
        ],
      });

      // Sequential repeats stand in for the concurrent case here (a real race
      // is guarded by the DB's UNIQUE(character_id, slot) constraint from
      // Task 1, which a synchronous fake repository cannot exercise).
      await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
      await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
      await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);

      expect(state.characterEquipment).toHaveLength(1);
      expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
    });

    it('rejects equipping while the character has an active combat', async () => {
      const wornSword = itemDefinition();
      const { service } = createHarness({
        itemDefinitions: [wornSword],
        characterItems: [
          {
            id: WORN_SWORD_ITEM_ID,
            characterId: CHARACTER_ID,
            itemDefinitionId: wornSword.id,
            quantity: 1,
          } as CharacterItem,
        ],
        combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat],
      });

      await expectEquipmentDomainError(
        service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
        'CHARACTER_IN_COMBAT',
      );
    });
  });

  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
// apps/api/src/equipment/dto/equip-item.dto.ts
import { IsUUID } from 'class-validator';

export class EquipItemDto {
  @IsUUID()
  characterItemId!: string;
}
  • Step 4: Implement EquipmentService
// 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<EquipmentSlot, EquipmentSlotItemDto | null>;

export interface EquipmentStatsDto {
  maxHp: number;
  attack: number;
  weaponDamage: number;
  armor: number;
}

export interface EquipmentResponseDto {
  slots: EquipmentSlotsDto;
  stats: EquipmentStatsDto;
}

type RepositoryScope = Pick<DataSource, 'getRepository'>;

@Injectable()
export class EquipmentService {
  constructor(
    private readonly dataSource: DataSource,
    private readonly characterStats: CharacterStatsService,
  ) {}

  async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
    const character = await this.dataSource
      .getRepository(Character)
      .findOneBy({ id: characterId });
    if (!character) {
      throw characterNotFound();
    }
    return this.buildResponse(character, this.dataSource);
  }

  /**
   * Equips (or replaces) one slot for `characterId` with `characterItemId`
   * (spec §14, §28). Runs in one transaction: the old item is unequipped by
   * being overwritten, never deleted (spec §15).
   */
  async equip(characterId: string, characterItemId: string): Promise<EquipmentResponseDto> {
    return this.dataSource.transaction(async (manager) => {
      const characters = manager.getRepository(Character);
      const combats = manager.getRepository(Combat);
      const characterItems = manager.getRepository(CharacterItem);
      const equipmentRepo = manager.getRepository(CharacterEquipment);

      const character = await characters.findOne({
        where: { id: characterId },
        lock: { mode: 'pessimistic_write' },
      });
      if (!character) {
        throw characterNotFound();
      }

      const activeCombat = await combats.findOne({
        where: { characterId, status: CombatStatus.ACTIVE },
      });
      if (activeCombat) {
        throw characterInCombat();
      }

      const characterItem = await characterItems.findOne({
        where: { id: characterItemId },
        relations: { itemDefinition: true },
      });
      if (!characterItem) {
        throw characterItemNotFound();
      }
      if (characterItem.characterId !== characterId) {
        throw itemNotOwned();
      }

      const definition = characterItem.itemDefinition;
      if (!definition.equipmentSlot) {
        throw itemNotEquippable();
      }
      if (definition.requiredLevel > character.level) {
        throw itemLevelRequirementNotMet();
      }

      const existing = await equipmentRepo.findOne({
        where: { characterId, slot: definition.equipmentSlot },
        lock: { mode: 'pessimistic_write' },
      });
      if (existing) {
        existing.characterItemId = characterItem.id;
        await equipmentRepo.save(existing);
      } else {
        await equipmentRepo.save(
          equipmentRepo.create({
            characterId,
            slot: definition.equipmentSlot,
            characterItemId: characterItem.id,
          }),
        );
      }

      return this.buildResponse(character, manager);
    });
  }

  private async buildResponse(
    character: Character,
    scope: RepositoryScope,
  ): Promise<EquipmentResponseDto> {
    const equipped = await scope.getRepository(CharacterEquipment).find({
      where: { characterId: character.id },
      relations: { characterItem: { itemDefinition: true } },
    });

    const slots = Object.fromEntries(
      Object.values(EquipmentSlot).map((slot) => [slot, null]),
    ) as EquipmentSlotsDto;

    for (const row of equipped) {
      const definition = row.characterItem.itemDefinition;
      slots[row.slot] = {
        characterItemId: row.characterItemId,
        item: {
          key: definition.key,
          name: definition.name,
          rarity: definition.rarity,
          iconPath: definition.iconPath,
        },
      };
    }

    const stats = await this.characterStats.calculate(character, scope);

    return {
      slots,
      stats: {
        maxHp: stats.maxHp,
        attack: stats.attack,
        weaponDamage: stats.weaponDamage,
        armor: stats.armor,
      },
    };
  }
}
  • 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
// 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<EquipmentResponseDto> {
    return this.equipmentService.getEquipment(DEMO_CHARACTER_ID);
  }

  @Post()
  equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
    return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId);
  }
}
// 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
// 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
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)

// 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> = {}): 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<CharacterItem>;
    const equipment = {
      find: jest.fn().mockResolvedValue([
        { characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment,
      ]),
    } as unknown as Repository<CharacterEquipment>;
    const service = new InventoryService(characterItems, equipment);

    const result = await service.getInventory(CHARACTER_ID);

    expect(characterItems.find).toHaveBeenCalledWith({
      where: { characterId: CHARACTER_ID },
      relations: { itemDefinition: true },
      order: { createdAt: 'ASC' },
    });
    expect(result.items).toEqual([
      {
        id: 'item-1',
        quantity: 1,
        equipped: true,
        item: {
          key: 'worn-short-sword',
          name: 'Abgenutztes Kurzschwert',
          rarity: 'COMMON',
          equipmentSlot: 'WEAPON',
          requiredLevel: 1,
          weaponDamage: 8,
          bonusAttack: 0,
          bonusHp: 0,
          bonusArmor: 0,
          iconPath: '/images/items/worn-short-sword.png',
        },
      },
      {
        id: 'item-2',
        quantity: 3,
        equipped: false,
        item: expect.objectContaining({ key: 'worn-short-sword' }),
      },
    ]);
  });

  it('returns an empty list when the character owns nothing', async () => {
    const characterItems = {
      find: jest.fn().mockResolvedValue([]),
    } as unknown as Repository<CharacterItem>;
    const equipment = {
      find: jest.fn().mockResolvedValue([]),
    } as unknown as Repository<CharacterEquipment>;
    const service = new InventoryService(characterItems, equipment);

    await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] });
  });
});
  • 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
// 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<CharacterItem>,
    @InjectRepository(CharacterEquipment)
    private readonly equipment: Repository<CharacterEquipment>,
  ) {}

  async getInventory(characterId: string): Promise<InventoryResponseDto> {
    const [items, equipped] = await Promise.all([
      this.characterItems.find({
        where: { characterId },
        relations: { itemDefinition: true },
        order: { createdAt: 'ASC' },
      }),
      this.equipment.find({ where: { characterId } }),
    ]);
    const equippedIds = new Set(equipped.map((row) => row.characterItemId));

    return {
      items: items.map((characterItem) => ({
        id: characterItem.id,
        quantity: characterItem.quantity,
        equipped: equippedIds.has(characterItem.id),
        item: {
          key: characterItem.itemDefinition.key,
          name: characterItem.itemDefinition.name,
          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
// 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<InventoryResponseDto> {
    return this.inventoryService.getInventory(DEMO_CHARACTER_ID);
  }
}
// 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
// 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
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
// 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<T extends { id: string }> {
  constructor(
    private readonly state: FakeState,
    private readonly target: EntityTarget<T>,
    private readonly dataSource: FakeDataSource,
  ) {}

  findOne(options: {
    where: Partial<T>;
    relations?: Record<string, unknown>;
    lock?: { mode: string };
  }): Promise<T | null> {
    const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
    return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
  }

  findOneBy(where: Partial<T>): Promise<T | null> {
    return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
  }

  find(options: {
    where: Partial<T>;
    relations?: Record<string, unknown>;
    order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
  }): Promise<T[]> {
    const matched = this.rows().filter((row) => this.matches(row, options.where));
    return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
  }

  count(options: { where: Partial<T> }): Promise<number> {
    return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length);
  }

  create(values: Partial<T>): T {
    return { ...values } as T;
  }

  save(entity: T): Promise<T> {
    if (!entity.id) {
      entity.id = this.dataSource.nextId(this.targetName());
    }
    const rows = this.rows();
    const index = rows.findIndex((row) => row.id === entity.id);
    if (index === -1) {
      rows.push(entity);
    } else {
      rows[index] = entity;
    }
    return Promise.resolve(entity);
  }

  private withRelations(row: T, relations?: Record<string, unknown>): T {
    if (!relations) {
      return row;
    }
    const copy = { ...row } as T & Record<string, unknown>;
    if (this.target === CharacterItem && relations['itemDefinition']) {
      const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
      copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
    }
    if (this.target === CharacterEquipment && relations['characterItem']) {
      const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
      const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
      copy['characterItem'] = characterItem
        ? {
            ...characterItem,
            itemDefinition: this.state.itemDefinitions.find(
              (d) => d.id === characterItem.itemDefinitionId,
            ),
          }
        : undefined;
    }
    return copy as T;
  }

  private rows(): T[] {
    if (this.target === Character) return this.state.characters as T[];
    if (this.target === Hunt) return this.state.hunts as T[];
    if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
    if (this.target === MonsterDefinition) return this.state.monsters as T[];
    if (this.target === Combat) return this.state.combats as T[];
    if (this.target === CombatEvent) return this.state.combatEvents as T[];
    if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
    if (this.target === CharacterItem) return this.state.characterItems as T[];
    if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
    throw new Error(`Unsupported repository ${this.targetName()}`);
  }

  private matches(row: T, where: Partial<T>): boolean {
    return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
  }

  private targetName(): string {
    return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
  }
}

class FakeDataSource {
  private readonly idCounters = new Map<string, number>();
  constructor(public state: FakeState) {}

  getRepository<T extends { id: string }>(target: EntityTarget<T>) {
    return new FakeRepository(this.state, target, this);
  }

  async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
    return work({
      getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
        this.getRepository(target),
    } as unknown as EntityManager);
  }

  nextId(targetName: string): string {
    const next = (this.idCounters.get(targetName) ?? 0) + 1;
    this.idCounters.set(targetName, next);
    return `${targetName.toLowerCase()}-generated-${next}`;
  }
}

function character(overrides: Partial<Character> = {}): Character {
  return {
    id: CHARACTER_ID,
    name: 'Aric Duskwalker',
    level: 1,
    experience: 0,
    silver: 0,
    baseHp: 100,
    baseAttack: 6,
    currentHp: 100,
    currentLocationId: 'location-1',
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    updatedAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as Character;
}

function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition {
  return {
    id: MONSTER_ID,
    key: 'road-bandit',
    name: 'Straßenräuber',
    level: 2,
    maxHp: 75,
    attack: 9,
    armor: 5,
    experienceReward: 16,
    silverMin: 9,
    silverMax: 15,
    artworkPath: '/images/monsters/road-bandit.png',
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    updatedAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as MonsterDefinition;
}

function hunt(overrides: Partial<Hunt> = {}): Hunt {
  return {
    id: HUNT_ID,
    characterId: CHARACTER_ID,
    locationId: 'location-1',
    status: HuntStatus.ACTIVE,
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as Hunt;
}

function encounter(id: string, overrides: Partial<HuntEncounter> = {}): HuntEncounter {
  return {
    id,
    huntId: HUNT_ID,
    monsterDefinitionId: MONSTER_ID,
    position: 0,
    status: HuntEncounterStatus.AVAILABLE,
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as HuntEncounter;
}

function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
  return {
    id: WORN_SWORD_DEFINITION_ID,
    key: 'worn-short-sword',
    name: 'Abgenutztes Kurzschwert',
    description: '',
    type: ItemType.WEAPON,
    equipmentSlot: EquipmentSlot.WEAPON,
    rarity: ItemRarity.COMMON,
    tier: 1,
    requiredLevel: 1,
    weaponDamage: 8,
    bonusHp: 0,
    bonusAttack: 0,
    bonusArmor: 0,
    sellPrice: 0,
    iconPath: '/images/items/worn-short-sword.png',
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    updatedAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as ItemDefinition;
}

function fakeTravelService(): TravelService {
  return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService;
}

function fakeRewardService(): CombatRewardService {
  return {
    grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }),
    loadRewards: jest.fn().mockResolvedValue(null),
  } as unknown as CombatRewardService;
}

function createHarness() {
  const state: FakeState = {
    characters: [character()],
    hunts: [hunt()],
    huntEncounters: [],
    monsters: [monster()],
    combats: [],
    combatEvents: [],
    itemDefinitions: [
      itemDefinition(),
      itemDefinition({
        id: BANDIT_BLADE_DEFINITION_ID,
        key: 'bandit-blade',
        name: 'Räuberklinge',
        weaponDamage: 11,
        bonusAttack: 1,
        iconPath: '/images/items/bandit-blade.png',
      }),
    ],
    characterItems: [
      {
        id: WORN_SWORD_ITEM_ID,
        characterId: CHARACTER_ID,
        itemDefinitionId: WORN_SWORD_DEFINITION_ID,
        quantity: 1,
        createdAt: new Date(),
        updatedAt: new Date(),
      } as CharacterItem,
      {
        id: BANDIT_BLADE_ITEM_ID,
        characterId: CHARACTER_ID,
        itemDefinitionId: BANDIT_BLADE_DEFINITION_ID,
        quantity: 1,
        createdAt: new Date(),
        updatedAt: new Date(),
      } as CharacterItem,
    ],
    characterEquipment: [
      {
        id: 'equip-1',
        characterId: CHARACTER_ID,
        slot: EquipmentSlot.WEAPON,
        characterItemId: WORN_SWORD_ITEM_ID,
        createdAt: new Date(),
        updatedAt: new Date(),
      } as CharacterEquipment,
    ],
  };
  const dataSource = new FakeDataSource(state);
  const 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
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

// 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:
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):
  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:
  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
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:

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<EquipmentSlot, EquipmentSlotItem | null>;

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:

  getInventory(): Observable<InventoryResponse> {
    return this.http.get<InventoryResponse>('/api/inventory');
  }

  getEquipment(): Observable<EquipmentResponse> {
    return this.http.get<EquipmentResponse>('/api/equipment');
  }

  equipItem(characterItemId: string): Observable<EquipmentResponse> {
    return this.http.post<EquipmentResponse>('/api/equipment', { characterItemId });
  }
  • Step 3: Build

Run: npm run build --workspace=@ashen-realms/web Expected: builds cleanly.

  • Step 4: Commit
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

// 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<typeof vi.fn>;
    getEquipment: ReturnType<typeof vi.fn>;
    equipItem: ReturnType<typeof vi.fn>;
  };
  let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
  let store: InventoryStore;

  beforeEach(() => {
    api = {
      getInventory: vi.fn(() => of(inventory)),
      getEquipment: vi.fn(() => of(equipment)),
      equipItem: vi.fn(() => of(equippedAfter)),
    };
    worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };

    TestBed.configureTestingModule({
      providers: [
        InventoryStore,
        { provide: GameApiService, useValue: api },
        { provide: WorldStore, useValue: worldStore },
      ],
    });
    store = TestBed.inject(InventoryStore);
  });

  it('loads inventory and equipment together', async () => {
    await store.load();

    expect(store.inventory()).toEqual(inventory);
    expect(store.equipment()).toEqual(equipment);
  });

  it('selects an item by id', async () => {
    await store.load();

    store.selectItem('item-blade');

    expect(store.selectedItemId()).toBe('item-blade');
    expect(store.selectedItem()).toEqual(inventory.items[1]);
  });

  it('equips the selected item, refreshes inventory/equipment, and refreshes the character HUD', async () => {
    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
// 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<Record<string, string>> = {
  CHARACTER_ITEM_NOT_FOUND: 'Dieser Gegenstand konnte nicht gefunden werden.',
  ITEM_NOT_OWNED: 'Dieser Gegenstand gehört dir nicht.',
  ITEM_NOT_EQUIPPABLE: 'Dieser Gegenstand kann nicht ausgerüstet werden.',
  ITEM_LEVEL_REQUIREMENT_NOT_MET: 'Du erfüllst die Stufenanforderung nicht.',
  INVALID_EQUIPMENT_SLOT: 'Dieser Ausrüstungsplatz ist ungültig.',
  CHARACTER_IN_COMBAT: 'Ausrüstung kann während eines Kampfes nicht geändert werden.',
};

@Injectable({ providedIn: 'root' })
export class InventoryStore {
  private readonly inventoryState = signal<InventoryResponse | null>(null);
  private readonly equipmentState = signal<EquipmentResponse | null>(null);
  private readonly selectedItemIdState = signal<string | null>(null);
  private readonly loadingState = signal(false);
  private readonly equippingState = signal(false);
  private readonly errorState = signal<string | null>(null);

  readonly inventory = this.inventoryState.asReadonly();
  readonly equipment = this.equipmentState.asReadonly();
  readonly selectedItemId = this.selectedItemIdState.asReadonly();
  readonly loading = this.loadingState.asReadonly();
  readonly equipping = this.equippingState.asReadonly();
  readonly error = this.errorState.asReadonly();

  constructor(
    private readonly api: GameApiService,
    private readonly worldStore: WorldStore,
  ) {}

  async load(): Promise<void> {
    this.loadingState.set(true);
    this.errorState.set(null);
    try {
      const { inventory, equipment } = await firstValueFrom(
        forkJoin({ inventory: this.api.getInventory(), equipment: this.api.getEquipment() }),
      );
      this.inventoryState.set(inventory);
      this.equipmentState.set(equipment);
    } catch (error) {
      this.errorState.set(this.toErrorMessage(error));
    } finally {
      this.loadingState.set(false);
    }
  }

  selectItem(characterItemId: string | null): void {
    this.selectedItemIdState.set(characterItemId);
  }

  selectedItem(): InventoryItem | null {
    const id = this.selectedItemIdState();
    if (!id) {
      return null;
    }
    return this.inventoryState()?.items.find((item) => item.id === id) ?? null;
  }

  /** Equips an item, then refreshes inventory/equipment and the character HUD (spec §35, §40). */
  async equip(characterItemId: string): Promise<void> {
    if (this.equippingState()) {
      return;
    }
    this.equippingState.set(true);
    this.errorState.set(null);
    try {
      const equipment = await firstValueFrom(this.api.equipItem(characterItemId));
      this.equipmentState.set(equipment);
      const inventory = await firstValueFrom(this.api.getInventory());
      this.inventoryState.set(inventory);
      await this.worldStore.refreshCharacter();
    } catch (error) {
      this.errorState.set(this.toErrorMessage(error));
    } finally {
      this.equippingState.set(false);
    }
  }

  private toErrorMessage(error: unknown): string {
    if (error instanceof HttpErrorResponse) {
      const code = (error.error as { code?: string } | null)?.code;
      return (code && EQUIPMENT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
    }
    return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
  }
}
  • 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
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<string>. 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
// 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<HTMLButtonElement>('[data-detail-equip]');

    expect(button?.textContent).toContain('Benötigt Stufe 5');
    expect(button?.disabled).toBe(true);
  });

  it('emits equip with the characterItemId when Ausrüsten is clicked', async () => {
    const fixture = await setup({ item: banditBlade });
    const emitted: string[] = [];
    fixture.componentInstance.equip.subscribe((id: string) => emitted.push(id));

    (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]')?.click();

    expect(emitted).toEqual(['item-blade']);
  });

  it('disables the equip button while busy', async () => {
    const fixture = await setup({ item: banditBlade, busy: true });
    const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');

    expect(button?.disabled).toBe(true);
  });
});
  • 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
// 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<Record<EquipmentSlot, string>> = {
  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 §3237). */
@Component({
  selector: 'app-inventory-detail-panel',
  templateUrl: './inventory-detail-panel.component.html',
  styleUrl: './inventory-detail-panel.component.scss',
})
export class InventoryDetailPanelComponent {
  readonly item = input<InventoryItem | null>(null);
  readonly equippedItemInSlot = input<InventoryItem | null>(null);
  readonly characterLevel = input(1);
  readonly busy = input(false);
  readonly equip = output<string>();

  protected readonly rarityLabel = computed(() => {
    const item = this.item();
    return item ? RARITY_LABELS[item.item.rarity] : '';
  });

  protected readonly slotLabel = computed(() => {
    const slot = this.item()?.item.equipmentSlot;
    return slot ? SLOT_LABELS[slot] : null;
  });

  protected readonly statRows = computed<StatRow[]>(() => {
    const item = this.item();
    if (!item) {
      return [];
    }
    const compareTo = this.equippedItemInSlot();
    const comparable = compareTo && compareTo.id !== item.id ? compareTo.item : null;

    return STAT_LABELS.map(({ label, key }) => ({
      label,
      value: item.item[key],
      diff: comparable ? item.item[key] - comparable[key] : null,
    })).filter((row) => row.value > 0 || (row.diff ?? 0) !== 0);
  });

  protected readonly isEquippable = computed(() => !!this.item()?.item.equipmentSlot);

  protected readonly meetsLevelRequirement = computed(() => {
    const item = this.item();
    return item ? item.item.requiredLevel <= this.characterLevel() : true;
  });

  protected onEquip(): void {
    const item = this.item();
    if (item) {
      this.equip.emit(item.id);
    }
  }
}
  • Step 4: Write the template
<!-- apps/web/src/app/features/inventory/inventory-detail-panel.component.html -->
@if (item(); as item) {
  <article class="inventory-detail" aria-label="Gegenstandsdetails">
    <div class="inventory-detail__header">
      <img class="inventory-detail__icon" [src]="item.item.iconPath" [alt]="item.item.name" />
      <div>
        <h2 class="inventory-detail__name" data-detail-name>{{ item.item.name }}</h2>
        <p class="inventory-detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
        @if (slotLabel(); as slot) {
          <p class="inventory-detail__meta">{{ slot }} · Stufe {{ item.item.requiredLevel }}</p>
        }
      </div>
    </div>

    @if (statRows().length) {
      <dl class="inventory-detail__stats" data-detail-stats>
        @for (row of statRows(); track row.label) {
          <div class="inventory-detail__stat">
            <dt>{{ row.label }}</dt>
            <dd>
              {{ row.value }}
              @if (row.diff !== null && row.diff !== 0) {
                <span
                  class="inventory-detail__diff"
                  [class.inventory-detail__diff--positive]="row.diff > 0"
                  [class.inventory-detail__diff--negative]="row.diff < 0"
                >
                  ({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
                </span>
              }
            </dd>
          </div>
        }
      </dl>
    }

    <div class="inventory-detail__actions">
      @if (item.equipped) {
        <span class="inventory-detail__equipped" data-detail-equipped>Ausgerüstet</span>
      } @else if (!isEquippable()) {
        <span class="inventory-detail__note">Nicht ausrüstbar</span>
      } @else if (!meetsLevelRequirement()) {
        <button type="button" class="inventory-detail__equip" data-detail-equip disabled>
          Benötigt Stufe {{ item.item.requiredLevel }}
        </button>
      } @else {
        <button
          type="button"
          class="inventory-detail__equip"
          data-detail-equip
          [disabled]="busy()"
          (click)="onEquip()"
        >
          Ausrüsten
        </button>
      }
    </div>
  </article>
} @else {
  <p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}
  • Step 5: Write the stylesheet
// 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
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

// 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<string | null>(null),
    loading: signal(false),
    equipping: signal(false),
    error: signal<string | null>(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<HTMLButtonElement>('.inventory-page__slot')[1].click();

    expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade');
  });

  it('shows the equipment overview with all seven slots and empty ones as Leer', async () => {
    const { fixture } = await setup();
    const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? '';

    expect(text).toContain('Waffe');
    expect(text).toContain('Abgenutztes Kurzschwert');
    expect(text).toContain('Kopf');
    expect(text).toContain('Leer');
  });

  it('shows the effective stats summary from the equipment response', async () => {
    const { fixture } = await setup();
    const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? '';

    expect(text).toContain('100');
    expect(text).toContain('6');
    expect(text).toContain('8');
  });
});
  • 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
// 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<Record<EquipmentSlot, string>> = {
  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<void> {
    await this.inventoryStore.equip(characterItemId);
  }

  protected retry(): void {
    void this.inventoryStore.load();
  }
}
  • Step 4: Write the template
<!-- apps/web/src/app/features/inventory/inventory-page.component.html -->
<section class="inventory-page" aria-label="Inventar">
  @if (inventoryStore.loading() && !inventoryStore.inventory()) {
    <p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
  } @else if (inventoryStore.inventory(); as inventory) {
    <section class="inventory-page__grid" aria-label="Gegenstände">
      @for (entry of inventory.items; track entry.id) {
        <button
          type="button"
          class="inventory-page__slot"
          [class.inventory-page__slot--selected]="inventoryStore.selectedItemId() === entry.id"
          [attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
          (click)="selectItem(entry.id)"
        >
          <app-item-card [item]="entry.item" [quantity]="entry.quantity" />
          @if (entry.equipped) {
            <span class="inventory-page__equipped-badge" data-slot-equipped>Ausgerüstet</span>
          }
        </button>
      } @empty {
        <p class="inventory-page__empty" data-inventory-empty>Noch keine Gegenstände gefunden.</p>
      }
    </section>

    <aside class="inventory-page__side" aria-label="Details und Ausrüstung">
      <app-inventory-detail-panel
        [item]="inventoryStore.selectedItem()"
        [equippedItemInSlot]="equippedItemInSelectedSlot()"
        [characterLevel]="characterLevel()"
        [busy]="inventoryStore.equipping()"
        (equip)="equipSelected($event)"
      />

      @if (inventoryStore.equipment(); as equipment) {
        <section class="inventory-page__equipment" aria-label="Ausrüstung">
          <h3>Ausrüstung</h3>
          <ul class="inventory-page__equipment-list">
            @for (slot of slotOrder; track slot) {
              <li>
                <span class="inventory-page__equipment-slot-label">{{ slotLabels[slot] }}</span>
                <span class="inventory-page__equipment-slot-value">
                  {{ equipment.slots[slot]?.item?.name ?? 'Leer' }}
                </span>
              </li>
            }
          </ul>

          <dl class="inventory-page__stats" data-inventory-stats>
            <div><dt>Leben</dt><dd>{{ equipment.stats.maxHp }}</dd></div>
            <div><dt>Angriff</dt><dd>{{ equipment.stats.attack }}</dd></div>
            <div><dt>Waffenschaden</dt><dd>{{ equipment.stats.weaponDamage }}</dd></div>
            <div><dt>Rüstung</dt><dd>{{ equipment.stats.armor }}</dd></div>
          </dl>
        </section>
      }
    </aside>
  }

  @if (inventoryStore.error(); as error) {
    <section class="inventory-page__notice inventory-page__notice--error" role="alert">
      <p>{{ error }}</p>
      <button type="button" data-inventory-retry (click)="retry()">Erneut versuchen</button>
    </section>
  }
</section>
  • Step 5: Write the stylesheet
// 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
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

// 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:

  <button
    class="side-navigation__item"
    type="button"
    routerLink="/inventory"
    routerLinkActive="side-navigation__item--active"
    [routerLinkActiveOptions]="{ exact: true }"
    ariaCurrentWhenActive="page"
    data-navigation="inventory"
    aria-label="Inventar"
  >
    <img src="/images/hud/runtime/InventoryIcon-128.png" alt="" />
    <span>Inventar</span>
  </button>
  • 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
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:

  it('navigates to /inventory from the victory screen', async () => {
    const fixture = await setup({ ...activeCombat, status: 'WON' });
    const element = fixture.nativeElement as HTMLElement;

    element.querySelector<HTMLButtonElement>('[data-combat-to-inventory]')?.click();

    expect(router.navigate).toHaveBeenCalledWith(['/inventory']);
  });
  • 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():

  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:

          <div class="outcome__actions">
            <button type="button" class="outcome__button" data-combat-to-inventory (click)="goToInventory()">
              Inventar öffnen
            </button>
            <button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
              Zur Jagd
            </button>
          </div>

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:

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