Files
ashen-realms/docs/superpowers/plans/2026-08-20-playable-slice-0.5-first-upgrade.md
Bastian Wagner 8a5f57fa1c specs
2026-08-20 16:17:19 +02:00

3993 lines
135 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `CharacterCombatStatsService``CharacterStatsService`)
- `apps/api/src/characters/characters.service.ts` + `.spec.ts` (effective attack/HP)
- `apps/api/src/combat/combat.service.ts` + `.spec.ts`, `apps/api/src/combat/combat.module.ts` (consume `CharacterStatsService`)
- `apps/api/src/app.module.ts` (register `EquipmentModule`, `InventoryModule`)
- `apps/api/src/demo/demo-character.constants.ts` (two stable seed IDs)
- `apps/api/src/database/seeds/vertical-slice.seed.ts` + `.spec.ts` (starting sword as real equipped item)
**Backend — deleted files:**
- `apps/api/src/characters/character-combat-stats.service.ts` + `.spec.ts`
**Frontend — new files:**
- `apps/web/src/app/features/inventory/inventory.store.ts` + `.spec.ts`
- `apps/web/src/app/features/inventory/inventory-detail-panel.component.ts` + `.html` + `.scss` + `.spec.ts`
- `apps/web/src/app/features/inventory/inventory-page.component.ts` + `.html` + `.scss` + `.spec.ts`
**Frontend — modified files:**
- `apps/web/src/app/core/api/game-api.models.ts` (inventory/equipment types)
- `apps/web/src/app/core/api/game-api.service.ts` (3 new HTTP calls)
- `apps/web/src/app/app.routes.ts` (`/inventory` route)
- `apps/web/src/app/layout/side-navigation/side-navigation.component.html` (enable Inventar)
- `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` + `.html` + `.spec.ts` (Inventar öffnen button)
---
## Task 1: `character_equipment` table and entity
**Files:**
- Create: `apps/api/src/equipment/entities/character-equipment.entity.ts`
- Create: `apps/api/src/database/migrations/1789000000000-CreateEquipment.ts`
- Create: `apps/api/src/database/migrations/equipment.migration.spec.ts`
**Interfaces:**
- Produces: `CharacterEquipment` entity (`id`, `characterId`, `slot: EquipmentSlot`, `characterItemId`, `createdAt`, `updatedAt`, relations `character`, `characterItem`). Table `character_equipment` with `UNIQUE(character_id, slot)` and `UNIQUE(character_item_id)`.
- [ ] **Step 1: Write the failing migration-metadata test**
Create `apps/api/src/database/migrations/equipment.migration.spec.ts` (this project's migration specs assert TypeORM entity metadata rather than running against a live DB — see `encounter-status.migration.spec.ts`):
```ts
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
describe('character_equipment schema', () => {
it('stores slot as a non-nullable equipment_slot_enum column', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === CharacterEquipment && candidate.propertyName === 'slot',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('enum');
expect(column?.options.enumName).toBe('equipment_slot_enum');
expect(column?.options.nullable).toBeFalsy();
});
it('enforces one equipped item per character per slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.includes('characterId') &&
candidate.columns?.includes('slot'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('forbids one CharacterItem from occupying more than one equipment slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.length === 1 &&
candidate.columns?.includes('characterItemId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
});
```
- [ ] **Step 2: Run it to confirm it fails**
Run: `npm run test --workspace=@ashen-realms/api -- equipment.migration.spec.ts`
Expected: FAIL — `Cannot find module '../../equipment/entities/character-equipment.entity'`.
- [ ] **Step 3: Create the entity**
```ts
// apps/api/src/equipment/entities/character-equipment.entity.ts
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { EquipmentSlot } from '../../items/equipment-slot.enum';
/**
* One equipped item in one slot for one character (spec §12).
*
* `characterItemId` must belong to `characterId` — enforced by
* `EquipmentService`, never by the client (spec §13).
*/
@Entity({ name: 'character_equipment' })
@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], {
unique: true,
})
@Index('IDX_character_equipment_character_item', ['characterItemId'], {
unique: true,
})
export class CharacterEquipment {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({
name: 'slot',
type: 'enum',
enum: EquipmentSlot,
enumName: 'equipment_slot_enum',
})
slot!: EquipmentSlot;
@Column({ name: 'character_item_id', type: 'uuid' })
characterItemId!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => CharacterItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_item_id' })
characterItem!: CharacterItem;
}
```
- [ ] **Step 4: Run the metadata test again**
Run: `npm run test --workspace=@ashen-realms/api -- equipment.migration.spec.ts`
Expected: PASS (all 3 assertions).
- [ ] **Step 5: Write the migration**
```ts
// apps/api/src/database/migrations/1789000000000-CreateEquipment.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateEquipment1789000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<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**
```bash
git add apps/api/src/equipment/entities/character-equipment.entity.ts apps/api/src/database/migrations/1789000000000-CreateEquipment.ts apps/api/src/database/migrations/equipment.migration.spec.ts
git commit -m "feat(api): add character_equipment table and entity"
```
---
## Task 2: Equipment domain errors
**Files:**
- Create: `apps/api/src/equipment/equipment.errors.ts`
**Interfaces:**
- Produces: `EquipmentDomainError`, and factories `characterItemNotFound()`, `itemNotOwned()`, `itemNotEquippable()`, `itemLevelRequirementNotMet()`, `invalidEquipmentSlot()`, `characterInCombat()`, plus re-exported `characterNotFound()`.
- [ ] **Step 1: Write the file**
```ts
// apps/api/src/equipment/equipment.errors.ts
import { HttpException, HttpStatus } from '@nestjs/common';
export type EquipmentErrorCode =
| 'CHARACTER_ITEM_NOT_FOUND'
| 'ITEM_NOT_OWNED'
| 'ITEM_NOT_EQUIPPABLE'
| 'ITEM_LEVEL_REQUIREMENT_NOT_MET'
| 'INVALID_EQUIPMENT_SLOT'
| 'CHARACTER_IN_COMBAT';
export class EquipmentDomainError extends HttpException {
constructor(
public readonly code: EquipmentErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function characterItemNotFound(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_ITEM_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This item could not be found.',
);
}
export function itemNotOwned(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_OWNED',
HttpStatus.FORBIDDEN,
'This item does not belong to the character.',
);
}
export function itemNotEquippable(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_EQUIPPABLE',
HttpStatus.BAD_REQUEST,
'This item cannot be equipped.',
);
}
export function itemLevelRequirementNotMet(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
HttpStatus.BAD_REQUEST,
"The character does not meet this item's level requirement.",
);
}
// Defensive: slot is always derived from the item definition server-side, so
// this is unreachable in practice (spec §27 still names it explicitly).
export function invalidEquipmentSlot(): EquipmentDomainError {
return new EquipmentDomainError(
'INVALID_EQUIPMENT_SLOT',
HttpStatus.BAD_REQUEST,
'This item does not target a valid equipment slot.',
);
}
export function characterInCombat(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_IN_COMBAT',
HttpStatus.CONFLICT,
'Equipment cannot be changed during an active combat.',
);
}
export { characterNotFound } from '../travel/travel.errors';
```
- [ ] **Step 2: Compile**
Run: `npm run build --workspace=@ashen-realms/api`
Expected: builds cleanly.
- [ ] **Step 3: Commit**
```bash
git add apps/api/src/equipment/equipment.errors.ts
git commit -m "feat(api): add equipment domain errors"
```
---
## Task 3: `CharacterStatsService`
**Files:**
- Create: `apps/api/src/characters/character-stats.service.ts`
- Test: `apps/api/src/characters/character-stats.service.spec.ts`
**Interfaces:**
- Consumes: `CharacterEquipment` entity (Task 1), `Character` entity.
- Produces: `EffectiveCharacterStats { maxHp, currentHp, attack, weaponDamage, armor, combatPower }` and `CharacterStatsService.calculate(character: Character, scope?: Pick<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)**
```ts
// apps/api/src/characters/character-stats.service.spec.ts
import { DataSource } from 'typeorm';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemDefinition } from '../items/entities/item-definition.entity';
type EquippedFixture = {
slot: EquipmentSlot;
item: Partial<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**
```ts
// apps/api/src/characters/character-stats.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { Character } from './entities/character.entity';
export interface EffectiveCharacterStats {
maxHp: number;
currentHp: number;
attack: number;
weaponDamage: number;
armor: number;
combatPower: number;
}
type RepositoryScope = Pick<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**
```bash
git add apps/api/src/characters/character-stats.service.ts apps/api/src/characters/character-stats.service.spec.ts
git commit -m "feat(api): add CharacterStatsService as authoritative effective-stat source"
```
---
## Task 4: Retire `CharacterCombatStatsService`; wire `CharacterStatsService` into `CharactersModule`
**Files:**
- Modify: `apps/api/src/characters/characters.module.ts`
- Modify: `apps/api/src/characters/characters.service.ts`
- Modify: `apps/api/src/characters/characters.service.spec.ts`
- Delete: `apps/api/src/characters/character-combat-stats.service.ts`
- Delete: `apps/api/src/characters/character-combat-stats.service.spec.ts`
**Interfaces:**
- Consumes: `CharacterStatsService.calculate()` (Task 3).
- Produces: `CharactersService.getDemoCharacter()` unchanged response shape, but `attack`/`maxHp` are now the *effective* values (spec §17 — remove the Slice 0.3 shortcut).
- [ ] **Step 1: Update the failing test first**
Edit `apps/api/src/characters/characters.service.spec.ts` — replace every `new CharactersService(repository)` call with a version that also passes a fake `CharacterStatsService`, and assert it is invoked:
```ts
import { NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { CharactersService } from './characters.service';
function fakeCharacterStats(
overrides: Partial<{ maxHp: number; attack: number }> = {},
): CharacterStatsService {
return {
calculate: jest.fn().mockResolvedValue({
maxHp: overrides.maxHp ?? 100,
currentHp: 100,
attack: overrides.attack ?? 6,
weaponDamage: 8,
armor: 0,
combatPower: 0,
}),
} as unknown as CharacterStatsService;
}
describe('CharactersService', () => {
it('returns the demo character with effective attack/HP and its location summary', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
currentHp: 100,
baseHp: 100,
baseAttack: 6,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'Südtor von Graufurt',
},
}),
} as unknown as Repository<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`**
```ts
// apps/api/src/characters/characters.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
@Injectable()
export class CharactersService {
constructor(
@InjectRepository(Character)
private readonly characters: Repository<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`**
```ts
// apps/api/src/characters/characters.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterStatsService } from './character-stats.service';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Character])],
controllers: [CharactersController],
providers: [CharactersService, CharacterStatsService],
exports: [CharacterStatsService],
})
export class CharactersModule {}
```
- [ ] **Step 5: Delete the retired service and its spec**
```bash
git rm apps/api/src/characters/character-combat-stats.service.ts apps/api/src/characters/character-combat-stats.service.spec.ts
```
- [ ] **Step 6: Run the full characters test suite**
Run: `npm run test --workspace=@ashen-realms/api -- characters`
Expected: PASS.
- [ ] **Step 7: Build**
Run: `npm run build --workspace=@ashen-realms/api`
Expected: builds cleanly (confirms no other file still imports `CharacterCombatStatsService`; Task 5 fixes `combat.service.ts`, so this build will still fail until Task 5 completes — that's expected and fine at this checkpoint if it fails only on `combat.service.ts`/`combat.module.ts`).
- [ ] **Step 8: Commit**
```bash
git add apps/api/src/characters/characters.module.ts apps/api/src/characters/characters.service.ts apps/api/src/characters/characters.service.spec.ts
git commit -m "feat(api): retire CharacterCombatStatsService; characters/me returns effective stats"
```
---
## Task 5: Wire `CharacterStatsService` into `CombatService`
**Files:**
- Modify: `apps/api/src/combat/combat.service.ts`
- Modify: `apps/api/src/combat/combat.module.ts`
- Modify: `apps/api/src/combat/combat.service.spec.ts`
**Interfaces:**
- Consumes: `CharacterStatsService.calculate(character, manager)` (Task 3), exported by `CharactersModule` (Task 4).
- Produces: `CombatService`'s constructor now takes `CharacterStatsService` instead of `CharacterCombatStatsService`; `startCombat`'s player snapshot is computed via `calculate()` inside the existing transaction.
- [ ] **Step 1: Update the test fakes first**
In `apps/api/src/combat/combat.service.spec.ts`:
- Replace the import `import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';` with `import { CharacterStatsService } from '../characters/character-stats.service';`.
- Add this helper near the other `fake*` helpers:
```ts
function fakeCharacterStats(): CharacterStatsService {
return {
calculate: jest.fn(async (character: Character) => ({
maxHp: character.baseHp,
currentHp: character.currentHp,
attack: character.baseAttack,
weaponDamage: 8,
armor: 6,
combatPower: 0,
})),
} as unknown as CharacterStatsService;
}
```
- In `createService()`, replace `const characterCombatStats = new CharacterCombatStatsService();` with `const characterCombatStats = fakeCharacterStats();` (keep the local variable name — it's passed positionally into `new CombatService(...)` unchanged).
- In the `rewards` describe block, replace each of the four `new CharacterCombatStatsService()` call sites (inline inside `new CombatService(dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), new CharacterCombatStatsService(), rewards)`) with `fakeCharacterStats()`.
These fakes reproduce the exact old constants (`weaponDamage: 8, armor: 6`) so every existing assertion (e.g. `playerState: { attack: 6, weaponDamage: 8, armor: 6 }`) keeps passing unchanged — this step only swaps *which service* supplies those numbers, not the numbers themselves.
- [ ] **Step 2: Run to confirm failure**
Run: `npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts`
Expected: FAIL — `Cannot find module '../characters/character-stats.service'` (doesn't exist as an import target yet in this file) or a leftover reference to the deleted `CharacterCombatStatsService` import.
- [ ] **Step 3: Update `CombatService`**
In `apps/api/src/combat/combat.service.ts`:
- Replace the import: `import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';``import { CharacterStatsService } from '../characters/character-stats.service';`.
- Rename the constructor parameter and its type:
```ts
constructor(
private readonly dataSource: DataSource,
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterStats: CharacterStatsService,
private readonly combatRewards: CombatRewardService,
) {}
```
- In `startCombat`, replace the synchronous call with an awaited, transaction-scoped call (this is the only behavioral line-change in this file):
```ts
const playerStats = await this.characterStats.calculate(character, manager);
```
(This line sits where `const playerStats = this.characterCombatStats.getStats(character);` used to be, inside the `this.dataSource.transaction(async (manager) => { ... })` callback — pass `manager`, not `this.dataSource`, so the read is scoped to the same transaction as everything else in `startCombat`.)
- [ ] **Step 4: Update `CombatModule`**
In `apps/api/src/combat/combat.module.ts`, `CharactersModule` is already imported and now exports `CharacterStatsService` (Task 4) instead of `CharacterCombatStatsService` — no import-list change needed in this file, since `CombatService` resolves `CharacterStatsService` through Nest's DI via the already-imported `CharactersModule`.
- [ ] **Step 5: Run the combat test suite**
Run: `npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts`
Expected: PASS (all existing tests, unchanged assertions).
- [ ] **Step 6: Full API build**
Run: `npm run build --workspace=@ashen-realms/api`
Expected: builds cleanly — this confirms no remaining reference to the deleted `CharacterCombatStatsService` anywhere in the codebase.
- [ ] **Step 7: Commit**
```bash
git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts
git commit -m "feat(api): combat snapshots now use CharacterStatsService"
```
---
## Task 6: `EquipmentService`, `EquipmentController`, `EquipmentModule`
**Files:**
- Create: `apps/api/src/equipment/dto/equip-item.dto.ts`
- Create: `apps/api/src/equipment/equipment.service.ts`
- Test: `apps/api/src/equipment/equipment.service.spec.ts`
- Create: `apps/api/src/equipment/equipment.controller.ts`
- Create: `apps/api/src/equipment/equipment.module.ts`
- Modify: `apps/api/src/app.module.ts`
**Interfaces:**
- Consumes: `CharacterEquipment` entity (Task 1), equipment errors (Task 2), `CharacterStatsService` (Task 3, exported by `CharactersModule`).
- Produces: `EquipmentResponseDto { slots: Record<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)**
```ts
// apps/api/src/equipment/equipment.service.spec.ts
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentDomainError } from './equipment.errors';
import { EquipmentService } from './equipment.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
const BANDIT_HOOD_ITEM_ID = '70000000-0000-4000-8000-000000000003';
interface State {
characters: Character[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
combats: Combat[];
}
class FakeRepository<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**
```ts
// apps/api/src/equipment/dto/equip-item.dto.ts
import { IsUUID } from 'class-validator';
export class EquipItemDto {
@IsUUID()
characterItemId!: string;
}
```
- [ ] **Step 4: Implement `EquipmentService`**
```ts
// apps/api/src/equipment/equipment.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import {
characterInCombat,
characterItemNotFound,
characterNotFound,
itemLevelRequirementNotMet,
itemNotEquippable,
itemNotOwned,
} from './equipment.errors';
export interface EquipmentSlotItemDto {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
}
export type EquipmentSlotsDto = Record<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**
```ts
// apps/api/src/equipment/equipment.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { EquipItemDto } from './dto/equip-item.dto';
import { EquipmentResponseDto, EquipmentService } from './equipment.service';
@Controller('equipment')
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
@Get()
getEquipment(): Promise<EquipmentResponseDto> {
return this.equipmentService.getEquipment(DEMO_CHARACTER_ID);
}
@Post()
equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId);
}
}
```
```ts
// apps/api/src/equipment/equipment.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersModule } from '../characters/characters.module';
import { Character } from '../characters/entities/character.entity';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]),
CharactersModule,
],
controllers: [EquipmentController],
providers: [EquipmentService],
exports: [EquipmentService],
})
export class EquipmentModule {}
```
- [ ] **Step 7: Register `EquipmentModule` in `AppModule`**
```ts
// apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module';
import { EquipmentModule } from './equipment/equipment.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@Module({
imports: [
DatabaseModule,
HealthModule,
CharactersModule,
TravelModule,
WorldModule,
HuntingModule,
CombatModule,
EquipmentModule,
],
})
export class AppModule {}
```
(`InventoryModule` is added to this same list in Task 7.)
- [ ] **Step 8: Build**
Run: `npm run build --workspace=@ashen-realms/api`
Expected: builds cleanly.
- [ ] **Step 9: Commit**
```bash
git add apps/api/src/equipment apps/api/src/app.module.ts
git commit -m "feat(api): add equipment API (GET/POST /api/equipment)"
```
---
## Task 7: `InventoryService`, `InventoryController`, `InventoryModule`
**Files:**
- Create: `apps/api/src/inventory/inventory.service.ts`
- Test: `apps/api/src/inventory/inventory.service.spec.ts`
- Create: `apps/api/src/inventory/inventory.controller.ts`
- Create: `apps/api/src/inventory/inventory.module.ts`
- Modify: `apps/api/src/app.module.ts`
**Interfaces:**
- Consumes: `CharacterItem`, `CharacterEquipment` (Task 1) via `@InjectRepository`.
- Produces: `InventoryResponseDto { items: InventoryItemDto[] }` where `InventoryItemDto = { id, quantity, equipped, item: { key, name, rarity, equipmentSlot, requiredLevel, weaponDamage, bonusAttack, bonusHp, bonusArmor, iconPath } }`. Route `GET /api/inventory`.
- [ ] **Step 1: Write the failing tests (spec §44)**
```ts
// apps/api/src/inventory/inventory.service.spec.ts
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { InventoryService } from './inventory.service';
const CHARACTER_ID = 'character-1';
function characterItem(overrides: Partial<CharacterItem> = {}): 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`**
```ts
// apps/api/src/inventory/inventory.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
export interface InventoryItemDto {
id: string;
quantity: number;
equipped: boolean;
item: {
key: string;
name: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;
weaponDamage: number;
bonusAttack: number;
bonusHp: number;
bonusArmor: number;
iconPath: string;
};
}
export interface InventoryResponseDto {
items: InventoryItemDto[];
}
@Injectable()
export class InventoryService {
constructor(
@InjectRepository(CharacterItem)
private readonly characterItems: Repository<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**
```ts
// apps/api/src/inventory/inventory.controller.ts
import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { InventoryResponseDto, InventoryService } from './inventory.service';
@Controller('inventory')
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
@Get()
getInventory(): Promise<InventoryResponseDto> {
return this.inventoryService.getInventory(DEMO_CHARACTER_ID);
}
}
```
```ts
// apps/api/src/inventory/inventory.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
@Module({
imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])],
controllers: [InventoryController],
providers: [InventoryService],
})
export class InventoryModule {}
```
- [ ] **Step 6: Register `InventoryModule` in `AppModule`**
```ts
// apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module';
import { EquipmentModule } from './equipment/equipment.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { InventoryModule } from './inventory/inventory.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@Module({
imports: [
DatabaseModule,
HealthModule,
CharactersModule,
TravelModule,
WorldModule,
HuntingModule,
CombatModule,
EquipmentModule,
InventoryModule,
],
})
export class AppModule {}
```
- [ ] **Step 7: Build**
Run: `npm run build --workspace=@ashen-realms/api`
Expected: builds cleanly.
- [ ] **Step 8: Commit**
```bash
git add apps/api/src/inventory apps/api/src/app.module.ts
git commit -m "feat(api): add inventory API (GET /api/inventory)"
```
---
## Task 8: Combat + equipment integration proof (spec §45, §60)
**Files:**
- Create: `apps/api/src/combat/combat-equipment-integration.spec.ts`
**Interfaces:**
- Consumes: real `CombatService` (Task 5), real `EquipmentService` (Task 6), real `CharacterStatsService` (Task 3) composed over one shared fake `DataSource`.
This is the critical proof: equipping Räuberklinge must make a *later* combat deal more damage, not just change a stored number. It composes the real services together (not mocks) the same way `combat.service.spec.ts`'s existing reward tests already compose `CombatService` + `CombatRewardService` over one shared fake `DataSource`.
- [ ] **Step 1: Write the failing integration test**
```ts
// apps/api/src/combat/combat-equipment-integration.spec.ts
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentService } from '../equipment/equipment.service';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { CombatRewardService } from '../rewards/combat-reward.service';
import { TravelService } from '../travel/travel.service';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatService } from './combat.service';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
interface FakeState {
characters: Character[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
monsters: MonsterDefinition[];
combats: Combat[];
combatEvents: CombatEvent[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
}
class FakeRepository<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**
```bash
git add apps/api/src/combat/combat-equipment-integration.spec.ts
git commit -m "test(api): prove equipping a weapon upgrade increases future combat damage"
```
---
## Task 9: Starting equipment in the seed (spec §17, §55)
**Files:**
- Modify: `apps/api/src/demo/demo-character.constants.ts`
- Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts`
- Modify: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`
**Interfaces:**
- Produces: two new stable ID constants; the demo character always ends up owning and having equipped `worn-short-sword` via real `CharacterItem`/`CharacterEquipment` rows, idempotently, without ever overwriting a player-earned equip.
- [ ] **Step 1: Add the stable seed IDs**
```ts
// apps/api/src/demo/demo-character.constants.ts
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID =
'10000000-0000-4000-8000-000000000002';
export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID =
'10000000-0000-4000-8000-000000000003';
```
- [ ] **Step 2: Write the failing seed test**
Edit `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`:
- Add imports: `CharacterItem` from `'../../items/entities/character-item.entity'` and `CharacterEquipment` from `'../../equipment/entities/character-equipment.entity'`, and `DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID` from `'../../demo/demo-character.constants'`.
- Extend `createDataSource(...)` with two more optional-defaulted parameters and branches:
```ts
function createDataSource(
locationRepository: InMemoryRepository,
connectionRepository: InMemoryRepository,
characterRepository: InMemoryRepository,
monsterRepository: InMemoryRepository,
locationMonsterRepository: InMemoryRepository,
itemRepository: InMemoryRepository = new InMemoryRepository(),
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
): DataSource {
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === LocationDefinition) return locationRepository;
if (entity === LocationConnection) return connectionRepository;
if (entity === Character) return characterRepository;
if (entity === MonsterDefinition) return monsterRepository;
if (entity === LocationMonster) return locationMonsterRepository;
if (entity === ItemDefinition) return itemRepository;
if (entity === LootTable) return lootTableRepository;
if (entity === LootTableEntry) return lootEntryRepository;
if (entity === CharacterItem) return characterItemRepository;
if (entity === CharacterEquipment) return characterEquipmentRepository;
throw new Error('Unexpected repository');
}),
} as unknown as DataSource;
}
```
- Add a new `describe` block at the end of the file (before the final closing of the outer `describe`):
```ts
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(characterItemRepository.rows).toHaveLength(1);
expect(characterItemRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
}),
);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]).toEqual(
expect.objectContaining({
characterId: DEMO_CHARACTER_ID,
slot: 'WEAPON',
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
}),
);
});
it('never re-equips the starting sword once the player has equipped different gear', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const characterItemRepository = new InMemoryRepository();
const characterEquipmentRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterItemRepository,
characterEquipmentRepository,
);
await seedVisibleVerticalSlice(dataSource);
// Simulate the player having equipped earned loot instead.
characterEquipmentRepository.rows[0]['characterItemId'] = 'earned-bandit-blade-item-id';
await seedVisibleVerticalSlice(dataSource);
expect(characterEquipmentRepository.rows).toHaveLength(1);
expect(characterEquipmentRepository.rows[0]['characterItemId']).toBe(
'earned-bandit-blade-item-id',
);
expect(characterItemRepository.rows).toHaveLength(1);
});
```
- [ ] **Step 3: Run to confirm failure**
Run: `npm run test --workspace=@ashen-realms/api -- vertical-slice.seed.spec.ts`
Expected: FAIL — `characterItemRepository.rows` is empty (seed doesn't create the row yet).
- [ ] **Step 4: Update the seed**
In `apps/api/src/database/seeds/vertical-slice.seed.ts`:
- Add imports: `CharacterItem` from `'../../items/entities/character-item.entity'`, `CharacterEquipment` from `'../../equipment/entities/character-equipment.entity'`, `EquipmentSlot` from `'../../items/equipment-slot.enum'`, and `DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID`, `DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID` from `'../../demo/demo-character.constants'` (alongside the existing `DEMO_CHARACTER_ID` import).
- After the existing `if (!existing) { await characterRepository.insert({...}); }` block, unconditionally (so it also backfills a demo character seeded before Slice 0.5), add:
```ts
const characterItemRepository = dataSource.getRepository(CharacterItem);
const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment);
const existingStartingSword = await characterItemRepository.findOneBy({
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
});
if (!existingStartingSword) {
await characterItemRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
});
}
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
});
if (!existingWeaponEquipment) {
await characterEquipmentRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
});
}
```
- [ ] **Step 5: Run the tests again**
Run: `npm run test --workspace=@ashen-realms/api -- vertical-slice.seed.spec.ts`
Expected: PASS (all tests, including the two new ones).
- [ ] **Step 6: Run the full API suite and build**
Run: `npm run test --workspace=@ashen-realms/api && npm run build --workspace=@ashen-realms/api`
Expected: all green, clean build.
- [ ] **Step 7: Commit**
```bash
git add apps/api/src/demo/demo-character.constants.ts apps/api/src/database/seeds/vertical-slice.seed.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts
git commit -m "feat(api): seed the starting sword as a real, equipped CharacterItem"
```
---
## Task 10: Frontend API models and client methods
**Files:**
- Modify: `apps/web/src/app/core/api/game-api.models.ts`
- Modify: `apps/web/src/app/core/api/game-api.service.ts`
**Interfaces:**
- Produces: `EquipmentSlot`, `InventoryItem`, `InventoryResponse`, `EquipmentSlotItem`, `EquipmentSlots`, `EquipmentStats`, `EquipmentResponse` types, and `GameApiService.getInventory()`, `getEquipment()`, `equipItem(characterItemId)`.
- [ ] **Step 1: Add the models**
Append to `apps/web/src/app/core/api/game-api.models.ts`:
```ts
export type EquipmentSlot =
| 'WEAPON'
| 'HEAD'
| 'CHEST'
| 'HANDS'
| 'LEGS'
| 'FEET'
| 'AMULET';
export interface InventoryItem {
id: string;
quantity: number;
equipped: boolean;
item: {
key: string;
name: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;
weaponDamage: number;
bonusAttack: number;
bonusHp: number;
bonusArmor: number;
iconPath: string;
};
}
export interface InventoryResponse {
items: InventoryItem[];
}
export interface EquipmentSlotItem {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
}
export type EquipmentSlots = Record<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:
```ts
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**
```bash
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/core/api/game-api.service.ts
git commit -m "feat(web): add inventory/equipment API models and client methods"
```
---
## Task 11: `InventoryStore`
**Files:**
- Create: `apps/web/src/app/features/inventory/inventory.store.ts`
- Test: `apps/web/src/app/features/inventory/inventory.store.spec.ts`
**Interfaces:**
- Consumes: `GameApiService.getInventory/getEquipment/equipItem` (Task 10), `WorldStore.refreshCharacter()` (existing).
- Produces: `InventoryStore` with readonly signals `inventory`, `equipment`, `selectedItemId`, `loading`, `equipping`, `error`; methods `load()`, `selectItem(id)`, `selectedItem()`, `equip(characterItemId)`. Task 13's page component and Task 12's detail panel consume these exact names.
- [ ] **Step 1: Write the failing test**
```ts
// apps/web/src/app/features/inventory/inventory.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type { EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
import { InventoryStore } from './inventory.store';
const inventory: InventoryResponse = {
items: [
{
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
},
],
};
const equipment: EquipmentResponse = {
slots: {
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
},
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
};
const equippedAfter: EquipmentResponse = {
...equipment,
slots: { ...equipment.slots, WEAPON: { characterItemId: 'item-blade', item: { key: 'bandit-blade', name: 'Räuberklinge', rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png' } } },
stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 },
};
describe('InventoryStore', () => {
let api: {
getInventory: ReturnType<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**
```ts
// apps/web/src/app/features/inventory/inventory.store.ts
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom, forkJoin } from 'rxjs';
import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.';
// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`.
const EQUIPMENT_ERROR_MESSAGES: Readonly<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**
```bash
git add apps/web/src/app/features/inventory/inventory.store.ts apps/web/src/app/features/inventory/inventory.store.spec.ts
git commit -m "feat(web): add InventoryStore"
```
---
## Task 12: Item detail/comparison panel component
**Files:**
- Create: `apps/web/src/app/features/inventory/inventory-detail-panel.component.ts`
- Create: `apps/web/src/app/features/inventory/inventory-detail-panel.component.html`
- Create: `apps/web/src/app/features/inventory/inventory-detail-panel.component.scss`
- Test: `apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts`
**Interfaces:**
- Consumes: `InventoryItem` (Task 10), `RARITY_LABELS` (existing, from `apps/web/src/app/shared/item-card/item-card.component.ts`).
- Produces: `InventoryDetailPanelComponent` with inputs `item`, `equippedItemInSlot`, `characterLevel`, `busy`, and output `equip: OutputEmitterRef<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**
```ts
// apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type { InventoryItem } from '../../core/api/game-api.models';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
const wornSword: InventoryItem = {
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
};
const banditBlade: InventoryItem = {
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
};
async function setup(overrides: {
item?: InventoryItem | null;
equippedItemInSlot?: InventoryItem | null;
characterLevel?: number;
busy?: boolean;
}) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({ imports: [InventoryDetailPanelComponent] }).compileComponents();
const fixture = TestBed.createComponent(InventoryDetailPanelComponent);
fixture.componentRef.setInput('item', overrides.item ?? null);
fixture.componentRef.setInput('equippedItemInSlot', overrides.equippedItemInSlot ?? null);
fixture.componentRef.setInput('characterLevel', overrides.characterLevel ?? 1);
fixture.componentRef.setInput('busy', overrides.busy ?? false);
fixture.detectChanges();
return fixture;
}
describe('InventoryDetailPanelComponent', () => {
it('shows a placeholder when nothing is selected', async () => {
const fixture = await setup({ item: null });
expect((fixture.nativeElement as HTMLElement).querySelector('[data-detail-empty]')).not.toBeNull();
});
it('shows Ausgerüstet for the currently equipped item, with no equip button', async () => {
const fixture = await setup({ item: wornSword });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-detail-equipped]')?.textContent).toContain('Ausgerüstet');
expect(element.querySelector('[data-detail-equip]')).toBeNull();
});
it('shows the stat comparison against the equipped item in the same slot', async () => {
const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword });
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? '';
expect(text).toContain('11');
expect(text).toContain('+3');
expect(text).toContain('+1');
});
it('shows a disabled Benötigt Stufe X button when the level requirement is not met', async () => {
const fixture = await setup({ item: { ...banditBlade, item: { ...banditBlade.item, requiredLevel: 5 } }, characterLevel: 1 });
const button = (fixture.nativeElement as HTMLElement).querySelector<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**
```ts
// apps/web/src/app/features/inventory/inventory-detail-panel.component.ts
import { Component, computed, input, output } from '@angular/core';
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
import { RARITY_LABELS } from '../../shared/item-card/item-card.component';
const SLOT_LABELS: Readonly<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**
```html
<!-- 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**
```scss
// apps/web/src/app/features/inventory/inventory-detail-panel.component.scss
:host {
display: block;
}
.inventory-detail {
padding: var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
background:
linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96);
box-shadow: var(--ar-shadow-raised);
}
.inventory-detail__header {
display: flex;
gap: var(--ar-space-3);
align-items: center;
margin-block-end: var(--ar-space-3);
}
.inventory-detail__icon {
inline-size: 4rem;
block-size: 4rem;
padding: var(--ar-space-1);
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, #1b1f22, #0d1012);
object-fit: contain;
}
.inventory-detail__name {
margin: 0;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.15rem;
font-weight: 400;
}
.inventory-detail__rarity {
margin: 0.15rem 0 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.inventory-detail__meta {
margin: 0.25rem 0 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.inventory-detail__stats {
display: grid;
gap: var(--ar-space-2);
margin: 0 0 var(--ar-space-3);
padding-block: var(--ar-space-2);
border-block: 1px solid rgb(155 122 66 / 0.45);
}
.inventory-detail__stat {
display: flex;
justify-content: space-between;
}
.inventory-detail__stat dt {
color: var(--ar-text-muted);
}
.inventory-detail__stat dd {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-detail__diff--positive {
color: var(--ar-success);
}
.inventory-detail__diff--negative {
color: var(--ar-danger);
}
.inventory-detail__actions {
display: flex;
justify-content: center;
}
.inventory-detail__equipped {
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-detail__note {
color: var(--ar-text-muted);
font-style: italic;
}
.inventory-detail__equip {
inline-size: 100%;
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #263b4b, #17232d);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
}
.inventory-detail__equip:hover:not(:disabled) {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.inventory-detail__equip:disabled {
border-color: var(--ar-border);
color: var(--ar-text-muted);
background: #1a1c1d;
}
.inventory-detail__empty {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
font-style: italic;
text-align: center;
}
```
- [ ] **Step 6: Run the tests again**
Run: `npm run test --workspace=@ashen-realms/web -- inventory-detail-panel.component.spec.ts`
Expected: PASS (all 6 tests).
- [ ] **Step 7: Commit**
```bash
git add apps/web/src/app/features/inventory/inventory-detail-panel.component.ts apps/web/src/app/features/inventory/inventory-detail-panel.component.html apps/web/src/app/features/inventory/inventory-detail-panel.component.scss apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts
git commit -m "feat(web): add inventory item detail/comparison panel"
```
---
## Task 13: Inventory page (grid + equipment overview + effective stats)
**Files:**
- Create: `apps/web/src/app/features/inventory/inventory-page.component.ts`
- Create: `apps/web/src/app/features/inventory/inventory-page.component.html`
- Create: `apps/web/src/app/features/inventory/inventory-page.component.scss`
- Test: `apps/web/src/app/features/inventory/inventory-page.component.spec.ts`
**Interfaces:**
- Consumes: `InventoryStore` (Task 11), `InventoryDetailPanelComponent` (Task 12), `ItemCardComponent` (existing), `WorldStore` (existing, for `character()?.level`).
- Produces: `InventoryPageComponent`, selector `app-inventory-page`. Task 14 routes `/inventory` to it.
- [ ] **Step 1: Write the failing test**
```ts
// apps/web/src/app/features/inventory/inventory-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type { CharacterResponse, EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models';
import { WorldStore } from '../world/world.store';
import { InventoryPageComponent } from './inventory-page.component';
import { InventoryStore } from './inventory.store';
const inventory: InventoryResponse = {
items: [
{
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
},
],
};
const equipment: EquipmentResponse = {
slots: {
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
},
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
};
const character: CharacterResponse = {
id: 'character-1',
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' },
};
async function setup() {
const inventoryStore = {
inventory: signal(inventory),
equipment: signal(equipment),
selectedItemId: signal<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**
```ts
// apps/web/src/app/features/inventory/inventory-page.component.ts
import { Component, OnInit, computed, inject } from '@angular/core';
import type { EquipmentSlot } from '../../core/api/game-api.models';
import { ItemCardComponent } from '../../shared/item-card/item-card.component';
import { WorldStore } from '../world/world.store';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
import { InventoryStore } from './inventory.store';
const SLOT_ORDER: readonly EquipmentSlot[] = [
'WEAPON',
'HEAD',
'CHEST',
'HANDS',
'LEGS',
'FEET',
'AMULET',
];
const SLOT_LABELS: Readonly<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**
```html
<!-- 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**
```scss
// apps/web/src/app/features/inventory/inventory-page.component.scss
:host {
display: block;
}
.inventory-page {
display: grid;
grid-template-columns: 1fr 20rem;
gap: var(--ar-space-5);
align-items: start;
padding: var(--ar-space-5);
}
.inventory-page__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: var(--ar-space-3);
}
.inventory-page__slot {
position: relative;
padding: var(--ar-space-2);
border: 1px solid transparent;
border-radius: var(--ar-radius-sm);
background: transparent;
}
.inventory-page__slot--selected {
border-color: var(--ar-border-highlight);
background: rgb(155 122 66 / 0.12);
}
.inventory-page__equipped-badge {
position: absolute;
inset-block-start: 0.1rem;
inset-inline-start: 50%;
translate: -50% 0;
padding: 0.05rem 0.4rem;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-gold);
background: rgb(9 11 13 / 0.9);
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__empty {
grid-column: 1 / -1;
padding: var(--ar-space-4);
color: var(--ar-text-muted);
font-style: italic;
}
.inventory-page__side {
display: grid;
gap: var(--ar-space-4);
}
.inventory-page__equipment {
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel);
}
.inventory-page__equipment h3 {
margin: 0 0 var(--ar-space-2);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
font-weight: 400;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__equipment-list {
display: grid;
gap: var(--ar-space-1);
margin: 0 0 var(--ar-space-3);
padding: 0;
list-style: none;
}
.inventory-page__equipment-list li {
display: flex;
justify-content: space-between;
padding-block: var(--ar-space-1);
border-block-end: 1px solid rgb(85 74 57 / 0.4);
font-size: var(--ar-font-sm);
}
.inventory-page__equipment-slot-label {
color: var(--ar-text-muted);
}
.inventory-page__stats {
display: grid;
gap: var(--ar-space-1);
margin: 0;
padding-block-start: var(--ar-space-2);
border-block-start: 1px solid rgb(155 122 66 / 0.45);
}
.inventory-page__stats div {
display: flex;
justify-content: space-between;
}
.inventory-page__stats dt {
color: var(--ar-text-muted);
}
.inventory-page__stats dd {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-page__notice {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
text-align: center;
}
.inventory-page__notice--error {
color: var(--ar-danger);
}
@media (width < 960px) {
.inventory-page {
grid-template-columns: 1fr;
}
}
```
- [ ] **Step 6: Run the tests again**
Run: `npm run test --workspace=@ashen-realms/web -- inventory-page.component.spec.ts`
Expected: PASS (all 6 tests).
- [ ] **Step 7: Commit**
```bash
git add apps/web/src/app/features/inventory/inventory-page.component.ts apps/web/src/app/features/inventory/inventory-page.component.html apps/web/src/app/features/inventory/inventory-page.component.scss apps/web/src/app/features/inventory/inventory-page.component.spec.ts
git commit -m "feat(web): add inventory page with grid, detail panel, and equipment overview"
```
---
## Task 14: Route and side-nav wiring
**Files:**
- Modify: `apps/web/src/app/app.routes.ts`
- Modify: `apps/web/src/app/layout/side-navigation/side-navigation.component.html`
**Interfaces:**
- Consumes: `InventoryPageComponent` (Task 13).
- Produces: `/inventory` route; enabled "Inventar" nav button.
- [ ] **Step 1: Register the route**
```ts
// apps/web/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { AppShellComponent } from './layout/app-shell/app-shell.component';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'world' },
{
path: '',
component: AppShellComponent,
children: [
{
path: 'world',
loadComponent: () =>
import('./features/world/world-page.component').then(
(module) => module.WorldPageComponent,
),
},
{
path: 'hunt',
loadComponent: () =>
import('./features/hunting/hunt-page/hunt-page.component').then(
(module) => module.HuntPageComponent,
),
},
{
path: 'combat/:combatId',
loadComponent: () =>
import('./features/combat/combat-page/combat-page.component').then(
(module) => module.CombatPageComponent,
),
},
{
path: 'inventory',
loadComponent: () =>
import('./features/inventory/inventory-page.component').then(
(module) => module.InventoryPageComponent,
),
},
],
},
{ path: '**', redirectTo: 'world' },
];
```
- [ ] **Step 2: Enable the nav button**
In `apps/web/src/app/layout/side-navigation/side-navigation.component.html`, replace the disabled Inventar button:
```html
<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**
```bash
git add apps/web/src/app/app.routes.ts apps/web/src/app/layout/side-navigation/side-navigation.component.html
git commit -m "feat(web): route and enable the Inventar nav entry"
```
---
## Task 15: Reward screen "Inventar öffnen" button
**Files:**
- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.ts`
- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.html`
- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`
**Interfaces:**
- Produces: `CombatPageComponent.goToInventory()`. Spec §41: shown only on the victory screen, does not auto-equip anything.
- [ ] **Step 1: Extend the failing test**
Add to `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`, next to the existing `'navigates to /hunt from the victory screen'` test:
```ts
it('navigates to /inventory from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<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()`:
```ts
protected goToInventory(): void {
void this.router.navigate(['/inventory']);
}
```
- [ ] **Step 4: Add the button**
In `apps/web/src/app/features/combat/combat-page/combat-page.component.html`, inside the `@if (combat.status === 'WON')` block, right after the existing `rewards` section and before the `Zur Jagd` button:
```html
<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:
```scss
.outcome__actions {
display: flex;
gap: var(--ar-space-2);
justify-content: center;
margin-block-start: var(--ar-space-2);
}
.outcome__actions .outcome__button {
margin-block-start: 0;
}
```
- [ ] **Step 6: Run the tests again**
Run: `npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts`
Expected: PASS (all tests, including the new one and the pre-existing `Zur Jagd` navigation test).
- [ ] **Step 7: Build**
Run: `npm run build --workspace=@ashen-realms/web`
Expected: builds cleanly.
- [ ] **Step 8: Commit**
```bash
git add apps/web/src/app/features/combat/combat-page/combat-page.component.ts apps/web/src/app/features/combat/combat-page/combat-page.component.html apps/web/src/app/features/combat/combat-page/combat-page.component.scss apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
git commit -m "feat(web): add Inventar öffnen button to the victory screen"
```
---
## Task 16: Full-loop verification (spec §56§60)
**Files:** none (verification only).
- [ ] **Step 1: Run the full backend suite**
Run: `npm run test --workspace=@ashen-realms/api`
Expected: all tests pass, including `character-stats.service.spec.ts`, `equipment.service.spec.ts`, `inventory.service.spec.ts`, `combat-equipment-integration.spec.ts`, `vertical-slice.seed.spec.ts`, and the untouched pre-existing suites.
- [ ] **Step 2: Run the full frontend suite**
Run: `npm run test --workspace=@ashen-realms/web`
Expected: all tests pass, including the four new inventory spec files and the updated `combat-page.component.spec.ts`.
- [ ] **Step 3: Build both apps**
Run: `npm run build --workspace=@ashen-realms/api && npm run build --workspace=@ashen-realms/web`
Expected: both build cleanly.
- [ ] **Step 4: Run and verify the migration**
Run: `npm run db:migrate`
Expected: `CreateEquipment1789000000000` applies cleanly on top of the existing schema.
- [ ] **Step 5: Run the seed and verify idempotency**
Run: `npm run db:seed` twice in a row.
Expected: second run makes no destructive changes — demo character still has exactly one `worn-short-sword` `CharacterItem` and one `WEAPON` `CharacterEquipment` row (or whatever the player has since equipped, if this is a shared dev DB with prior play).
- [ ] **Step 6: Manual browser walkthrough**
With the API and web dev servers running, walk the full loop from spec §56/§57:
- Open `/inventory` fresh: see `Abgenutztes Kurzschwert` owned and marked `Ausgerüstet` in the `WEAPON` slot.
- Travel to Verbrannte Straße, hunt, fight a `Straßenräuber` until `Räuberklinge` drops.
- On the victory screen, click `Inventar öffnen`.
- Select `Räuberklinge`; confirm the comparison shows `11 Waffenschaden (+3)` and `+1 Angriff` (or `(+1)` depending on layout) against the equipped sword.
- Click `Ausrüsten`; confirm `Räuberklinge` becomes `Ausgerüstet`, `Abgenutztes Kurzschwert` becomes selectable/unequipped, and the equipment-overview stats update.
- Refresh the page; confirm `Räuberklinge` is still shown as equipped.
- Start a new hunt/combat; confirm the player deals visibly more damage than before the upgrade.
- Verify error cases surface as in-shell messages (no browser `alert`): attempt equipping while an active combat exists (`CHARACTER_IN_COMBAT`), and confirm the equip button/flow is blocked or errors cleanly rather than throwing an unhandled exception.
- [ ] **Step 7: Report completion**
Once every check above passes, Playable Slice 0.5 satisfies its Definition of Done (spec §56) and acceptance criteria (spec §58). No commit needed for this task — it is verification of the prior 15 commits.