135 KiB
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
characterItemIdto equip — neveritemDefinition.id(spec §13–14). - Replacing an equipped item never deletes the previous
CharacterItem(spec §15). - Equipment cannot change during an active combat →
CHARACTER_IN_COMBAT(spec §46). Do not add a travel restriction — nothing in the current UI blocks inventory during travel, and the spec explicitly forbids assuming this (spec §47). - Combat Power (
HP/10 + attack×2 + weaponDamage×2 + armor×1.5) stays internal — never added to a response DTO or the UI (spec §21). - Seed changes must stay idempotent: never duplicate the starting sword, never delete an earned item, never reset equipped state (spec §55).
- No sell/merchant/durability/crafting/socketing/drag-and-drop/filters/stash — do not build any of it (spec §4).
- Backend tests: Jest,
apps/api. Frontend tests: Vitest via Angular'sTestBed,apps/web— usevi.fn()/vifrom'vitest', notjest.fn(). - Follow the established domain-error pattern: one
<module>.errors.tsfile per module, exporting anHttpExceptionsubclass with acodefield plus small factory functions (seeapps/api/src/combat/combat.errors.ts). - Controllers resolve "the current character" via the
DEMO_CHARACTER_IDconstant fromapps/api/src/demo/demo-character.constants.ts— there is no auth yet. Follow the existing pattern exactly (seeapps/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.tsapps/api/src/equipment/entities/character-equipment.entity.tsapps/api/src/equipment/equipment.errors.tsapps/api/src/equipment/equipment.service.ts+.spec.tsapps/api/src/equipment/equipment.controller.tsapps/api/src/equipment/equipment.module.tsapps/api/src/equipment/dto/equip-item.dto.tsapps/api/src/characters/character-stats.service.ts+.spec.tsapps/api/src/inventory/inventory.service.ts+.spec.tsapps/api/src/inventory/inventory.controller.tsapps/api/src/inventory/inventory.module.tsapps/api/src/combat/combat-equipment-integration.spec.ts
Backend — modified files:
apps/api/src/characters/characters.module.ts(swapCharacterCombatStatsService→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(consumeCharacterStatsService)apps/api/src/app.module.ts(registerEquipmentModule,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.tsapps/web/src/app/features/inventory/inventory-detail-panel.component.ts+.html+.scss+.spec.tsapps/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(/inventoryroute)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:
CharacterEquipmententity (id,characterId,slot: EquipmentSlot,characterItemId,createdAt,updatedAt, relationscharacter,characterItem). Tablecharacter_equipmentwithUNIQUE(character_id, slot)andUNIQUE(character_item_id). -
Step 1: Write the failing migration-metadata test
Create apps/api/src/database/migrations/equipment.migration.spec.ts (this project's migration specs assert TypeORM entity metadata rather than running against a live DB — see encounter-status.migration.spec.ts):
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { CharacterEquipment } from '../../equipment/entities/character-equipment.entity';
describe('character_equipment schema', () => {
it('stores slot as a non-nullable equipment_slot_enum column', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === CharacterEquipment && candidate.propertyName === 'slot',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('enum');
expect(column?.options.enumName).toBe('equipment_slot_enum');
expect(column?.options.nullable).toBeFalsy();
});
it('enforces one equipped item per character per slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.includes('characterId') &&
candidate.columns?.includes('slot'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('forbids one CharacterItem from occupying more than one equipment slot', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CharacterEquipment &&
candidate.columns?.length === 1 &&
candidate.columns?.includes('characterItemId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
});
- Step 2: Run it to confirm it fails
Run: npm run test --workspace=@ashen-realms/api -- equipment.migration.spec.ts
Expected: FAIL — Cannot find module '../../equipment/entities/character-equipment.entity'.
- Step 3: Create the entity
// apps/api/src/equipment/entities/character-equipment.entity.ts
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CharacterItem } from '../../items/entities/character-item.entity';
import { EquipmentSlot } from '../../items/equipment-slot.enum';
/**
* One equipped item in one slot for one character (spec §12).
*
* `characterItemId` must belong to `characterId` — enforced by
* `EquipmentService`, never by the client (spec §13).
*/
@Entity({ name: 'character_equipment' })
@Index('IDX_character_equipment_character_slot', ['characterId', 'slot'], {
unique: true,
})
@Index('IDX_character_equipment_character_item', ['characterItemId'], {
unique: true,
})
export class CharacterEquipment {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({
name: 'slot',
type: 'enum',
enum: EquipmentSlot,
enumName: 'equipment_slot_enum',
})
slot!: EquipmentSlot;
@Column({ name: 'character_item_id', type: 'uuid' })
characterItemId!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => CharacterItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_item_id' })
characterItem!: CharacterItem;
}
- Step 4: Run the metadata test again
Run: npm run test --workspace=@ashen-realms/api -- equipment.migration.spec.ts
Expected: PASS (all 3 assertions).
- Step 5: Write the migration
// apps/api/src/database/migrations/1789000000000-CreateEquipment.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateEquipment1789000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Reuses the "equipment_slot_enum" type created by CreateLootAndRewards.
await queryRunner.query(`CREATE TABLE "character_equipment" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"slot" "equipment_slot_enum" NOT NULL,
"character_item_id" uuid NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_equipment" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_equipment_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_equipment_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_equipment_character_slot" ON "character_equipment" ("character_id", "slot")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_equipment_character_item" ON "character_equipment" ("character_item_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'DROP INDEX "IDX_character_equipment_character_item"',
);
await queryRunner.query(
'DROP INDEX "IDX_character_equipment_character_slot"',
);
await queryRunner.query('DROP TABLE "character_equipment"');
}
}
- Step 6: Compile the migration
Run: npm run build --workspace=@ashen-realms/api
Expected: builds cleanly (no TypeScript errors).
- Step 7: Commit
git add apps/api/src/equipment/entities/character-equipment.entity.ts apps/api/src/database/migrations/1789000000000-CreateEquipment.ts apps/api/src/database/migrations/equipment.migration.spec.ts
git commit -m "feat(api): add character_equipment table and entity"
Task 2: Equipment domain errors
Files:
- Create:
apps/api/src/equipment/equipment.errors.ts
Interfaces:
-
Produces:
EquipmentDomainError, and factoriescharacterItemNotFound(),itemNotOwned(),itemNotEquippable(),itemLevelRequirementNotMet(),invalidEquipmentSlot(),characterInCombat(), plus re-exportedcharacterNotFound(). -
Step 1: Write the file
// apps/api/src/equipment/equipment.errors.ts
import { HttpException, HttpStatus } from '@nestjs/common';
export type EquipmentErrorCode =
| 'CHARACTER_ITEM_NOT_FOUND'
| 'ITEM_NOT_OWNED'
| 'ITEM_NOT_EQUIPPABLE'
| 'ITEM_LEVEL_REQUIREMENT_NOT_MET'
| 'INVALID_EQUIPMENT_SLOT'
| 'CHARACTER_IN_COMBAT';
export class EquipmentDomainError extends HttpException {
constructor(
public readonly code: EquipmentErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function characterItemNotFound(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_ITEM_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This item could not be found.',
);
}
export function itemNotOwned(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_OWNED',
HttpStatus.FORBIDDEN,
'This item does not belong to the character.',
);
}
export function itemNotEquippable(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_NOT_EQUIPPABLE',
HttpStatus.BAD_REQUEST,
'This item cannot be equipped.',
);
}
export function itemLevelRequirementNotMet(): EquipmentDomainError {
return new EquipmentDomainError(
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
HttpStatus.BAD_REQUEST,
"The character does not meet this item's level requirement.",
);
}
// Defensive: slot is always derived from the item definition server-side, so
// this is unreachable in practice (spec §27 still names it explicitly).
export function invalidEquipmentSlot(): EquipmentDomainError {
return new EquipmentDomainError(
'INVALID_EQUIPMENT_SLOT',
HttpStatus.BAD_REQUEST,
'This item does not target a valid equipment slot.',
);
}
export function characterInCombat(): EquipmentDomainError {
return new EquipmentDomainError(
'CHARACTER_IN_COMBAT',
HttpStatus.CONFLICT,
'Equipment cannot be changed during an active combat.',
);
}
export { characterNotFound } from '../travel/travel.errors';
- Step 2: Compile
Run: npm run build --workspace=@ashen-realms/api
Expected: builds cleanly.
- Step 3: Commit
git add apps/api/src/equipment/equipment.errors.ts
git commit -m "feat(api): add equipment domain errors"
Task 3: CharacterStatsService
Files:
- Create:
apps/api/src/characters/character-stats.service.ts - Test:
apps/api/src/characters/character-stats.service.spec.ts
Interfaces:
-
Consumes:
CharacterEquipmententity (Task 1),Characterentity. -
Produces:
EffectiveCharacterStats { maxHp, currentHp, attack, weaponDamage, armor, combatPower }andCharacterStatsService.calculate(character: Character, scope?: Pick<DataSource, 'getRepository'>): Promise<EffectiveCharacterStats>. Later tasks (EquipmentService, CombatService, CharactersService) callcalculate()and read these exact field names. -
Step 1: Write the failing tests (spec §42)
// apps/api/src/characters/character-stats.service.spec.ts
import { DataSource } from 'typeorm';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemDefinition } from '../items/entities/item-definition.entity';
type EquippedFixture = {
slot: EquipmentSlot;
item: Partial<ItemDefinition>;
};
function fakeScope(equipped: EquippedFixture[]): Pick<DataSource, 'getRepository'> {
const rows = equipped.map((entry) => ({
slot: entry.slot,
characterItem: {
itemDefinition: {
weaponDamage: 0,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
...entry.item,
},
},
}));
return {
getRepository: () => ({ find: async () => rows }) as never,
} as unknown as Pick<DataSource, 'getRepository'>;
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: 'character-1',
baseHp: 100,
baseAttack: 6,
currentHp: 90,
...overrides,
} as Character;
}
describe('CharacterStatsService', () => {
const service = new CharacterStatsService({} as DataSource);
it('derives stats from the starting weapon alone', async () => {
const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]);
const stats = await service.calculate(character(), scope);
expect(stats.attack).toBe(6);
expect(stats.weaponDamage).toBe(8);
expect(stats.maxHp).toBe(100);
expect(stats.armor).toBe(0);
});
it('applies Räuberklinge\'s weapon damage and bonus attack', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.attack).toBe(7);
expect(stats.weaponDamage).toBe(11);
});
it('sums bonusArmor across multiple equipped armor pieces', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } },
{ slot: EquipmentSlot.CHEST, item: { bonusArmor: 7 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.armor).toBe(10);
});
it('sums bonusHp across equipped items on top of base HP', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.HEAD, item: { bonusHp: 5 } },
{ slot: EquipmentSlot.CHEST, item: { bonusHp: 10 } },
]);
const stats = await service.calculate(character(), scope);
expect(stats.maxHp).toBe(115);
});
it('reports weaponDamage as 0 when no weapon is equipped', async () => {
const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]);
const stats = await service.calculate(character(), scope);
expect(stats.weaponDamage).toBe(0);
});
it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => {
const scope = fakeScope([
{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } },
{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } },
]);
const stats = await service.calculate(character(), scope);
// maxHp=105, attack=7, weaponDamage=11, armor=3
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
});
it('passes currentHp through unchanged from the character', async () => {
const scope = fakeScope([]);
const stats = await service.calculate(character({ currentHp: 42 }), scope);
expect(stats.currentHp).toBe(42);
});
});
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/api -- character-stats.service.spec.ts
Expected: FAIL — Cannot find module './character-stats.service'.
- Step 3: Implement the service
// apps/api/src/characters/character-stats.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { Character } from './entities/character.entity';
export interface EffectiveCharacterStats {
maxHp: number;
currentHp: number;
attack: number;
weaponDamage: number;
armor: number;
combatPower: number;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
/**
* Single authoritative source of effective character stats (spec §18).
* Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut.
*/
@Injectable()
export class CharacterStatsService {
constructor(private readonly dataSource: DataSource) {}
async calculate(
character: Character,
scope?: RepositoryScope,
): Promise<EffectiveCharacterStats> {
const db = scope ?? this.dataSource;
const equipped = await db.getRepository(CharacterEquipment).find({
where: { characterId: character.id },
relations: { characterItem: { itemDefinition: true } },
});
let weaponDamage = 0;
let bonusHp = 0;
let bonusAttack = 0;
let bonusArmor = 0;
for (const slot of equipped) {
const definition = slot.characterItem.itemDefinition;
if (slot.slot === EquipmentSlot.WEAPON) {
weaponDamage = definition.weaponDamage;
}
bonusHp += definition.bonusHp;
bonusAttack += definition.bonusAttack;
bonusArmor += definition.bonusArmor;
}
const maxHp = character.baseHp + bonusHp;
const attack = character.baseAttack + bonusAttack;
const armor = bonusArmor;
return {
maxHp,
currentHp: character.currentHp,
attack,
weaponDamage,
armor,
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
};
}
}
- Step 4: Run the tests again
Run: npm run test --workspace=@ashen-realms/api -- character-stats.service.spec.ts
Expected: PASS (all 7 tests).
- Step 5: Commit
git add apps/api/src/characters/character-stats.service.ts apps/api/src/characters/character-stats.service.spec.ts
git commit -m "feat(api): add CharacterStatsService as authoritative effective-stat source"
Task 4: Retire CharacterCombatStatsService; wire CharacterStatsService into CharactersModule
Files:
- Modify:
apps/api/src/characters/characters.module.ts - Modify:
apps/api/src/characters/characters.service.ts - Modify:
apps/api/src/characters/characters.service.spec.ts - Delete:
apps/api/src/characters/character-combat-stats.service.ts - Delete:
apps/api/src/characters/character-combat-stats.service.spec.ts
Interfaces:
-
Consumes:
CharacterStatsService.calculate()(Task 3). -
Produces:
CharactersService.getDemoCharacter()unchanged response shape, butattack/maxHpare now the effective values (spec §17 — remove the Slice 0.3 shortcut). -
Step 1: Update the failing test first
Edit apps/api/src/characters/characters.service.spec.ts — replace every new CharactersService(repository) call with a version that also passes a fake CharacterStatsService, and assert it is invoked:
import { NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
import { CharactersService } from './characters.service';
function fakeCharacterStats(
overrides: Partial<{ maxHp: number; attack: number }> = {},
): CharacterStatsService {
return {
calculate: jest.fn().mockResolvedValue({
maxHp: overrides.maxHp ?? 100,
currentHp: 100,
attack: overrides.attack ?? 6,
weaponDamage: 8,
armor: 0,
combatPower: 0,
}),
} as unknown as CharacterStatsService;
}
describe('CharactersService', () => {
it('returns the demo character with effective attack/HP and its location summary', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
currentHp: 100,
baseHp: 100,
baseAttack: 6,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'Südtor von Graufurt',
},
}),
} as unknown as Repository<Character>;
const characterStats = fakeCharacterStats({ maxHp: 115, attack: 7 });
const service = new CharactersService(repository, characterStats);
await expect(service.getDemoCharacter()).resolves.toEqual({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
currentHp: 100,
maxHp: 115,
attack: 7,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'Südtor von Graufurt',
},
});
expect(characterStats.calculate).toHaveBeenCalledWith(
expect.objectContaining({ id: DEMO_CHARACTER_ID }),
);
});
it('exposes the persisted silver so the HUD never has to guess', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue({
id: DEMO_CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 24,
silver: 18,
currentHp: 100,
baseHp: 100,
baseAttack: 6,
currentLocation: {
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'Südtor von Graufurt',
},
}),
} as unknown as Repository<Character>;
const service = new CharactersService(repository, fakeCharacterStats());
await expect(service.getDemoCharacter()).resolves.toEqual(
expect.objectContaining({ experience: 24, silver: 18 }),
);
});
it('reports a missing demo seed as not found', async () => {
const repository = {
findOne: jest.fn().mockResolvedValue(null),
} as unknown as Repository<Character>;
const service = new CharactersService(repository, fakeCharacterStats());
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/api -- characters.service.spec.ts
Expected: FAIL — CharactersService constructor only accepts one argument; effective stats not yet used.
- Step 3: Update
CharactersService
// apps/api/src/characters/characters.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CharacterStatsService } from './character-stats.service';
import { Character } from './entities/character.entity';
@Injectable()
export class CharactersService {
constructor(
@InjectRepository(Character)
private readonly characters: Repository<Character>,
private readonly characterStats: CharacterStatsService,
) {}
async getDemoCharacter() {
const character = await this.characters.findOne({
where: { id: DEMO_CHARACTER_ID },
relations: { currentLocation: true },
});
if (!character) {
throw new NotFoundException('Demo character has not been seeded');
}
const stats = await this.characterStats.calculate(character);
return {
id: character.id,
name: character.name,
level: character.level,
experience: character.experience,
silver: character.silver,
currentHp: character.currentHp,
maxHp: stats.maxHp,
attack: stats.attack,
currentLocation: {
id: character.currentLocation.id,
key: character.currentLocation.key,
name: character.currentLocation.name,
},
};
}
}
- Step 4: Update
CharactersModule
// apps/api/src/characters/characters.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterStatsService } from './character-stats.service';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Character])],
controllers: [CharactersController],
providers: [CharactersService, CharacterStatsService],
exports: [CharacterStatsService],
})
export class CharactersModule {}
- Step 5: Delete the retired service and its spec
git rm apps/api/src/characters/character-combat-stats.service.ts apps/api/src/characters/character-combat-stats.service.spec.ts
- Step 6: Run the full characters test suite
Run: npm run test --workspace=@ashen-realms/api -- characters
Expected: PASS.
- Step 7: Build
Run: npm run build --workspace=@ashen-realms/api
Expected: builds cleanly (confirms no other file still imports CharacterCombatStatsService; Task 5 fixes combat.service.ts, so this build will still fail until Task 5 completes — that's expected and fine at this checkpoint if it fails only on combat.service.ts/combat.module.ts).
- Step 8: Commit
git add apps/api/src/characters/characters.module.ts apps/api/src/characters/characters.service.ts apps/api/src/characters/characters.service.spec.ts
git commit -m "feat(api): retire CharacterCombatStatsService; characters/me returns effective stats"
Task 5: Wire CharacterStatsService into CombatService
Files:
- Modify:
apps/api/src/combat/combat.service.ts - Modify:
apps/api/src/combat/combat.module.ts - Modify:
apps/api/src/combat/combat.service.spec.ts
Interfaces:
-
Consumes:
CharacterStatsService.calculate(character, manager)(Task 3), exported byCharactersModule(Task 4). -
Produces:
CombatService's constructor now takesCharacterStatsServiceinstead ofCharacterCombatStatsService;startCombat's player snapshot is computed viacalculate()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';withimport { CharacterStatsService } from '../characters/character-stats.service';. - Add this helper near the other
fake*helpers:
function fakeCharacterStats(): CharacterStatsService {
return {
calculate: jest.fn(async (character: Character) => ({
maxHp: character.baseHp,
currentHp: character.currentHp,
attack: character.baseAttack,
weaponDamage: 8,
armor: 6,
combatPower: 0,
})),
} as unknown as CharacterStatsService;
}
- In
createService(), replaceconst characterCombatStats = new CharacterCombatStatsService();withconst characterCombatStats = fakeCharacterStats();(keep the local variable name — it's passed positionally intonew CombatService(...)unchanged). - In the
rewardsdescribe block, replace each of the fournew CharacterCombatStatsService()call sites (inline insidenew CombatService(dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), new CharacterCombatStatsService(), rewards)) withfakeCharacterStats().
These fakes reproduce the exact old constants (weaponDamage: 8, armor: 6) so every existing assertion (e.g. playerState: { attack: 6, weaponDamage: 8, armor: 6 }) keeps passing unchanged — this step only swaps which service supplies those numbers, not the numbers themselves.
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts
Expected: FAIL — Cannot find module '../characters/character-stats.service' (doesn't exist as an import target yet in this file) or a leftover reference to the deleted CharacterCombatStatsService import.
- Step 3: Update
CombatService
In apps/api/src/combat/combat.service.ts:
- Replace the import:
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';→import { CharacterStatsService } from '../characters/character-stats.service';. - Rename the constructor parameter and its type:
constructor(
private readonly dataSource: DataSource,
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterStats: CharacterStatsService,
private readonly combatRewards: CombatRewardService,
) {}
- In
startCombat, replace the synchronous call with an awaited, transaction-scoped call (this is the only behavioral line-change in this file):
const playerStats = await this.characterStats.calculate(character, manager);
(This line sits where const playerStats = this.characterCombatStats.getStats(character); used to be, inside the this.dataSource.transaction(async (manager) => { ... }) callback — pass manager, not this.dataSource, so the read is scoped to the same transaction as everything else in startCombat.)
- Step 4: Update
CombatModule
In apps/api/src/combat/combat.module.ts, CharactersModule is already imported and now exports CharacterStatsService (Task 4) instead of CharacterCombatStatsService — no import-list change needed in this file, since CombatService resolves CharacterStatsService through Nest's DI via the already-imported CharactersModule.
- Step 5: Run the combat test suite
Run: npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts
Expected: PASS (all existing tests, unchanged assertions).
- Step 6: Full API build
Run: npm run build --workspace=@ashen-realms/api
Expected: builds cleanly — this confirms no remaining reference to the deleted CharacterCombatStatsService anywhere in the codebase.
- Step 7: Commit
git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts
git commit -m "feat(api): combat snapshots now use CharacterStatsService"
Task 6: EquipmentService, EquipmentController, EquipmentModule
Files:
- Create:
apps/api/src/equipment/dto/equip-item.dto.ts - Create:
apps/api/src/equipment/equipment.service.ts - Test:
apps/api/src/equipment/equipment.service.spec.ts - Create:
apps/api/src/equipment/equipment.controller.ts - Create:
apps/api/src/equipment/equipment.module.ts - Modify:
apps/api/src/app.module.ts
Interfaces:
-
Consumes:
CharacterEquipmententity (Task 1), equipment errors (Task 2),CharacterStatsService(Task 3, exported byCharactersModule). -
Produces:
EquipmentResponseDto { slots: Record<EquipmentSlot, EquipmentSlotItemDto | null>, stats: { maxHp, attack, weaponDamage, armor } },EquipmentService.getEquipment(characterId),EquipmentService.equip(characterId, characterItemId). RoutesGET /api/equipment,POST /api/equipment. Later frontend tasks consume this exact JSON shape. -
Step 1: Write the failing service tests (spec §43)
// apps/api/src/equipment/equipment.service.spec.ts
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentDomainError } from './equipment.errors';
import { EquipmentService } from './equipment.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
const BANDIT_HOOD_ITEM_ID = '70000000-0000-4000-8000-000000000003';
interface State {
characters: Character[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
combats: Combat[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: State,
private readonly target: EntityTarget<T>,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
}
find(options: { where: Partial<T>; relations?: Record<string, unknown> }): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where));
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private withRelations(row: T, relations?: Record<string, unknown>): T {
if (!relations) {
return row;
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
copy['characterItem'] = characterItem
? {
...characterItem,
itemDefinition: this.state.itemDefinitions.find(
(d) => d.id === characterItem.itemDefinitionId,
),
}
: undefined;
}
return copy as T;
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
if (this.target === Combat) return this.state.combats as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
}
}
class FakeDataSource {
private readonly idCounters = new Map<string, number>();
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, this);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
} as unknown as EntityManager);
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
return {
id: 'def-worn-sword',
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: '',
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
rarity: ItemRarity.COMMON,
tier: 1,
requiredLevel: 1,
weaponDamage: 8,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
sellPrice: 0,
iconPath: '/images/items/worn-short-sword.png',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as ItemDefinition;
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
...overrides,
} as Character;
}
function createHarness(state: Partial<State> = {}) {
const fullState: State = {
characters: [character()],
itemDefinitions: [],
characterItems: [],
characterEquipment: [],
combats: [],
...state,
};
const dataSource = new FakeDataSource(fullState);
const characterStats = new CharacterStatsService(dataSource as unknown as DataSource);
const service = new EquipmentService(dataSource as unknown as DataSource, characterStats);
return { state: fullState, service };
}
async function expectEquipmentDomainError(promise: Promise<unknown>, code: string): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(EquipmentDomainError);
if (!(error instanceof EquipmentDomainError)) {
throw new Error('Expected EquipmentDomainError');
}
expect(error.code).toBe(code);
}
describe('EquipmentService', () => {
describe('equip', () => {
it('equips an owned weapon into the WEAPON slot', async () => {
const wornSword = itemDefinition();
const { state, service } = createHarness({
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
});
const result = await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
expect(result.slots.WEAPON).toEqual({
characterItemId: WORN_SWORD_ITEM_ID,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
iconPath: '/images/items/worn-short-sword.png',
},
});
expect(state.characterEquipment).toHaveLength(1);
});
it('replaces the equipped weapon without deleting the old CharacterItem', async () => {
const wornSword = itemDefinition();
const banditBlade = itemDefinition({
id: 'def-bandit-blade',
key: 'bandit-blade',
name: 'Räuberklinge',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
});
const { state, service } = createHarness({
itemDefinitions: [wornSword, banditBlade],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: banditBlade.id,
quantity: 1,
} as CharacterItem,
],
characterEquipment: [
{
id: 'equip-1',
characterId: CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: WORN_SWORD_ITEM_ID,
} as CharacterEquipment,
],
});
const result = await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined();
});
it('rejects equipping an item owned by a different character', async () => {
const wornSword = itemDefinition();
const { service } = createHarness({
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: OTHER_CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
'ITEM_NOT_OWNED',
);
});
it('rejects equipping an unknown CharacterItem id', async () => {
const { service } = createHarness();
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, 'unknown-item'),
'CHARACTER_ITEM_NOT_FOUND',
);
});
it('rejects equipping an item above the character level', async () => {
const highLevelHelm = itemDefinition({
id: BANDIT_HOOD_ITEM_ID,
key: 'bandit-hood',
equipmentSlot: EquipmentSlot.HEAD,
requiredLevel: 5,
});
const { service } = createHarness({
itemDefinitions: [highLevelHelm],
characterItems: [
{
id: BANDIT_HOOD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: highLevelHelm.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID),
'ITEM_LEVEL_REQUIREMENT_NOT_MET',
);
});
it('rejects equipping a non-equippable item', async () => {
const material = itemDefinition({
id: 'def-ash-pelt',
key: 'ash-pelt',
type: ItemType.MATERIAL,
equipmentSlot: null,
});
const { service } = createHarness({
itemDefinitions: [material],
characterItems: [
{
id: 'item-ash-pelt',
characterId: CHARACTER_ID,
itemDefinitionId: material.id,
quantity: 1,
} as CharacterItem,
],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, 'item-ash-pelt'),
'ITEM_NOT_EQUIPPABLE',
);
});
it('never produces two equipped weapons when the same slot is equipped repeatedly', async () => {
const wornSword = itemDefinition();
const banditBlade = itemDefinition({
id: 'def-bandit-blade',
key: 'bandit-blade',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
});
const { state, service } = createHarness({
itemDefinitions: [wornSword, banditBlade],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: banditBlade.id,
quantity: 1,
} as CharacterItem,
],
});
// Sequential repeats stand in for the concurrent case here (a real race
// is guarded by the DB's UNIQUE(character_id, slot) constraint from
// Task 1, which a synchronous fake repository cannot exercise).
await service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID);
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
expect(state.characterEquipment).toHaveLength(1);
expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID);
});
it('rejects equipping while the character has an active combat', async () => {
const wornSword = itemDefinition();
const { service } = createHarness({
itemDefinitions: [wornSword],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: wornSword.id,
quantity: 1,
} as CharacterItem,
],
combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat],
});
await expectEquipmentDomainError(
service.equip(CHARACTER_ID, WORN_SWORD_ITEM_ID),
'CHARACTER_IN_COMBAT',
);
});
});
describe('getEquipment', () => {
it('returns empty slots and base stats when nothing is equipped', async () => {
const { service } = createHarness();
const result = await service.getEquipment(CHARACTER_ID);
expect(result.slots).toEqual({
WEAPON: null,
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
});
expect(result.stats).toEqual({ maxHp: 100, attack: 6, weaponDamage: 0, armor: 0 });
});
});
});
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/api -- equipment.service.spec.ts
Expected: FAIL — Cannot find module './equipment.service'.
- Step 3: Write the DTO
// apps/api/src/equipment/dto/equip-item.dto.ts
import { IsUUID } from 'class-validator';
export class EquipItemDto {
@IsUUID()
characterItemId!: string;
}
- Step 4: Implement
EquipmentService
// apps/api/src/equipment/equipment.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { CharacterEquipment } from './entities/character-equipment.entity';
import {
characterInCombat,
characterItemNotFound,
characterNotFound,
itemLevelRequirementNotMet,
itemNotEquippable,
itemNotOwned,
} from './equipment.errors';
export interface EquipmentSlotItemDto {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
}
export type EquipmentSlotsDto = Record<EquipmentSlot, EquipmentSlotItemDto | null>;
export interface EquipmentStatsDto {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
export interface EquipmentResponseDto {
slots: EquipmentSlotsDto;
stats: EquipmentStatsDto;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
@Injectable()
export class EquipmentService {
constructor(
private readonly dataSource: DataSource,
private readonly characterStats: CharacterStatsService,
) {}
async getEquipment(characterId: string): Promise<EquipmentResponseDto> {
const character = await this.dataSource
.getRepository(Character)
.findOneBy({ id: characterId });
if (!character) {
throw characterNotFound();
}
return this.buildResponse(character, this.dataSource);
}
/**
* Equips (or replaces) one slot for `characterId` with `characterItemId`
* (spec §14, §28). Runs in one transaction: the old item is unequipped by
* being overwritten, never deleted (spec §15).
*/
async equip(characterId: string, characterItemId: string): Promise<EquipmentResponseDto> {
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const combats = manager.getRepository(Combat);
const characterItems = manager.getRepository(CharacterItem);
const equipmentRepo = manager.getRepository(CharacterEquipment);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const activeCombat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
});
if (activeCombat) {
throw characterInCombat();
}
const characterItem = await characterItems.findOne({
where: { id: characterItemId },
relations: { itemDefinition: true },
});
if (!characterItem) {
throw characterItemNotFound();
}
if (characterItem.characterId !== characterId) {
throw itemNotOwned();
}
const definition = characterItem.itemDefinition;
if (!definition.equipmentSlot) {
throw itemNotEquippable();
}
if (definition.requiredLevel > character.level) {
throw itemLevelRequirementNotMet();
}
const existing = await equipmentRepo.findOne({
where: { characterId, slot: definition.equipmentSlot },
lock: { mode: 'pessimistic_write' },
});
if (existing) {
existing.characterItemId = characterItem.id;
await equipmentRepo.save(existing);
} else {
await equipmentRepo.save(
equipmentRepo.create({
characterId,
slot: definition.equipmentSlot,
characterItemId: characterItem.id,
}),
);
}
return this.buildResponse(character, manager);
});
}
private async buildResponse(
character: Character,
scope: RepositoryScope,
): Promise<EquipmentResponseDto> {
const equipped = await scope.getRepository(CharacterEquipment).find({
where: { characterId: character.id },
relations: { characterItem: { itemDefinition: true } },
});
const slots = Object.fromEntries(
Object.values(EquipmentSlot).map((slot) => [slot, null]),
) as EquipmentSlotsDto;
for (const row of equipped) {
const definition = row.characterItem.itemDefinition;
slots[row.slot] = {
characterItemId: row.characterItemId,
item: {
key: definition.key,
name: definition.name,
rarity: definition.rarity,
iconPath: definition.iconPath,
},
};
}
const stats = await this.characterStats.calculate(character, scope);
return {
slots,
stats: {
maxHp: stats.maxHp,
attack: stats.attack,
weaponDamage: stats.weaponDamage,
armor: stats.armor,
},
};
}
}
- Step 5: Run the tests again
Run: npm run test --workspace=@ashen-realms/api -- equipment.service.spec.ts
Expected: PASS (all 10 tests).
- Step 6: Add the controller and module
// apps/api/src/equipment/equipment.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { EquipItemDto } from './dto/equip-item.dto';
import { EquipmentResponseDto, EquipmentService } from './equipment.service';
@Controller('equipment')
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
@Get()
getEquipment(): Promise<EquipmentResponseDto> {
return this.equipmentService.getEquipment(DEMO_CHARACTER_ID);
}
@Post()
equip(@Body() request: EquipItemDto): Promise<EquipmentResponseDto> {
return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId);
}
}
// apps/api/src/equipment/equipment.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersModule } from '../characters/characters.module';
import { Character } from '../characters/entities/character.entity';
import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterEquipment } from './entities/character-equipment.entity';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]),
CharactersModule,
],
controllers: [EquipmentController],
providers: [EquipmentService],
exports: [EquipmentService],
})
export class EquipmentModule {}
- Step 7: Register
EquipmentModuleinAppModule
// apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module';
import { EquipmentModule } from './equipment/equipment.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@Module({
imports: [
DatabaseModule,
HealthModule,
CharactersModule,
TravelModule,
WorldModule,
HuntingModule,
CombatModule,
EquipmentModule,
],
})
export class AppModule {}
(InventoryModule is added to this same list in Task 7.)
- Step 8: Build
Run: npm run build --workspace=@ashen-realms/api
Expected: builds cleanly.
- Step 9: Commit
git add apps/api/src/equipment apps/api/src/app.module.ts
git commit -m "feat(api): add equipment API (GET/POST /api/equipment)"
Task 7: InventoryService, InventoryController, InventoryModule
Files:
- Create:
apps/api/src/inventory/inventory.service.ts - Test:
apps/api/src/inventory/inventory.service.spec.ts - Create:
apps/api/src/inventory/inventory.controller.ts - Create:
apps/api/src/inventory/inventory.module.ts - Modify:
apps/api/src/app.module.ts
Interfaces:
-
Consumes:
CharacterItem,CharacterEquipment(Task 1) via@InjectRepository. -
Produces:
InventoryResponseDto { items: InventoryItemDto[] }whereInventoryItemDto = { id, quantity, equipped, item: { key, name, rarity, equipmentSlot, requiredLevel, weaponDamage, bonusAttack, bonusHp, bonusArmor, iconPath } }. RouteGET /api/inventory. -
Step 1: Write the failing tests (spec §44)
// apps/api/src/inventory/inventory.service.spec.ts
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { InventoryService } from './inventory.service';
const CHARACTER_ID = 'character-1';
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
return {
id: 'item-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'def-1',
quantity: 1,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
itemDefinition: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: ItemRarity.COMMON,
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
...overrides,
} as CharacterItem;
}
describe('InventoryService', () => {
it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => {
const items = [
characterItem({ id: 'item-1', quantity: 1 }),
characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }),
];
const characterItems = {
find: jest.fn().mockResolvedValue(items),
} as unknown as Repository<CharacterItem>;
const equipment = {
find: jest.fn().mockResolvedValue([
{ characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment,
]),
} as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment);
const result = await service.getInventory(CHARACTER_ID);
expect(characterItems.find).toHaveBeenCalledWith({
where: { characterId: CHARACTER_ID },
relations: { itemDefinition: true },
order: { createdAt: 'ASC' },
});
expect(result.items).toEqual([
{
id: 'item-1',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-2',
quantity: 3,
equipped: false,
item: expect.objectContaining({ key: 'worn-short-sword' }),
},
]);
});
it('returns an empty list when the character owns nothing', async () => {
const characterItems = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<CharacterItem>;
const equipment = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<CharacterEquipment>;
const service = new InventoryService(characterItems, equipment);
await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] });
});
});
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/api -- inventory.service.spec.ts
Expected: FAIL — Cannot find module './inventory.service'.
- Step 3: Implement
InventoryService
// apps/api/src/inventory/inventory.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
export interface InventoryItemDto {
id: string;
quantity: number;
equipped: boolean;
item: {
key: string;
name: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;
weaponDamage: number;
bonusAttack: number;
bonusHp: number;
bonusArmor: number;
iconPath: string;
};
}
export interface InventoryResponseDto {
items: InventoryItemDto[];
}
@Injectable()
export class InventoryService {
constructor(
@InjectRepository(CharacterItem)
private readonly characterItems: Repository<CharacterItem>,
@InjectRepository(CharacterEquipment)
private readonly equipment: Repository<CharacterEquipment>,
) {}
async getInventory(characterId: string): Promise<InventoryResponseDto> {
const [items, equipped] = await Promise.all([
this.characterItems.find({
where: { characterId },
relations: { itemDefinition: true },
order: { createdAt: 'ASC' },
}),
this.equipment.find({ where: { characterId } }),
]);
const equippedIds = new Set(equipped.map((row) => row.characterItemId));
return {
items: items.map((characterItem) => ({
id: characterItem.id,
quantity: characterItem.quantity,
equipped: equippedIds.has(characterItem.id),
item: {
key: characterItem.itemDefinition.key,
name: characterItem.itemDefinition.name,
rarity: characterItem.itemDefinition.rarity,
equipmentSlot: characterItem.itemDefinition.equipmentSlot,
requiredLevel: characterItem.itemDefinition.requiredLevel,
weaponDamage: characterItem.itemDefinition.weaponDamage,
bonusAttack: characterItem.itemDefinition.bonusAttack,
bonusHp: characterItem.itemDefinition.bonusHp,
bonusArmor: characterItem.itemDefinition.bonusArmor,
iconPath: characterItem.itemDefinition.iconPath,
},
})),
};
}
}
- Step 4: Run the tests again
Run: npm run test --workspace=@ashen-realms/api -- inventory.service.spec.ts
Expected: PASS.
- Step 5: Add the controller and module
// apps/api/src/inventory/inventory.controller.ts
import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { InventoryResponseDto, InventoryService } from './inventory.service';
@Controller('inventory')
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
@Get()
getInventory(): Promise<InventoryResponseDto> {
return this.inventoryService.getInventory(DEMO_CHARACTER_ID);
}
}
// apps/api/src/inventory/inventory.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
@Module({
imports: [TypeOrmModule.forFeature([CharacterItem, CharacterEquipment])],
controllers: [InventoryController],
providers: [InventoryService],
})
export class InventoryModule {}
- Step 6: Register
InventoryModuleinAppModule
// apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module';
import { EquipmentModule } from './equipment/equipment.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { InventoryModule } from './inventory/inventory.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@Module({
imports: [
DatabaseModule,
HealthModule,
CharactersModule,
TravelModule,
WorldModule,
HuntingModule,
CombatModule,
EquipmentModule,
InventoryModule,
],
})
export class AppModule {}
- Step 7: Build
Run: npm run build --workspace=@ashen-realms/api
Expected: builds cleanly.
- Step 8: Commit
git add apps/api/src/inventory apps/api/src/app.module.ts
git commit -m "feat(api): add inventory API (GET /api/inventory)"
Task 8: Combat + equipment integration proof (spec §45, §60)
Files:
- Create:
apps/api/src/combat/combat-equipment-integration.spec.ts
Interfaces:
- Consumes: real
CombatService(Task 5), realEquipmentService(Task 6), realCharacterStatsService(Task 3) composed over one shared fakeDataSource.
This is the critical proof: equipping Räuberklinge must make a later combat deal more damage, not just change a stored number. It composes the real services together (not mocks) the same way combat.service.spec.ts's existing reward tests already compose CombatService + CombatRewardService over one shared fake DataSource.
- Step 1: Write the failing integration test
// apps/api/src/combat/combat-equipment-integration.spec.ts
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { Character } from '../characters/entities/character.entity';
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
import { EquipmentService } from '../equipment/equipment.service';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { EquipmentSlot } from '../items/equipment-slot.enum';
import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { CombatRewardService } from '../rewards/combat-reward.service';
import { TravelService } from '../travel/travel.service';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatService } from './combat.service';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
const WORN_SWORD_DEFINITION_ID = '50000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_DEFINITION_ID = '50000000-0000-4000-8000-000000000002';
const WORN_SWORD_ITEM_ID = '70000000-0000-4000-8000-000000000001';
const BANDIT_BLADE_ITEM_ID = '70000000-0000-4000-8000-000000000002';
interface FakeState {
characters: Character[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
monsters: MonsterDefinition[];
combats: Combat[];
combatEvents: CombatEvent[];
itemDefinitions: ItemDefinition[];
characterItems: CharacterItem[];
characterEquipment: CharacterEquipment[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
private readonly target: EntityTarget<T>,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
lock?: { mode: string };
}): Promise<T | null> {
const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null;
return Promise.resolve(row ? this.withRelations(row, options.relations) : null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
}
find(options: {
where: Partial<T>;
relations?: Record<string, unknown>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where));
return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations)));
}
count(options: { where: Partial<T> }): Promise<number> {
return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private withRelations(row: T, relations?: Record<string, unknown>): T {
if (!relations) {
return row;
}
const copy = { ...row } as T & Record<string, unknown>;
if (this.target === CharacterItem && relations['itemDefinition']) {
const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId;
copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId);
}
if (this.target === CharacterEquipment && relations['characterItem']) {
const characterItemId = (row as unknown as CharacterEquipment).characterItemId;
const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId);
copy['characterItem'] = characterItem
? {
...characterItem,
itemDefinition: this.state.itemDefinitions.find(
(d) => d.id === characterItem.itemDefinitionId,
),
}
: undefined;
}
return copy as T;
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === Hunt) return this.state.hunts as T[];
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
if (this.target === MonsterDefinition) return this.state.monsters as T[];
if (this.target === Combat) return this.state.combats as T[];
if (this.target === CombatEvent) return this.state.combatEvents as T[];
if (this.target === ItemDefinition) return this.state.itemDefinitions as T[];
if (this.target === CharacterItem) return this.state.characterItems as T[];
if (this.target === CharacterEquipment) return this.state.characterEquipment as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
}
}
class FakeDataSource {
private readonly idCounters = new Map<string, number>();
constructor(public state: FakeState) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, this);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.getRepository(target),
} as unknown as EntityManager);
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: 'location-1',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Character;
}
function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition {
return {
id: MONSTER_ID,
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as MonsterDefinition;
}
function hunt(overrides: Partial<Hunt> = {}): Hunt {
return {
id: HUNT_ID,
characterId: CHARACTER_ID,
locationId: 'location-1',
status: HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Hunt;
}
function encounter(id: string, overrides: Partial<HuntEncounter> = {}): HuntEncounter {
return {
id,
huntId: HUNT_ID,
monsterDefinitionId: MONSTER_ID,
position: 0,
status: HuntEncounterStatus.AVAILABLE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as HuntEncounter;
}
function itemDefinition(overrides: Partial<ItemDefinition> = {}): ItemDefinition {
return {
id: WORN_SWORD_DEFINITION_ID,
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
description: '',
type: ItemType.WEAPON,
equipmentSlot: EquipmentSlot.WEAPON,
rarity: ItemRarity.COMMON,
tier: 1,
requiredLevel: 1,
weaponDamage: 8,
bonusHp: 0,
bonusAttack: 0,
bonusArmor: 0,
sellPrice: 0,
iconPath: '/images/items/worn-short-sword.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as ItemDefinition;
}
function fakeTravelService(): TravelService {
return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService;
}
function fakeRewardService(): CombatRewardService {
return {
grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }),
loadRewards: jest.fn().mockResolvedValue(null),
} as unknown as CombatRewardService;
}
function createHarness() {
const state: FakeState = {
characters: [character()],
hunts: [hunt()],
huntEncounters: [],
monsters: [monster()],
combats: [],
combatEvents: [],
itemDefinitions: [
itemDefinition(),
itemDefinition({
id: BANDIT_BLADE_DEFINITION_ID,
key: 'bandit-blade',
name: 'Räuberklinge',
weaponDamage: 11,
bonusAttack: 1,
iconPath: '/images/items/bandit-blade.png',
}),
],
characterItems: [
{
id: WORN_SWORD_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: WORN_SWORD_DEFINITION_ID,
quantity: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterItem,
{
id: BANDIT_BLADE_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: BANDIT_BLADE_DEFINITION_ID,
quantity: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterItem,
],
characterEquipment: [
{
id: 'equip-1',
characterId: CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: WORN_SWORD_ITEM_ID,
createdAt: new Date(),
updatedAt: new Date(),
} as CharacterEquipment,
],
};
const dataSource = new FakeDataSource(state);
const characterStats = new CharacterStatsService(dataSource as unknown as DataSource);
const equipmentService = new EquipmentService(dataSource as unknown as DataSource, characterStats);
const combatService = new CombatService(
dataSource as unknown as DataSource,
fakeTravelService(),
new CombatEngineService(),
characterStats,
fakeRewardService(),
);
return { state, equipmentService, combatService };
}
describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () => {
it('deals more damage against the same monster after the upgrade than before it', async () => {
const { state, combatService, equipmentService } = createHarness();
state.huntEncounters.push(encounter('encounter-1'));
const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
const beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK);
const beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp;
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
state.huntEncounters.push(encounter('encounter-2'));
const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2');
const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK);
const afterDamage = after.monster.maxHp - afterResult.monster.currentHp;
// (6+8) vs 5 armor -> round(14 * 60/65) = 13
expect(beforeDamage).toBe(13);
// (7+11) vs 5 armor -> round(18 * 60/65) = 17
expect(afterDamage).toBe(17);
expect(afterDamage).toBeGreaterThan(beforeDamage);
});
it('does not change an already-active combat\'s snapshot when equipment changes mid-fight', async () => {
const { state, combatService, equipmentService } = createHarness();
state.huntEncounters.push(encounter('encounter-1'));
const combat = await combatService.startCombat(CHARACTER_ID, 'encounter-1');
await equipmentService.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID);
const result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK);
// Still the pre-upgrade damage: the snapshot was taken at startCombat time.
expect(combat.monster.maxHp - result.monster.currentHp).toBe(13);
});
});
- Step 2: Run to confirm the test framework wires up correctly, then that it passes
Run: npm run test --workspace=@ashen-realms/api -- combat-equipment-integration.spec.ts
Expected: PASS (both tests) — if either fails, first check that Tasks 1, 3, 5, and 6 are complete (this test imports all four).
- Step 3: Commit
git add apps/api/src/combat/combat-equipment-integration.spec.ts
git commit -m "test(api): prove equipping a weapon upgrade increases future combat damage"
Task 9: Starting equipment in the seed (spec §17, §55)
Files:
- Modify:
apps/api/src/demo/demo-character.constants.ts - Modify:
apps/api/src/database/seeds/vertical-slice.seed.ts - Modify:
apps/api/src/database/seeds/vertical-slice.seed.spec.ts
Interfaces:
-
Produces: two new stable ID constants; the demo character always ends up owning and having equipped
worn-short-swordvia realCharacterItem/CharacterEquipmentrows, idempotently, without ever overwriting a player-earned equip. -
Step 1: Add the stable seed IDs
// apps/api/src/demo/demo-character.constants.ts
export const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
export const DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID =
'10000000-0000-4000-8000-000000000002';
export const DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID =
'10000000-0000-4000-8000-000000000003';
- Step 2: Write the failing seed test
Edit apps/api/src/database/seeds/vertical-slice.seed.spec.ts:
- Add imports:
CharacterItemfrom'../../items/entities/character-item.entity'andCharacterEquipmentfrom'../../equipment/entities/character-equipment.entity', andDEMO_CHARACTER_STARTING_WEAPON_ITEM_IDfrom'../../demo/demo-character.constants'. - Extend
createDataSource(...)with two more optional-defaulted parameters and branches:
function createDataSource(
locationRepository: InMemoryRepository,
connectionRepository: InMemoryRepository,
characterRepository: InMemoryRepository,
monsterRepository: InMemoryRepository,
locationMonsterRepository: InMemoryRepository,
itemRepository: InMemoryRepository = new InMemoryRepository(),
lootTableRepository: InMemoryRepository = new InMemoryRepository(),
lootEntryRepository: InMemoryRepository = new InMemoryRepository(),
characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
): DataSource {
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === LocationDefinition) return locationRepository;
if (entity === LocationConnection) return connectionRepository;
if (entity === Character) return characterRepository;
if (entity === MonsterDefinition) return monsterRepository;
if (entity === LocationMonster) return locationMonsterRepository;
if (entity === ItemDefinition) return itemRepository;
if (entity === LootTable) return lootTableRepository;
if (entity === LootTableEntry) return lootEntryRepository;
if (entity === CharacterItem) return characterItemRepository;
if (entity === CharacterEquipment) return characterEquipmentRepository;
throw new Error('Unexpected repository');
}),
} as unknown as DataSource;
}
- Add a new
describeblock at the end of the file (before the final closing of the outerdescribe):
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:
CharacterItemfrom'../../items/entities/character-item.entity',CharacterEquipmentfrom'../../equipment/entities/character-equipment.entity',EquipmentSlotfrom'../../items/equipment-slot.enum', andDEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_IDfrom'../../demo/demo-character.constants'(alongside the existingDEMO_CHARACTER_IDimport). - After the existing
if (!existing) { await characterRepository.insert({...}); }block, unconditionally (so it also backfills a demo character seeded before Slice 0.5), add:
const characterItemRepository = dataSource.getRepository(CharacterItem);
const characterEquipmentRepository = dataSource.getRepository(CharacterEquipment);
const existingStartingSword = await characterItemRepository.findOneBy({
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
});
if (!existingStartingSword) {
await characterItemRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
characterId: DEMO_CHARACTER_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
quantity: 1,
});
}
const existingWeaponEquipment = await characterEquipmentRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
});
if (!existingWeaponEquipment) {
await characterEquipmentRepository.insert({
id: DEMO_CHARACTER_STARTING_WEAPON_EQUIPMENT_ID,
characterId: DEMO_CHARACTER_ID,
slot: EquipmentSlot.WEAPON,
characterItemId: DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID,
});
}
- Step 5: Run the tests again
Run: npm run test --workspace=@ashen-realms/api -- vertical-slice.seed.spec.ts
Expected: PASS (all tests, including the two new ones).
- Step 6: Run the full API suite and build
Run: npm run test --workspace=@ashen-realms/api && npm run build --workspace=@ashen-realms/api
Expected: all green, clean build.
- Step 7: Commit
git add apps/api/src/demo/demo-character.constants.ts apps/api/src/database/seeds/vertical-slice.seed.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts
git commit -m "feat(api): seed the starting sword as a real, equipped CharacterItem"
Task 10: Frontend API models and client methods
Files:
- Modify:
apps/web/src/app/core/api/game-api.models.ts - Modify:
apps/web/src/app/core/api/game-api.service.ts
Interfaces:
-
Produces:
EquipmentSlot,InventoryItem,InventoryResponse,EquipmentSlotItem,EquipmentSlots,EquipmentStats,EquipmentResponsetypes, andGameApiService.getInventory(),getEquipment(),equipItem(characterItemId). -
Step 1: Add the models
Append to apps/web/src/app/core/api/game-api.models.ts:
export type EquipmentSlot =
| 'WEAPON'
| 'HEAD'
| 'CHEST'
| 'HANDS'
| 'LEGS'
| 'FEET'
| 'AMULET';
export interface InventoryItem {
id: string;
quantity: number;
equipped: boolean;
item: {
key: string;
name: string;
rarity: ItemRarity;
equipmentSlot: EquipmentSlot | null;
requiredLevel: number;
weaponDamage: number;
bonusAttack: number;
bonusHp: number;
bonusArmor: number;
iconPath: string;
};
}
export interface InventoryResponse {
items: InventoryItem[];
}
export interface EquipmentSlotItem {
characterItemId: string;
item: {
key: string;
name: string;
rarity: ItemRarity;
iconPath: string;
};
}
export type EquipmentSlots = Record<EquipmentSlot, EquipmentSlotItem | null>;
export interface EquipmentStats {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
export interface EquipmentResponse {
slots: EquipmentSlots;
stats: EquipmentStats;
}
- Step 2: Add the client methods
Edit apps/web/src/app/core/api/game-api.service.ts — add the three new imports to the existing import { ... } from './game-api.models'; block (EquipmentResponse, InventoryResponse) and append these methods inside the class:
getInventory(): Observable<InventoryResponse> {
return this.http.get<InventoryResponse>('/api/inventory');
}
getEquipment(): Observable<EquipmentResponse> {
return this.http.get<EquipmentResponse>('/api/equipment');
}
equipItem(characterItemId: string): Observable<EquipmentResponse> {
return this.http.post<EquipmentResponse>('/api/equipment', { characterItemId });
}
- Step 3: Build
Run: npm run build --workspace=@ashen-realms/web
Expected: builds cleanly.
- Step 4: Commit
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/core/api/game-api.service.ts
git commit -m "feat(web): add inventory/equipment API models and client methods"
Task 11: InventoryStore
Files:
- Create:
apps/web/src/app/features/inventory/inventory.store.ts - Test:
apps/web/src/app/features/inventory/inventory.store.spec.ts
Interfaces:
-
Consumes:
GameApiService.getInventory/getEquipment/equipItem(Task 10),WorldStore.refreshCharacter()(existing). -
Produces:
InventoryStorewith readonly signalsinventory,equipment,selectedItemId,loading,equipping,error; methodsload(),selectItem(id),selectedItem(),equip(characterItemId). Task 13's page component and Task 12's detail panel consume these exact names. -
Step 1: Write the failing test
// apps/web/src/app/features/inventory/inventory.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type { EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
import { InventoryStore } from './inventory.store';
const inventory: InventoryResponse = {
items: [
{
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
},
],
};
const equipment: EquipmentResponse = {
slots: {
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
},
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
};
const equippedAfter: EquipmentResponse = {
...equipment,
slots: { ...equipment.slots, WEAPON: { characterItemId: 'item-blade', item: { key: 'bandit-blade', name: 'Räuberklinge', rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png' } } },
stats: { maxHp: 100, attack: 7, weaponDamage: 11, armor: 0 },
};
describe('InventoryStore', () => {
let api: {
getInventory: ReturnType<typeof vi.fn>;
getEquipment: ReturnType<typeof vi.fn>;
equipItem: ReturnType<typeof vi.fn>;
};
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
let store: InventoryStore;
beforeEach(() => {
api = {
getInventory: vi.fn(() => of(inventory)),
getEquipment: vi.fn(() => of(equipment)),
equipItem: vi.fn(() => of(equippedAfter)),
};
worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) };
TestBed.configureTestingModule({
providers: [
InventoryStore,
{ provide: GameApiService, useValue: api },
{ provide: WorldStore, useValue: worldStore },
],
});
store = TestBed.inject(InventoryStore);
});
it('loads inventory and equipment together', async () => {
await store.load();
expect(store.inventory()).toEqual(inventory);
expect(store.equipment()).toEqual(equipment);
});
it('selects an item by id', async () => {
await store.load();
store.selectItem('item-blade');
expect(store.selectedItemId()).toBe('item-blade');
expect(store.selectedItem()).toEqual(inventory.items[1]);
});
it('equips the selected item, refreshes inventory/equipment, and refreshes the character HUD', async () => {
await store.load();
await store.equip('item-blade');
expect(api.equipItem).toHaveBeenCalledWith('item-blade');
expect(store.equipment()).toEqual(equippedAfter);
expect(worldStore.refreshCharacter).toHaveBeenCalledOnce();
});
it('surfaces a German message for a known equip error', async () => {
await store.load();
api.equipItem.mockReturnValue(
throwError(() => new HttpErrorResponse({ error: { code: 'ITEM_LEVEL_REQUIREMENT_NOT_MET' }, status: 400 })),
);
await store.equip('item-blade');
expect(store.error()).toBe('Du erfüllst die Stufenanforderung nicht.');
});
});
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/web -- inventory.store.spec.ts
Expected: FAIL — Cannot find module './inventory.store'.
- Step 3: Implement the store
// apps/web/src/app/features/inventory/inventory.store.ts
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom, forkJoin } from 'rxjs';
import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.';
// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`.
const EQUIPMENT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
CHARACTER_ITEM_NOT_FOUND: 'Dieser Gegenstand konnte nicht gefunden werden.',
ITEM_NOT_OWNED: 'Dieser Gegenstand gehört dir nicht.',
ITEM_NOT_EQUIPPABLE: 'Dieser Gegenstand kann nicht ausgerüstet werden.',
ITEM_LEVEL_REQUIREMENT_NOT_MET: 'Du erfüllst die Stufenanforderung nicht.',
INVALID_EQUIPMENT_SLOT: 'Dieser Ausrüstungsplatz ist ungültig.',
CHARACTER_IN_COMBAT: 'Ausrüstung kann während eines Kampfes nicht geändert werden.',
};
@Injectable({ providedIn: 'root' })
export class InventoryStore {
private readonly inventoryState = signal<InventoryResponse | null>(null);
private readonly equipmentState = signal<EquipmentResponse | null>(null);
private readonly selectedItemIdState = signal<string | null>(null);
private readonly loadingState = signal(false);
private readonly equippingState = signal(false);
private readonly errorState = signal<string | null>(null);
readonly inventory = this.inventoryState.asReadonly();
readonly equipment = this.equipmentState.asReadonly();
readonly selectedItemId = this.selectedItemIdState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly equipping = this.equippingState.asReadonly();
readonly error = this.errorState.asReadonly();
constructor(
private readonly api: GameApiService,
private readonly worldStore: WorldStore,
) {}
async load(): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const { inventory, equipment } = await firstValueFrom(
forkJoin({ inventory: this.api.getInventory(), equipment: this.api.getEquipment() }),
);
this.inventoryState.set(inventory);
this.equipmentState.set(equipment);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
selectItem(characterItemId: string | null): void {
this.selectedItemIdState.set(characterItemId);
}
selectedItem(): InventoryItem | null {
const id = this.selectedItemIdState();
if (!id) {
return null;
}
return this.inventoryState()?.items.find((item) => item.id === id) ?? null;
}
/** Equips an item, then refreshes inventory/equipment and the character HUD (spec §35, §40). */
async equip(characterItemId: string): Promise<void> {
if (this.equippingState()) {
return;
}
this.equippingState.set(true);
this.errorState.set(null);
try {
const equipment = await firstValueFrom(this.api.equipItem(characterItemId));
this.equipmentState.set(equipment);
const inventory = await firstValueFrom(this.api.getInventory());
this.inventoryState.set(inventory);
await this.worldStore.refreshCharacter();
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.equippingState.set(false);
}
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && EQUIPMENT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
}
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
}
}
- Step 4: Run the tests again
Run: npm run test --workspace=@ashen-realms/web -- inventory.store.spec.ts
Expected: PASS (all 4 tests).
- Step 5: Commit
git add apps/web/src/app/features/inventory/inventory.store.ts apps/web/src/app/features/inventory/inventory.store.spec.ts
git commit -m "feat(web): add InventoryStore"
Task 12: Item detail/comparison panel component
Files:
- Create:
apps/web/src/app/features/inventory/inventory-detail-panel.component.ts - Create:
apps/web/src/app/features/inventory/inventory-detail-panel.component.html - Create:
apps/web/src/app/features/inventory/inventory-detail-panel.component.scss - Test:
apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts
Interfaces:
- Consumes:
InventoryItem(Task 10),RARITY_LABELS(existing, fromapps/web/src/app/shared/item-card/item-card.component.ts). - Produces:
InventoryDetailPanelComponentwith inputsitem,equippedItemInSlot,characterLevel,busy, and outputequip: OutputEmitterRef<string>. Task 13 consumes these exact input/output names.
Deliberately a separate component from ItemCardComponent: that component's own spec (item-card.component.spec.ts) asserts no button and no "Anlegen" text exist on it — extending it would break that contract for no reason, since it's reused as-is for grid tiles (Task 13).
- Step 1: Write the failing test
// apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type { InventoryItem } from '../../core/api/game-api.models';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
const wornSword: InventoryItem = {
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
};
const banditBlade: InventoryItem = {
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
};
async function setup(overrides: {
item?: InventoryItem | null;
equippedItemInSlot?: InventoryItem | null;
characterLevel?: number;
busy?: boolean;
}) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({ imports: [InventoryDetailPanelComponent] }).compileComponents();
const fixture = TestBed.createComponent(InventoryDetailPanelComponent);
fixture.componentRef.setInput('item', overrides.item ?? null);
fixture.componentRef.setInput('equippedItemInSlot', overrides.equippedItemInSlot ?? null);
fixture.componentRef.setInput('characterLevel', overrides.characterLevel ?? 1);
fixture.componentRef.setInput('busy', overrides.busy ?? false);
fixture.detectChanges();
return fixture;
}
describe('InventoryDetailPanelComponent', () => {
it('shows a placeholder when nothing is selected', async () => {
const fixture = await setup({ item: null });
expect((fixture.nativeElement as HTMLElement).querySelector('[data-detail-empty]')).not.toBeNull();
});
it('shows Ausgerüstet for the currently equipped item, with no equip button', async () => {
const fixture = await setup({ item: wornSword });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-detail-equipped]')?.textContent).toContain('Ausgerüstet');
expect(element.querySelector('[data-detail-equip]')).toBeNull();
});
it('shows the stat comparison against the equipped item in the same slot', async () => {
const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword });
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? '';
expect(text).toContain('11');
expect(text).toContain('+3');
expect(text).toContain('+1');
});
it('shows a disabled Benötigt Stufe X button when the level requirement is not met', async () => {
const fixture = await setup({ item: { ...banditBlade, item: { ...banditBlade.item, requiredLevel: 5 } }, characterLevel: 1 });
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
expect(button?.textContent).toContain('Benötigt Stufe 5');
expect(button?.disabled).toBe(true);
});
it('emits equip with the characterItemId when Ausrüsten is clicked', async () => {
const fixture = await setup({ item: banditBlade });
const emitted: string[] = [];
fixture.componentInstance.equip.subscribe((id: string) => emitted.push(id));
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]')?.click();
expect(emitted).toEqual(['item-blade']);
});
it('disables the equip button while busy', async () => {
const fixture = await setup({ item: banditBlade, busy: true });
const button = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('[data-detail-equip]');
expect(button?.disabled).toBe(true);
});
});
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/web -- inventory-detail-panel.component.spec.ts
Expected: FAIL — Cannot find module './inventory-detail-panel.component'.
- Step 3: Implement the component
// apps/web/src/app/features/inventory/inventory-detail-panel.component.ts
import { Component, computed, input, output } from '@angular/core';
import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models';
import { RARITY_LABELS } from '../../shared/item-card/item-card.component';
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};
interface StatRow {
label: string;
value: number;
diff: number | null;
}
type StatKey = 'weaponDamage' | 'bonusAttack' | 'bonusHp' | 'bonusArmor';
const STAT_LABELS: ReadonlyArray<{ label: string; key: StatKey }> = [
{ label: 'Waffenschaden', key: 'weaponDamage' },
{ label: 'Angriff', key: 'bonusAttack' },
{ label: 'Leben', key: 'bonusHp' },
{ label: 'Rüstung', key: 'bonusArmor' },
];
/** Selected-item details and equip comparison (spec §32–37). */
@Component({
selector: 'app-inventory-detail-panel',
templateUrl: './inventory-detail-panel.component.html',
styleUrl: './inventory-detail-panel.component.scss',
})
export class InventoryDetailPanelComponent {
readonly item = input<InventoryItem | null>(null);
readonly equippedItemInSlot = input<InventoryItem | null>(null);
readonly characterLevel = input(1);
readonly busy = input(false);
readonly equip = output<string>();
protected readonly rarityLabel = computed(() => {
const item = this.item();
return item ? RARITY_LABELS[item.item.rarity] : '';
});
protected readonly slotLabel = computed(() => {
const slot = this.item()?.item.equipmentSlot;
return slot ? SLOT_LABELS[slot] : null;
});
protected readonly statRows = computed<StatRow[]>(() => {
const item = this.item();
if (!item) {
return [];
}
const compareTo = this.equippedItemInSlot();
const comparable = compareTo && compareTo.id !== item.id ? compareTo.item : null;
return STAT_LABELS.map(({ label, key }) => ({
label,
value: item.item[key],
diff: comparable ? item.item[key] - comparable[key] : null,
})).filter((row) => row.value > 0 || (row.diff ?? 0) !== 0);
});
protected readonly isEquippable = computed(() => !!this.item()?.item.equipmentSlot);
protected readonly meetsLevelRequirement = computed(() => {
const item = this.item();
return item ? item.item.requiredLevel <= this.characterLevel() : true;
});
protected onEquip(): void {
const item = this.item();
if (item) {
this.equip.emit(item.id);
}
}
}
- Step 4: Write the template
<!-- apps/web/src/app/features/inventory/inventory-detail-panel.component.html -->
@if (item(); as item) {
<article class="inventory-detail" aria-label="Gegenstandsdetails">
<div class="inventory-detail__header">
<img class="inventory-detail__icon" [src]="item.item.iconPath" [alt]="item.item.name" />
<div>
<h2 class="inventory-detail__name" data-detail-name>{{ item.item.name }}</h2>
<p class="inventory-detail__rarity" data-detail-rarity>{{ rarityLabel() }}</p>
@if (slotLabel(); as slot) {
<p class="inventory-detail__meta">{{ slot }} · Stufe {{ item.item.requiredLevel }}</p>
}
</div>
</div>
@if (statRows().length) {
<dl class="inventory-detail__stats" data-detail-stats>
@for (row of statRows(); track row.label) {
<div class="inventory-detail__stat">
<dt>{{ row.label }}</dt>
<dd>
{{ row.value }}
@if (row.diff !== null && row.diff !== 0) {
<span
class="inventory-detail__diff"
[class.inventory-detail__diff--positive]="row.diff > 0"
[class.inventory-detail__diff--negative]="row.diff < 0"
>
({{ row.diff > 0 ? '+' : '' }}{{ row.diff }})
</span>
}
</dd>
</div>
}
</dl>
}
<div class="inventory-detail__actions">
@if (item.equipped) {
<span class="inventory-detail__equipped" data-detail-equipped>Ausgerüstet</span>
} @else if (!isEquippable()) {
<span class="inventory-detail__note">Nicht ausrüstbar</span>
} @else if (!meetsLevelRequirement()) {
<button type="button" class="inventory-detail__equip" data-detail-equip disabled>
Benötigt Stufe {{ item.item.requiredLevel }}
</button>
} @else {
<button
type="button"
class="inventory-detail__equip"
data-detail-equip
[disabled]="busy()"
(click)="onEquip()"
>
Ausrüsten
</button>
}
</div>
</article>
} @else {
<p class="inventory-detail__empty" data-detail-empty>Wähle einen Gegenstand aus deinem Inventar.</p>
}
- Step 5: Write the stylesheet
// apps/web/src/app/features/inventory/inventory-detail-panel.component.scss
:host {
display: block;
}
.inventory-detail {
padding: var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
background:
linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96);
box-shadow: var(--ar-shadow-raised);
}
.inventory-detail__header {
display: flex;
gap: var(--ar-space-3);
align-items: center;
margin-block-end: var(--ar-space-3);
}
.inventory-detail__icon {
inline-size: 4rem;
block-size: 4rem;
padding: var(--ar-space-1);
border: 1px solid var(--ar-border);
background: linear-gradient(180deg, #1b1f22, #0d1012);
object-fit: contain;
}
.inventory-detail__name {
margin: 0;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.15rem;
font-weight: 400;
}
.inventory-detail__rarity {
margin: 0.15rem 0 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.inventory-detail__meta {
margin: 0.25rem 0 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
}
.inventory-detail__stats {
display: grid;
gap: var(--ar-space-2);
margin: 0 0 var(--ar-space-3);
padding-block: var(--ar-space-2);
border-block: 1px solid rgb(155 122 66 / 0.45);
}
.inventory-detail__stat {
display: flex;
justify-content: space-between;
}
.inventory-detail__stat dt {
color: var(--ar-text-muted);
}
.inventory-detail__stat dd {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-detail__diff--positive {
color: var(--ar-success);
}
.inventory-detail__diff--negative {
color: var(--ar-danger);
}
.inventory-detail__actions {
display: flex;
justify-content: center;
}
.inventory-detail__equipped {
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-detail__note {
color: var(--ar-text-muted);
font-style: italic;
}
.inventory-detail__equip {
inline-size: 100%;
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #263b4b, #17232d);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
}
.inventory-detail__equip:hover:not(:disabled) {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.inventory-detail__equip:disabled {
border-color: var(--ar-border);
color: var(--ar-text-muted);
background: #1a1c1d;
}
.inventory-detail__empty {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
font-style: italic;
text-align: center;
}
- Step 6: Run the tests again
Run: npm run test --workspace=@ashen-realms/web -- inventory-detail-panel.component.spec.ts
Expected: PASS (all 6 tests).
- Step 7: Commit
git add apps/web/src/app/features/inventory/inventory-detail-panel.component.ts apps/web/src/app/features/inventory/inventory-detail-panel.component.html apps/web/src/app/features/inventory/inventory-detail-panel.component.scss apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts
git commit -m "feat(web): add inventory item detail/comparison panel"
Task 13: Inventory page (grid + equipment overview + effective stats)
Files:
- Create:
apps/web/src/app/features/inventory/inventory-page.component.ts - Create:
apps/web/src/app/features/inventory/inventory-page.component.html - Create:
apps/web/src/app/features/inventory/inventory-page.component.scss - Test:
apps/web/src/app/features/inventory/inventory-page.component.spec.ts
Interfaces:
-
Consumes:
InventoryStore(Task 11),InventoryDetailPanelComponent(Task 12),ItemCardComponent(existing),WorldStore(existing, forcharacter()?.level). -
Produces:
InventoryPageComponent, selectorapp-inventory-page. Task 14 routes/inventoryto it. -
Step 1: Write the failing test
// apps/web/src/app/features/inventory/inventory-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type { CharacterResponse, EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models';
import { WorldStore } from '../world/world.store';
import { InventoryPageComponent } from './inventory-page.component';
import { InventoryStore } from './inventory.store';
const inventory: InventoryResponse = {
items: [
{
id: 'item-sword',
quantity: 1,
equipped: true,
item: {
key: 'worn-short-sword',
name: 'Abgenutztes Kurzschwert',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 8,
bonusAttack: 0,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/worn-short-sword.png',
},
},
{
id: 'item-blade',
quantity: 1,
equipped: false,
item: {
key: 'bandit-blade',
name: 'Räuberklinge',
rarity: 'COMMON',
equipmentSlot: 'WEAPON',
requiredLevel: 1,
weaponDamage: 11,
bonusAttack: 1,
bonusHp: 0,
bonusArmor: 0,
iconPath: '/images/items/bandit-blade.png',
},
},
],
};
const equipment: EquipmentResponse = {
slots: {
WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } },
HEAD: null,
CHEST: null,
HANDS: null,
LEGS: null,
FEET: null,
AMULET: null,
},
stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 },
};
const character: CharacterResponse = {
id: 'character-1',
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' },
};
async function setup() {
const inventoryStore = {
inventory: signal(inventory),
equipment: signal(equipment),
selectedItemId: signal<string | null>(null),
loading: signal(false),
equipping: signal(false),
error: signal<string | null>(null),
load: vi.fn(() => Promise.resolve()),
selectItem: vi.fn(),
selectedItem: vi.fn(() => null),
equip: vi.fn(() => Promise.resolve()),
};
const worldStore = { character: signal(character) };
await TestBed.configureTestingModule({
imports: [InventoryPageComponent],
providers: [
{ provide: InventoryStore, useValue: inventoryStore },
{ provide: WorldStore, useValue: worldStore },
],
}).compileComponents();
const fixture = TestBed.createComponent(InventoryPageComponent);
fixture.detectChanges();
return { fixture, inventoryStore };
}
describe('InventoryPageComponent', () => {
it('loads the inventory on init', async () => {
const { inventoryStore } = await setup();
expect(inventoryStore.load).toHaveBeenCalledOnce();
});
it('renders one tile per owned item', async () => {
const { fixture } = await setup();
const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot');
expect(tiles.length).toBe(2);
});
it('marks the equipped item with a badge', async () => {
const { fixture } = await setup();
expect((fixture.nativeElement as HTMLElement).querySelector('[data-slot-equipped]')).not.toBeNull();
});
it('selects an item when its tile is clicked', async () => {
const { fixture, inventoryStore } = await setup();
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('.inventory-page__slot')[1].click();
expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade');
});
it('shows the equipment overview with all seven slots and empty ones as Leer', async () => {
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? '';
expect(text).toContain('Waffe');
expect(text).toContain('Abgenutztes Kurzschwert');
expect(text).toContain('Kopf');
expect(text).toContain('Leer');
});
it('shows the effective stats summary from the equipment response', async () => {
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? '';
expect(text).toContain('100');
expect(text).toContain('6');
expect(text).toContain('8');
});
});
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/web -- inventory-page.component.spec.ts
Expected: FAIL — Cannot find module './inventory-page.component'.
- Step 3: Implement the component
// apps/web/src/app/features/inventory/inventory-page.component.ts
import { Component, OnInit, computed, inject } from '@angular/core';
import type { EquipmentSlot } from '../../core/api/game-api.models';
import { ItemCardComponent } from '../../shared/item-card/item-card.component';
import { WorldStore } from '../world/world.store';
import { InventoryDetailPanelComponent } from './inventory-detail-panel.component';
import { InventoryStore } from './inventory.store';
const SLOT_ORDER: readonly EquipmentSlot[] = [
'WEAPON',
'HEAD',
'CHEST',
'HANDS',
'LEGS',
'FEET',
'AMULET',
];
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = {
WEAPON: 'Waffe',
HEAD: 'Kopf',
CHEST: 'Brust',
HANDS: 'Handschuhe',
LEGS: 'Beine',
FEET: 'Stiefel',
AMULET: 'Amulett',
};
@Component({
selector: 'app-inventory-page',
imports: [ItemCardComponent, InventoryDetailPanelComponent],
templateUrl: './inventory-page.component.html',
styleUrl: './inventory-page.component.scss',
})
export class InventoryPageComponent implements OnInit {
protected readonly inventoryStore = inject(InventoryStore);
private readonly worldStore = inject(WorldStore);
protected readonly slotOrder = SLOT_ORDER;
protected readonly slotLabels = SLOT_LABELS;
protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1);
protected readonly equippedItemInSelectedSlot = computed(() => {
const selected = this.inventoryStore.selectedItem();
if (!selected?.item.equipmentSlot) {
return null;
}
return (
this.inventoryStore.inventory()?.items.find(
(item) => item.equipped && item.item.equipmentSlot === selected.item.equipmentSlot,
) ?? null
);
});
ngOnInit(): void {
void this.inventoryStore.load();
}
protected selectItem(itemId: string): void {
this.inventoryStore.selectItem(itemId);
}
protected async equipSelected(characterItemId: string): Promise<void> {
await this.inventoryStore.equip(characterItemId);
}
protected retry(): void {
void this.inventoryStore.load();
}
}
- Step 4: Write the template
<!-- apps/web/src/app/features/inventory/inventory-page.component.html -->
<section class="inventory-page" aria-label="Inventar">
@if (inventoryStore.loading() && !inventoryStore.inventory()) {
<p class="inventory-page__notice" role="status">Inventar wird geladen…</p>
} @else if (inventoryStore.inventory(); as inventory) {
<section class="inventory-page__grid" aria-label="Gegenstände">
@for (entry of inventory.items; track entry.id) {
<button
type="button"
class="inventory-page__slot"
[class.inventory-page__slot--selected]="inventoryStore.selectedItemId() === entry.id"
[attr.aria-pressed]="inventoryStore.selectedItemId() === entry.id"
(click)="selectItem(entry.id)"
>
<app-item-card [item]="entry.item" [quantity]="entry.quantity" />
@if (entry.equipped) {
<span class="inventory-page__equipped-badge" data-slot-equipped>Ausgerüstet</span>
}
</button>
} @empty {
<p class="inventory-page__empty" data-inventory-empty>Noch keine Gegenstände gefunden.</p>
}
</section>
<aside class="inventory-page__side" aria-label="Details und Ausrüstung">
<app-inventory-detail-panel
[item]="inventoryStore.selectedItem()"
[equippedItemInSlot]="equippedItemInSelectedSlot()"
[characterLevel]="characterLevel()"
[busy]="inventoryStore.equipping()"
(equip)="equipSelected($event)"
/>
@if (inventoryStore.equipment(); as equipment) {
<section class="inventory-page__equipment" aria-label="Ausrüstung">
<h3>Ausrüstung</h3>
<ul class="inventory-page__equipment-list">
@for (slot of slotOrder; track slot) {
<li>
<span class="inventory-page__equipment-slot-label">{{ slotLabels[slot] }}</span>
<span class="inventory-page__equipment-slot-value">
{{ equipment.slots[slot]?.item?.name ?? 'Leer' }}
</span>
</li>
}
</ul>
<dl class="inventory-page__stats" data-inventory-stats>
<div><dt>Leben</dt><dd>{{ equipment.stats.maxHp }}</dd></div>
<div><dt>Angriff</dt><dd>{{ equipment.stats.attack }}</dd></div>
<div><dt>Waffenschaden</dt><dd>{{ equipment.stats.weaponDamage }}</dd></div>
<div><dt>Rüstung</dt><dd>{{ equipment.stats.armor }}</dd></div>
</dl>
</section>
}
</aside>
}
@if (inventoryStore.error(); as error) {
<section class="inventory-page__notice inventory-page__notice--error" role="alert">
<p>{{ error }}</p>
<button type="button" data-inventory-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>
- Step 5: Write the stylesheet
// apps/web/src/app/features/inventory/inventory-page.component.scss
:host {
display: block;
}
.inventory-page {
display: grid;
grid-template-columns: 1fr 20rem;
gap: var(--ar-space-5);
align-items: start;
padding: var(--ar-space-5);
}
.inventory-page__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: var(--ar-space-3);
}
.inventory-page__slot {
position: relative;
padding: var(--ar-space-2);
border: 1px solid transparent;
border-radius: var(--ar-radius-sm);
background: transparent;
}
.inventory-page__slot--selected {
border-color: var(--ar-border-highlight);
background: rgb(155 122 66 / 0.12);
}
.inventory-page__equipped-badge {
position: absolute;
inset-block-start: 0.1rem;
inset-inline-start: 50%;
translate: -50% 0;
padding: 0.05rem 0.4rem;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-gold);
background: rgb(9 11 13 / 0.9);
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__empty {
grid-column: 1 / -1;
padding: var(--ar-space-4);
color: var(--ar-text-muted);
font-style: italic;
}
.inventory-page__side {
display: grid;
gap: var(--ar-space-4);
}
.inventory-page__equipment {
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel);
}
.inventory-page__equipment h3 {
margin: 0 0 var(--ar-space-2);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
font-weight: 400;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inventory-page__equipment-list {
display: grid;
gap: var(--ar-space-1);
margin: 0 0 var(--ar-space-3);
padding: 0;
list-style: none;
}
.inventory-page__equipment-list li {
display: flex;
justify-content: space-between;
padding-block: var(--ar-space-1);
border-block-end: 1px solid rgb(85 74 57 / 0.4);
font-size: var(--ar-font-sm);
}
.inventory-page__equipment-slot-label {
color: var(--ar-text-muted);
}
.inventory-page__stats {
display: grid;
gap: var(--ar-space-1);
margin: 0;
padding-block-start: var(--ar-space-2);
border-block-start: 1px solid rgb(155 122 66 / 0.45);
}
.inventory-page__stats div {
display: flex;
justify-content: space-between;
}
.inventory-page__stats dt {
color: var(--ar-text-muted);
}
.inventory-page__stats dd {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
}
.inventory-page__notice {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
text-align: center;
}
.inventory-page__notice--error {
color: var(--ar-danger);
}
@media (width < 960px) {
.inventory-page {
grid-template-columns: 1fr;
}
}
- Step 6: Run the tests again
Run: npm run test --workspace=@ashen-realms/web -- inventory-page.component.spec.ts
Expected: PASS (all 6 tests).
- Step 7: Commit
git add apps/web/src/app/features/inventory/inventory-page.component.ts apps/web/src/app/features/inventory/inventory-page.component.html apps/web/src/app/features/inventory/inventory-page.component.scss apps/web/src/app/features/inventory/inventory-page.component.spec.ts
git commit -m "feat(web): add inventory page with grid, detail panel, and equipment overview"
Task 14: Route and side-nav wiring
Files:
- Modify:
apps/web/src/app/app.routes.ts - Modify:
apps/web/src/app/layout/side-navigation/side-navigation.component.html
Interfaces:
-
Consumes:
InventoryPageComponent(Task 13). -
Produces:
/inventoryroute; enabled "Inventar" nav button. -
Step 1: Register the route
// apps/web/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { AppShellComponent } from './layout/app-shell/app-shell.component';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'world' },
{
path: '',
component: AppShellComponent,
children: [
{
path: 'world',
loadComponent: () =>
import('./features/world/world-page.component').then(
(module) => module.WorldPageComponent,
),
},
{
path: 'hunt',
loadComponent: () =>
import('./features/hunting/hunt-page/hunt-page.component').then(
(module) => module.HuntPageComponent,
),
},
{
path: 'combat/:combatId',
loadComponent: () =>
import('./features/combat/combat-page/combat-page.component').then(
(module) => module.CombatPageComponent,
),
},
{
path: 'inventory',
loadComponent: () =>
import('./features/inventory/inventory-page.component').then(
(module) => module.InventoryPageComponent,
),
},
],
},
{ path: '**', redirectTo: 'world' },
];
- Step 2: Enable the nav button
In apps/web/src/app/layout/side-navigation/side-navigation.component.html, replace the disabled Inventar button:
<button
class="side-navigation__item"
type="button"
routerLink="/inventory"
routerLinkActive="side-navigation__item--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
data-navigation="inventory"
aria-label="Inventar"
>
<img src="/images/hud/runtime/InventoryIcon-128.png" alt="" />
<span>Inventar</span>
</button>
- Step 3: Build
Run: npm run build --workspace=@ashen-realms/web
Expected: builds cleanly.
- Step 4: Manual check
Run the dev server (npm run start --workspace=@ashen-realms/web, and the API alongside it), open the app in a browser, click "Inventar" in the side nav, and confirm the page loads without a console error.
- Step 5: Commit
git add apps/web/src/app/app.routes.ts apps/web/src/app/layout/side-navigation/side-navigation.component.html
git commit -m "feat(web): route and enable the Inventar nav entry"
Task 15: Reward screen "Inventar öffnen" button
Files:
- Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.ts - Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.html - Modify:
apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
Interfaces:
-
Produces:
CombatPageComponent.goToInventory(). Spec §41: shown only on the victory screen, does not auto-equip anything. -
Step 1: Extend the failing test
Add to apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts, next to the existing 'navigates to /hunt from the victory screen' test:
it('navigates to /inventory from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-inventory]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/inventory']);
});
- Step 2: Run to confirm failure
Run: npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts
Expected: FAIL — data-combat-to-inventory not found.
- Step 3: Add the method
In apps/web/src/app/features/combat/combat-page/combat-page.component.ts, next to goToHunt():
protected goToInventory(): void {
void this.router.navigate(['/inventory']);
}
- Step 4: Add the button
In apps/web/src/app/features/combat/combat-page/combat-page.component.html, inside the @if (combat.status === 'WON') block, right after the existing rewards section and before the Zur Jagd button:
<div class="outcome__actions">
<button type="button" class="outcome__button" data-combat-to-inventory (click)="goToInventory()">
Inventar öffnen
</button>
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
</button>
</div>
Remove the now-duplicated standalone Zur Jagd button that previously sat directly under the rewards section (the one moved into .outcome__actions above replaces it — do not leave two).
- Step 5: Add a small style for the two-button row
In apps/web/src/app/features/combat/combat-page/combat-page.component.scss, next to the existing .outcome__button rule:
.outcome__actions {
display: flex;
gap: var(--ar-space-2);
justify-content: center;
margin-block-start: var(--ar-space-2);
}
.outcome__actions .outcome__button {
margin-block-start: 0;
}
- Step 6: Run the tests again
Run: npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts
Expected: PASS (all tests, including the new one and the pre-existing Zur Jagd navigation test).
- Step 7: Build
Run: npm run build --workspace=@ashen-realms/web
Expected: builds cleanly.
- Step 8: Commit
git add apps/web/src/app/features/combat/combat-page/combat-page.component.ts apps/web/src/app/features/combat/combat-page/combat-page.component.html apps/web/src/app/features/combat/combat-page/combat-page.component.scss apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
git commit -m "feat(web): add Inventar öffnen button to the victory screen"
Task 16: Full-loop verification (spec §56–§60)
Files: none (verification only).
- Step 1: Run the full backend suite
Run: npm run test --workspace=@ashen-realms/api
Expected: all tests pass, including character-stats.service.spec.ts, equipment.service.spec.ts, inventory.service.spec.ts, combat-equipment-integration.spec.ts, vertical-slice.seed.spec.ts, and the untouched pre-existing suites.
- Step 2: Run the full frontend suite
Run: npm run test --workspace=@ashen-realms/web
Expected: all tests pass, including the four new inventory spec files and the updated combat-page.component.spec.ts.
- Step 3: Build both apps
Run: npm run build --workspace=@ashen-realms/api && npm run build --workspace=@ashen-realms/web
Expected: both build cleanly.
- Step 4: Run and verify the migration
Run: npm run db:migrate
Expected: CreateEquipment1789000000000 applies cleanly on top of the existing schema.
- Step 5: Run the seed and verify idempotency
Run: npm run db:seed twice in a row.
Expected: second run makes no destructive changes — demo character still has exactly one worn-short-sword CharacterItem and one WEAPON CharacterEquipment row (or whatever the player has since equipped, if this is a shared dev DB with prior play).
- Step 6: Manual browser walkthrough
With the API and web dev servers running, walk the full loop from spec §56/§57:
-
Open
/inventoryfresh: seeAbgenutztes Kurzschwertowned and markedAusgerüstetin theWEAPONslot. -
Travel to Verbrannte Straße, hunt, fight a
StraßenräuberuntilRäuberklingedrops. -
On the victory screen, click
Inventar öffnen. -
Select
Räuberklinge; confirm the comparison shows11 Waffenschaden (+3)and+1 Angriff(or(+1)depending on layout) against the equipped sword. -
Click
Ausrüsten; confirmRäuberklingebecomesAusgerüstet,Abgenutztes Kurzschwertbecomes selectable/unequipped, and the equipment-overview stats update. -
Refresh the page; confirm
Räuberklingeis 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.