From f008e53cc644b97400db381d862506dcaec7c745 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 23:14:25 +0200 Subject: [PATCH] docs(slice-0.4): add the First Loot implementation plan Co-Authored-By: Claude Opus 5 --- ...026-08-19-playable-slice-0.4-first-loot.md | 3919 +++++++++++++++++ 1 file changed, 3919 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md diff --git a/docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md b/docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md new file mode 100644 index 0000000..9144d4d --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md @@ -0,0 +1,3919 @@ +# Playable Slice 0.4: First Loot — 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:** Winning a combat grants server-rolled XP, silver, and loot exactly once, persisted as `CombatReward` + `CharacterItem`, and the Angular victory screen renders that persisted reward — surviving a browser refresh without rerolling. + +**Architecture:** `CombatEngineService` stays completely unaware of rewards. When `CombatService.performAction` sees the engine flip `ACTIVE → WON`, it calls `CombatRewardService.grantVictoryRewards(manager, combat)` **inside the transaction it already opens**, which already holds a pessimistic write lock on the combat row. That service validates eligibility, rolls silver and loot through the existing injectable `RandomSource`, mutates `Character.experience`/`Character.silver`, upserts `CharacterItem` rows, and writes one `CombatReward` (+ `CombatRewardItem` rows) whose `combat_id` carries a unique index — so idempotency is enforced by both the service and the database. `LootService` only rolls; it never persists. Every combat read (`GET /combats/:id`, `GET /combats/active`, and the action response) includes the persisted `rewards`, so a refresh replays state instead of regenerating it. + +**Tech Stack:** NestJS 11 + TypeORM (raw-SQL migrations, `synchronize: false`) + PostgreSQL on the API; Angular 22 standalone components + signals + Vitest on the web app. Jest for API tests. Icon extraction via PowerShell + `System.Drawing`. + +**Spec:** `docs/playable-slices/Ashen Realms – Playable Slice 0.4_ First Loot.md` + +**Supporting content docs:** +- `docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md` §19 (Tier 1 item stats), §27–28 (monster loot tables) +- `docs/superpowers/plans/2026-08-19-playable-slice-0.3-first-combat.md` (the combat flow this extends) + +## Global Constraints + +- **Rewards are server-authoritative.** The client never sends XP, silver, `itemId`, drop results, drop chances, loot-table results, or reward quantities (spec §5, §49). The global `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` in `apps/api/src/app.config.ts` already rejects unknown body fields with 400. +- **Only `status = WON` combats grant rewards** (spec §6). `ACTIVE` and `LOST` must be rejected. +- **One combat grants rewards at most once** (spec §7). Enforced by a service-level existence check *and* a unique index on `combat_rewards.combat_id`. +- **All reward persistence is atomic** (spec §21, §22). Everything happens in the single transaction `CombatService.performAction` already opens. A mid-way failure must leave no XP, no silver, no items, and no reward record. +- **`CombatEngineService` must contain no reward logic** (spec §30, §56). It must not learn about XP, silver, loot tables, inventory, equipment, or item rarity. +- **Randomness comes only from the existing `RandomSource`** injected via the `RANDOM_SOURCE` token (spec §19). No new random abstraction, no direct `Math.random()` in services. Tests inject fixed values; **no statistical or flaky tests** (spec §18, §42). +- **Roll order is fixed and documented** so injected randoms are predictable: silver first, then loot entries in ascending `position`. A `minQuantity === maxQuantity` entry consumes **no** quantity random. +- **Loot probabilities are data-driven** — they live in `loot_table_entries` rows, never in TypeScript branching (spec §53). +- **Migrations** use raw SQL, UUID primary keys, explicit FKs and indexes, preserve existing data, and never touch `synchronize` (spec §37). Entities are auto-loaded via `autoLoadEntities: true`. +- **The seed stays idempotent** (spec §38). Re-running must not duplicate item definitions, loot tables, or loot entries, and must never reset XP, reset silver, delete player items, or delete combat rewards. +- **No equipment functionality** (spec §4, §33): no equip/unequip, no effective equipment stats, no comparison UI, no slot replacement, no inventory management, no selling, no merchants, no potions/consumable use, no boss guarantees, no pity systems, no duplicate protection. +- All demo-character endpoints use `DEMO_CHARACTER_ID` from `apps/api/src/demo/demo-character.constants.ts` — no auth exists yet. +- German player-facing copy throughout, matching existing screens. +- Every task must leave `npm run build` and the relevant test suite green before moving to the next task. + +## Documented Deviations + +These are deliberate, spec-sanctioned omissions. Each must appear as a code comment where it applies, and must not be silently "fixed" by an implementer. + +1. **Kleiner Heiltrank (10% on Straßenräuber) is not in the loot table.** Spec §16: "Do not implement potions or combat consumables solely because the final loot table contains them… Unsupported entries may be deferred explicitly." Its `ItemDefinition` **is** seeded (it costs nothing and Slice 0.5+ will want it); only the `loot_table_entry` is deferred. +2. **`sellPrice` is `0` for every item.** The balancing doc's Grenzmarken table (§31) lists *purchase* prices, not sell prices, and there are no merchants in this slice. Inventing sell values would be exactly the "silently invent replacement probabilities/numbers" that spec §15 forbids. +3. **`ash-pelt` (Aschenfell) ships with stand-in art** — a fur/hide patch cropped from the Aschenratte portrait — because no dedicated Aschenfell artwork exists. Flagged in the extraction script and the seed. +4. **No level-up.** Spec §10: the project has no level-up implementation to reuse, so XP is persisted and nothing more. `CombatRewardDto` stays shape-compatible with future level information. +5. **Rarity comes from the balancing doc, not from spec §31's mockup.** The slice doc's example screen shows Räuberklinge as "Selten"; `docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md` §19 defines it as **Gewöhnlich**. The seeded data is authoritative and the UI renders whatever the server sends. + +--- + +## File Structure + +**Backend — new:** +- `apps/api/src/shared/random-source.ts` — moved from `hunting/`; the one random abstraction +- `apps/api/src/shared/roll-range.ts` + spec — pure inclusive integer roll +- `apps/api/src/items/item-type.enum.ts` — `WEAPON | ARMOR | MATERIAL | CONSUMABLE` +- `apps/api/src/items/equipment-slot.enum.ts` — `WEAPON | HEAD | CHEST | HANDS | LEGS | FEET | AMULET` +- `apps/api/src/items/item-rarity.enum.ts` — `COMMON | RARE | EPIC` +- `apps/api/src/items/entities/item-definition.entity.ts` +- `apps/api/src/items/entities/character-item.entity.ts` +- `apps/api/src/loot/entities/loot-table.entity.ts` +- `apps/api/src/loot/entities/loot-table-entry.entity.ts` +- `apps/api/src/loot/loot.service.ts` + spec — pure rolls, no persistence +- `apps/api/src/loot/loot.module.ts` +- `apps/api/src/rewards/entities/combat-reward.entity.ts` +- `apps/api/src/rewards/entities/combat-reward-item.entity.ts` +- `apps/api/src/rewards/rewards.errors.ts` +- `apps/api/src/rewards/combat-reward.service.ts` + spec — grant + read +- `apps/api/src/rewards/rewards.module.ts` +- `apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts` +- `apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts` +- `apps/api/src/database/seeds/item.constants.ts` — stable item/loot-table UUIDs and keys +- `apps/api/src/database/seeds/item-content.ts` — the Tier-1 item + loot-table content tables +- `tools/extract-item-icons.ps1` — sheet → 256×256 icons + +**Backend — modified:** +- `apps/api/src/hunting/random-source.ts` — **deleted**, imports repointed +- `apps/api/src/hunting/hunting.module.ts`, `hunting.service.ts`, `hunting.service.spec.ts` — import from `shared/` +- `apps/api/src/characters/entities/character.entity.ts` — add `silver` +- `apps/api/src/characters/characters.service.ts` + spec — expose `silver` +- `apps/api/src/monsters/entities/monster-definition.entity.ts` — add nullable `lootTableId` + relation +- `apps/api/src/combat/combat.service.ts` + spec — grant on `WON`, include `rewards` in every DTO +- `apps/api/src/combat/combat.module.ts` — import `RewardsModule` +- `apps/api/src/database/seeds/vertical-slice.seed.ts` + spec — seed items, loot tables, entries, monster wiring + +**Frontend — new:** +- `apps/web/src/app/shared/item-card/item-card.component.ts` + `.html` + `.scss` + spec +- `apps/web/public/images/items/*.png` — 12 extracted icons + +**Frontend — modified:** +- `apps/web/src/app/core/api/game-api.models.ts` — `ItemRarity`, `CombatRewardItem`, `CombatReward`, `Combat.rewards`, `CharacterResponse.silver` +- `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` + `.html` + `.scss` + spec — reward summary +- `apps/web/src/app/features/world/world.store.ts` + spec — `refreshCharacter()` +- `apps/web/src/app/layout/top-bar/top-bar.component.html` + `.scss` — silver + XP + +--- + +## Task 1: Shared RandomSource and the inclusive integer roll + +Silver rolls and loot rolls must use the **same** injectable random abstraction hunting already uses (spec §19). It currently lives under `hunting/`, which is the wrong home once three subsystems share it. Move it to `shared/` and add the one pure helper both silver and quantity rolls need. + +**Files:** +- Create: `apps/api/src/shared/random-source.ts` +- Create: `apps/api/src/shared/roll-range.ts` +- Test: `apps/api/src/shared/roll-range.spec.ts` +- Delete: `apps/api/src/hunting/random-source.ts` +- Modify: `apps/api/src/hunting/hunting.module.ts:11` +- Modify: `apps/api/src/hunting/hunting.service.ts:19-20` +- Modify: `apps/api/src/hunting/hunting.service.spec.ts:15` + +**Interfaces:** +- Consumes: nothing. +- Produces: `RandomSource` (interface, `next(): number` uniform in `[0, 1)`), `RANDOM_SOURCE` (injection token symbol), `systemRandomSource` — all from `apps/api/src/shared/random-source.ts`. `rollInclusive(random: RandomSource, min: number, max: number): number` from `apps/api/src/shared/roll-range.ts`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/api/src/shared/roll-range.spec.ts +import type { RandomSource } from './random-source'; +import { rollInclusive } from './roll-range'; + +function fixed(...values: number[]): RandomSource { + let index = 0; + return { next: () => values[index++] }; +} + +describe('rollInclusive', () => { + it('maps the bottom of the random range to min and the top to max', () => { + expect(rollInclusive(fixed(0), 4, 7)).toBe(4); + expect(rollInclusive(fixed(0.999), 4, 7)).toBe(7); + }); + + it('spreads the random range evenly across every value in between', () => { + expect(rollInclusive(fixed(0.25), 4, 7)).toBe(5); + expect(rollInclusive(fixed(0.5), 4, 7)).toBe(6); + expect(rollInclusive(fixed(0.5), 9, 15)).toBe(12); + }); + + it('never exceeds max even if the source yields exactly 1', () => { + expect(rollInclusive(fixed(1), 9, 15)).toBe(15); + }); + + it('returns the single value when min equals max', () => { + expect(rollInclusive(fixed(0.7), 1, 1)).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- roll-range` +Expected: FAIL — `Cannot find module './roll-range'`. + +- [ ] **Step 3: Move the random source and add the roll helper** + +Create `apps/api/src/shared/random-source.ts` with exactly the contents of the current `apps/api/src/hunting/random-source.ts`: + +```ts +export interface RandomSource { + next(): number; // uniform value in [0, 1) +} + +export const RANDOM_SOURCE = Symbol('RANDOM_SOURCE'); + +export const systemRandomSource: RandomSource = { + next: () => Math.random(), +}; +``` + +Create `apps/api/src/shared/roll-range.ts`: + +```ts +import type { RandomSource } from './random-source'; + +/** + * Rolls an inclusive integer in [min, max] from one value of `random`. + * + * `RandomSource.next()` is documented as [0, 1), but the clamp keeps a + * misbehaving or hand-stubbed source from ever exceeding `max`. + */ +export function rollInclusive( + random: RandomSource, + min: number, + max: number, +): number { + if (max <= min) { + return min; + } + + return Math.min(max, min + Math.floor(random.next() * (max - min + 1))); +} +``` + +Delete `apps/api/src/hunting/random-source.ts`. + +- [ ] **Step 4: Repoint the three hunting imports** + +In `apps/api/src/hunting/hunting.module.ts`, replace line 11: + +```ts +import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; +``` + +In `apps/api/src/hunting/hunting.service.ts`, replace lines 19–20: + +```ts +import { RANDOM_SOURCE } from '../shared/random-source'; +import type { RandomSource } from '../shared/random-source'; +``` + +In `apps/api/src/hunting/hunting.service.spec.ts`, replace line 15: + +```ts +import type { RandomSource } from '../shared/random-source'; +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api` +Expected: PASS — all previously green suites still pass, plus 4 new `rollInclusive` tests. Nothing may reference `hunting/random-source` any more: + +Run: `git grep -n "hunting/random-source\|from './random-source'" -- apps/api/src` +Expected: only matches inside `apps/api/src/shared/`. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/shared apps/api/src/hunting +git commit -m "refactor(api): move RandomSource to shared and add rollInclusive" +``` + +--- + +## Task 2: Item, loot, and reward entities + +All new tables as TypeORM entities, plus the two columns existing entities gain. No migration yet — Task 3 writes the SQL that matches these. + +**Files:** +- Create: `apps/api/src/items/item-type.enum.ts` +- Create: `apps/api/src/items/equipment-slot.enum.ts` +- Create: `apps/api/src/items/item-rarity.enum.ts` +- Create: `apps/api/src/items/entities/item-definition.entity.ts` +- Create: `apps/api/src/items/entities/character-item.entity.ts` +- Create: `apps/api/src/loot/entities/loot-table.entity.ts` +- Create: `apps/api/src/loot/entities/loot-table-entry.entity.ts` +- Create: `apps/api/src/rewards/entities/combat-reward.entity.ts` +- Create: `apps/api/src/rewards/entities/combat-reward-item.entity.ts` +- Modify: `apps/api/src/characters/entities/character.entity.ts` +- Modify: `apps/api/src/monsters/entities/monster-definition.entity.ts` +- Test: `apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: + - `ItemType` enum: `WEAPON | ARMOR | MATERIAL | CONSUMABLE` + - `EquipmentSlot` enum: `WEAPON | HEAD | CHEST | HANDS | LEGS | FEET | AMULET` + - `ItemRarity` enum: `COMMON | RARE | EPIC` + - `ItemDefinition` — `id, key, name, description, type, equipmentSlot: EquipmentSlot | null, rarity, tier, requiredLevel, weaponDamage, bonusHp, bonusAttack, bonusArmor, sellPrice, iconPath, createdAt, updatedAt` + - `CharacterItem` — `id, characterId, itemDefinitionId, quantity, createdAt, updatedAt` + - `LootTable` — `id, key, name, createdAt, updatedAt` + - `LootTableEntry` — `id, lootTableId, itemDefinitionId, position, dropChance: string, minQuantity, maxQuantity, enabled, createdAt, updatedAt` + - `CombatReward` — `id, combatId, characterId, experienceGranted, silverGranted, createdAt` + - `CombatRewardItem` — `id, combatRewardId, characterItemId, itemDefinitionId, quantity, createdAt` + - `Character.silver: number` + - `MonsterDefinition.lootTableId: string | null` + +> **`dropChance` is typed `string`, not `number`.** TypeORM returns PostgreSQL `numeric` as a string (the existing `LocationConnection.ambushChance` does the same — see the `'0.0500'` values in the seed). `LootService` parses it with `Number(...)` exactly once. + +- [ ] **Step 1: Write the failing schema test** + +```ts +// apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootTable } from '../../loot/entities/loot-table.entity'; +import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; +import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; +import { CombatReward } from '../../rewards/entities/combat-reward.entity'; +import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity'; + +function uniqueIndexFor(target: unknown, columns: string[]) { + const index = getMetadataArgsStorage().indices.find( + (candidate) => + candidate.target === target && + columns.every((column) => candidate.columns?.includes(column)), + ); + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; + return indexMetadata?.options?.unique ?? indexMetadata?.unique; +} + +describe('loot and rewards schema', () => { + it('gives every combat at most one reward record', () => { + expect(uniqueIndexFor(CombatReward, ['combatId'])).toBe(true); + }); + + it('keeps one stack per character per item definition', () => { + expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true); + }); + + it('keeps loot-table content keys and entry positions unique', () => { + expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true); + expect(uniqueIndexFor(LootTable, ['key'])).toBe(true); + expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true); + expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true); + }); + + it('maps reward and loot relations with the documented onDelete behavior', () => { + const relations = getMetadataArgsStorage().relations.filter((relation) => + [CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes( + relation.target as never, + ), + ); + + expect( + relations.map((relation) => ({ + onDelete: relation.options.onDelete, + propertyName: relation.propertyName, + target: relation.target, + })), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }), + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }), + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }), + expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }), + expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }), + ]), + ); + }); + + it('adds the character silver column and the nullable monster loot table link', () => { + const columns = getMetadataArgsStorage().columns; + + const silver = columns.find( + (candidate) => candidate.target === Character && candidate.propertyName === 'silver', + ); + expect(silver).toBeDefined(); + expect(silver?.options.type).toBe('integer'); + + const lootTableId = columns.find( + (candidate) => + candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId', + ); + expect(lootTableId).toBeDefined(); + expect(lootTableId?.options.nullable).toBe(true); + }); + + it('stores drop chance as a numeric column so probabilities stay data-driven', () => { + const dropChance = getMetadataArgsStorage().columns.find( + (candidate) => + candidate.target === LootTableEntry && candidate.propertyName === 'dropChance', + ); + + expect(dropChance?.options.type).toBe('numeric'); + expect(dropChance?.options.precision).toBe(5); + expect(dropChance?.options.scale).toBe(4); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- loot-and-rewards` +Expected: FAIL — `Cannot find module '../../items/entities/item-definition.entity'`. + +- [ ] **Step 3: Create the three enums** + +```ts +// apps/api/src/items/item-type.enum.ts +export enum ItemType { + WEAPON = 'WEAPON', + ARMOR = 'ARMOR', + MATERIAL = 'MATERIAL', + CONSUMABLE = 'CONSUMABLE', +} +``` + +```ts +// apps/api/src/items/equipment-slot.enum.ts +// Slice 0.4 only stores the slot as content data. Slice 0.5 makes it functional. +export enum EquipmentSlot { + WEAPON = 'WEAPON', + HEAD = 'HEAD', + CHEST = 'CHEST', + HANDS = 'HANDS', + LEGS = 'LEGS', + FEET = 'FEET', + AMULET = 'AMULET', +} +``` + +```ts +// apps/api/src/items/item-rarity.enum.ts +// Mirrors docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19: +// Gewöhnlich / Selten / Episch. German labels live in the frontend. +export enum ItemRarity { + COMMON = 'COMMON', + RARE = 'RARE', + EPIC = 'EPIC', +} +``` + +- [ ] **Step 4: Create the item entities** + +```ts +// apps/api/src/items/entities/item-definition.entity.ts +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { EquipmentSlot } from '../equipment-slot.enum'; +import { ItemRarity } from '../item-rarity.enum'; +import { ItemType } from '../item-type.enum'; + +@Entity({ name: 'item_definitions' }) +@Index('IDX_item_definitions_key', ['key'], { unique: true }) +export class ItemDefinition { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'key', type: 'varchar', length: 100 }) + key!: string; + + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @Column({ name: 'description', type: 'text' }) + description!: string; + + @Column({ name: 'type', type: 'enum', enum: ItemType, enumName: 'item_type_enum' }) + type!: ItemType; + + @Column({ + name: 'equipment_slot', + type: 'enum', + enum: EquipmentSlot, + enumName: 'equipment_slot_enum', + nullable: true, + }) + equipmentSlot!: EquipmentSlot | null; + + @Column({ name: 'rarity', type: 'enum', enum: ItemRarity, enumName: 'item_rarity_enum' }) + rarity!: ItemRarity; + + @Column({ name: 'tier', type: 'integer' }) + tier!: number; + + @Column({ name: 'required_level', type: 'integer' }) + requiredLevel!: number; + + @Column({ name: 'weapon_damage', type: 'integer' }) + weaponDamage!: number; + + @Column({ name: 'bonus_hp', type: 'integer' }) + bonusHp!: number; + + @Column({ name: 'bonus_attack', type: 'integer' }) + bonusAttack!: number; + + @Column({ name: 'bonus_armor', type: 'integer' }) + bonusArmor!: number; + + // Always 0 in Slice 0.4: there are no merchants, and the balancing doc's + // Grenzmarken table lists purchase prices, not sell prices. + @Column({ name: 'sell_price', type: 'integer' }) + sellPrice!: number; + + @Column({ name: 'icon_path', type: 'varchar', length: 255 }) + iconPath!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} +``` + +```ts +// apps/api/src/items/entities/character-item.entity.ts +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { ItemDefinition } from './item-definition.entity'; + +/** + * One stack of one item definition owned by one character. + * + * Duplicate drops increment `quantity` (spec §28 allows duplicates and forbids + * duplicate protection). Slice 0.5 equips a `CharacterItem.id`, never an + * `ItemDefinition.id`. + */ +@Entity({ name: 'character_items' }) +@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], { + unique: true, +}) +export class CharacterItem { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'item_definition_id', type: 'uuid' }) + itemDefinitionId!: string; + + @Column({ name: 'quantity', type: 'integer' }) + quantity!: number; + + @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(() => ItemDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'item_definition_id' }) + itemDefinition!: ItemDefinition; +} +``` + +- [ ] **Step 5: Create the loot entities** + +```ts +// apps/api/src/loot/entities/loot-table.entity.ts +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'loot_tables' }) +@Index('IDX_loot_tables_key', ['key'], { unique: true }) +export class LootTable { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'key', type: 'varchar', length: 100 }) + key!: string; + + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} +``` + +```ts +// apps/api/src/loot/entities/loot-table-entry.entity.ts +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootTable } from './loot-table.entity'; + +/** + * One independently rolled drop (spec §17). + * + * `position` fixes the roll order so injected randoms are predictable in tests; + * it is content ordering, not priority. A guaranteed drop is simply + * `dropChance = 1.0000` — no extra mechanism needed (spec §14). + */ +@Entity({ name: 'loot_table_entries' }) +@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { unique: true }) +@Index('IDX_loot_table_entries_table_item', ['lootTableId', 'itemDefinitionId'], { unique: true }) +export class LootTableEntry { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'loot_table_id', type: 'uuid' }) + lootTableId!: string; + + @Column({ name: 'item_definition_id', type: 'uuid' }) + itemDefinitionId!: string; + + @Column({ name: 'position', type: 'integer' }) + position!: number; + + // PostgreSQL numeric arrives as a string, like LocationConnection.ambushChance. + @Column({ name: 'drop_chance', type: 'numeric', precision: 5, scale: 4 }) + dropChance!: string; + + @Column({ name: 'min_quantity', type: 'integer' }) + minQuantity!: number; + + @Column({ name: 'max_quantity', type: 'integer' }) + maxQuantity!: number; + + @Column({ name: 'enabled', type: 'boolean' }) + enabled!: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @ManyToOne(() => LootTable, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'loot_table_id' }) + lootTable!: LootTable; + + @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'item_definition_id' }) + itemDefinition!: ItemDefinition; +} +``` + +- [ ] **Step 6: Create the reward entities** + +```ts +// apps/api/src/rewards/entities/combat-reward.entity.ts +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { Combat } from '../../combat/entities/combat.entity'; + +/** + * Proof that one combat has already been rewarded (spec §8). + * + * The unique index on `combatId` is the database half of the idempotency + * invariant; `CombatRewardService` is the service half. + */ +@Entity({ name: 'combat_rewards' }) +@Index('IDX_combat_rewards_combat', ['combatId'], { unique: true }) +@Index('IDX_combat_rewards_character', ['characterId']) +export class CombatReward { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'combat_id', type: 'uuid' }) + combatId!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'experience_granted', type: 'integer' }) + experienceGranted!: number; + + @Column({ name: 'silver_granted', type: 'integer' }) + silverGranted!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @ManyToOne(() => Combat, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'combat_id' }) + combat!: Combat; + + @ManyToOne(() => Character, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; +} +``` + +```ts +// apps/api/src/rewards/entities/combat-reward-item.entity.ts +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { CharacterItem } from '../../items/entities/character-item.entity'; +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { CombatReward } from './combat-reward.entity'; + +/** + * What this specific combat dropped. + * + * Needed because `CharacterItem.quantity` is a running stack total: after a + * duplicate drop it no longer says how much *this* victory granted, and a + * refreshed reward screen must replay the original result (spec §25, §48). + */ +@Entity({ name: 'combat_reward_items' }) +@Index('IDX_combat_reward_items_reward', ['combatRewardId']) +@Index('IDX_combat_reward_items_reward_item', ['combatRewardId', 'itemDefinitionId'], { + unique: true, +}) +export class CombatRewardItem { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'combat_reward_id', type: 'uuid' }) + combatRewardId!: string; + + @Column({ name: 'character_item_id', type: 'uuid' }) + characterItemId!: string; + + @Column({ name: 'item_definition_id', type: 'uuid' }) + itemDefinitionId!: string; + + @Column({ name: 'quantity', type: 'integer' }) + quantity!: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @ManyToOne(() => CombatReward, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'combat_reward_id' }) + combatReward!: CombatReward; + + @ManyToOne(() => CharacterItem, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'character_item_id' }) + characterItem!: CharacterItem; + + @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'item_definition_id' }) + itemDefinition!: ItemDefinition; +} +``` + +- [ ] **Step 7: Add the two columns to existing entities** + +In `apps/api/src/characters/entities/character.entity.ts`, add after the `experience` column (line 24): + +```ts + @Column({ name: 'silver', type: 'integer' }) + silver!: number; +``` + +In `apps/api/src/monsters/entities/monster-definition.entity.ts`, add the import and the relation. Add to the import list from `'typeorm'`: `JoinColumn`, `ManyToOne`. Add at the top of the file: + +```ts +import { LootTable } from '../../loot/entities/loot-table.entity'; +``` + +and add after the `artworkPath` column: + +```ts + @Column({ name: 'loot_table_id', type: 'uuid', nullable: true }) + lootTableId!: string | null; + + @ManyToOne(() => LootTable, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'loot_table_id' }) + lootTable!: LootTable | null; +``` + +- [ ] **Step 8: Run the schema test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/api -- loot-and-rewards` +Expected: PASS — 6 tests. + +The whole API suite will **fail** at this point: every existing fixture that builds a `Character` now misses `silver`. Fix them by adding `silver: 0` to the character factories in `apps/api/src/combat/combat.service.spec.ts`, `apps/api/src/characters/characters.service.spec.ts`, `apps/api/src/hunting/hunting.service.spec.ts`, and `apps/api/src/travel/travel.service.spec.ts` — search first, and only touch the ones that actually construct a `Character`: + +Run: `git grep -ln "baseAttack:" -- apps/api/src` + +- [ ] **Step 9: Run the full suite and the build** + +Run: `npm test --workspace=@ashen-realms/api` +Expected: PASS — all suites green. + +Run: `npm run build:api` +Expected: build succeeds. + +- [ ] **Step 10: Commit** + +```bash +git add apps/api/src/items apps/api/src/loot apps/api/src/rewards apps/api/src/characters apps/api/src/monsters apps/api/src/database/migrations +git commit -m "feat(loot): add item, loot table, and combat reward entities" +``` + +--- + +## Task 3: The migration + +Raw SQL matching Task 2's entities. Preserves all existing rows: `characters.silver` and `monster_definitions.loot_table_id` are added, never recreated. + +**Files:** +- Create: `apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts` + +**Interfaces:** +- Consumes: the entities from Task 2. +- Produces: tables `item_definitions`, `loot_tables`, `loot_table_entries`, `character_items`, `combat_rewards`, `combat_reward_items`; columns `characters.silver`, `monster_definitions.loot_table_id`; enum types `item_type_enum`, `equipment_slot_enum`, `item_rarity_enum`. + +- [ ] **Step 1: Write the migration** + +```ts +// apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateLootAndRewards1788600000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Existing characters keep their progression; silver simply starts at 0. + await queryRunner.query( + 'ALTER TABLE "characters" ADD COLUMN "silver" integer NOT NULL DEFAULT 0', + ); + + await queryRunner.query( + "CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')", + ); + await queryRunner.query( + "CREATE TYPE \"equipment_slot_enum\" AS ENUM ('WEAPON', 'HEAD', 'CHEST', 'HANDS', 'LEGS', 'FEET', 'AMULET')", + ); + await queryRunner.query( + "CREATE TYPE \"item_rarity_enum\" AS ENUM ('COMMON', 'RARE', 'EPIC')", + ); + + await queryRunner.query(`CREATE TABLE "item_definitions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "description" text NOT NULL, + "type" "item_type_enum" NOT NULL, + "equipment_slot" "equipment_slot_enum", + "rarity" "item_rarity_enum" NOT NULL, + "tier" integer NOT NULL, + "required_level" integer NOT NULL, + "weapon_damage" integer NOT NULL DEFAULT 0, + "bonus_hp" integer NOT NULL DEFAULT 0, + "bonus_attack" integer NOT NULL DEFAULT 0, + "bonus_armor" integer NOT NULL DEFAULT 0, + "sell_price" integer NOT NULL DEFAULT 0, + "icon_path" character varying(255) NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_item_definitions" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_item_definitions_key" ON "item_definitions" ("key")', + ); + + await queryRunner.query(`CREATE TABLE "loot_tables" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_loot_tables" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_tables_key" ON "loot_tables" ("key")', + ); + + await queryRunner.query(`CREATE TABLE "loot_table_entries" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "loot_table_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "position" integer NOT NULL, + "drop_chance" numeric(5,4) NOT NULL, + "min_quantity" integer NOT NULL DEFAULT 1, + "max_quantity" integer NOT NULL DEFAULT 1, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_loot_table_entries" PRIMARY KEY ("id"), + CONSTRAINT "CHK_loot_table_entries_drop_chance" CHECK ("drop_chance" >= 0 AND "drop_chance" <= 1), + CONSTRAINT "CHK_loot_table_entries_quantity" CHECK ("min_quantity" >= 1 AND "max_quantity" >= "min_quantity"), + CONSTRAINT "FK_loot_table_entries_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_loot_table_entries_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_position" ON "loot_table_entries" ("loot_table_id", "position")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_item" ON "loot_table_entries" ("loot_table_id", "item_definition_id")', + ); + + await queryRunner.query( + 'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id" uuid', + ); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" ADD CONSTRAINT "FK_monster_definitions_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE RESTRICT ON UPDATE NO ACTION', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_monster_definitions_loot_table" ON "monster_definitions" ("loot_table_id")', + ); + + await queryRunner.query(`CREATE TABLE "character_items" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "character_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "quantity" integer 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_items" PRIMARY KEY ("id"), + CONSTRAINT "CHK_character_items_quantity" CHECK ("quantity" >= 1), + CONSTRAINT "FK_character_items_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_character_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_items_character_item" ON "character_items" ("character_id", "item_definition_id")', + ); + + await queryRunner.query(`CREATE TABLE "combat_rewards" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "combat_id" uuid NOT NULL, + "character_id" uuid NOT NULL, + "experience_granted" integer NOT NULL, + "silver_granted" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_combat_rewards" PRIMARY KEY ("id"), + CONSTRAINT "FK_combat_rewards_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_rewards_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + // The database half of the "one reward per combat" invariant (spec §7). + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_combat_rewards_combat" ON "combat_rewards" ("combat_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_combat_rewards_character" ON "combat_rewards" ("character_id")', + ); + + await queryRunner.query(`CREATE TABLE "combat_reward_items" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "combat_reward_id" uuid NOT NULL, + "character_item_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "quantity" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_combat_reward_items" PRIMARY KEY ("id"), + CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1), + CONSTRAINT "FK_combat_reward_items_reward" FOREIGN KEY ("combat_reward_id") REFERENCES "combat_rewards"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_reward_items_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_reward_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE INDEX "IDX_combat_reward_items_reward" ON "combat_reward_items" ("combat_reward_id")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item" ON "combat_reward_items" ("combat_reward_id", "item_definition_id")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward_item"'); + await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward"'); + await queryRunner.query('DROP TABLE "combat_reward_items"'); + await queryRunner.query('DROP INDEX "IDX_combat_rewards_character"'); + await queryRunner.query('DROP INDEX "IDX_combat_rewards_combat"'); + await queryRunner.query('DROP TABLE "combat_rewards"'); + await queryRunner.query('DROP INDEX "IDX_character_items_character_item"'); + await queryRunner.query('DROP TABLE "character_items"'); + await queryRunner.query('DROP INDEX "IDX_monster_definitions_loot_table"'); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" DROP CONSTRAINT "FK_monster_definitions_loot_table"', + ); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"', + ); + await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"'); + await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"'); + await queryRunner.query('DROP TABLE "loot_table_entries"'); + await queryRunner.query('DROP INDEX "IDX_loot_tables_key"'); + await queryRunner.query('DROP TABLE "loot_tables"'); + await queryRunner.query('DROP INDEX "IDX_item_definitions_key"'); + await queryRunner.query('DROP TABLE "item_definitions"'); + await queryRunner.query('DROP TYPE "item_rarity_enum"'); + await queryRunner.query('DROP TYPE "equipment_slot_enum"'); + await queryRunner.query('DROP TYPE "item_type_enum"'); + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "silver"'); + } +} +``` + +- [ ] **Step 2: Compile and run the migration** + +Run: `npm run build:api` +Expected: build succeeds. + +Run: `npm run db:migrate` +Expected: `CreateLootAndRewards1788600000000` reported as executed, no errors. + +- [ ] **Step 3: Verify the schema landed and existing data survived** + +Run (adjust credentials to your local `.env`): + +```bash +psql "$DATABASE_URL" -c "\d combat_rewards" -c "\d loot_table_entries" -c "SELECT id, name, experience, silver FROM characters" +``` + +Expected: both tables exist; the demo character still has its previous `experience`, and `silver` is `0`. + +- [ ] **Step 4: Verify the down migration is reversible, then re-apply** + +Run: `npm run db:revert` +Expected: migration reverts cleanly, no FK or type errors. + +Run: `npm run db:migrate` +Expected: applies again cleanly. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts +git commit -m "feat(loot): add loot and reward migration" +``` + +--- + +## Task 4: Extract the item icons + +`art/Items.png` (1448×1086) is a labelled 4/4/3 grid of the eleven Tier-1 items. Crop each cell's artwork — **excluding the caption text** — into a square 256×256 PNG. Aschenfell has no artwork of its own, so it takes a clearly-labelled stand-in cropped from the Aschenratte portrait. + +The grid coordinates below are measured and verified, not estimated: +- Rows 1–2 cells start at `x = 8, 366, 724, 1082` with `y = 8` and `y = 364`; the artwork is the 300×300 square at `x + 25`. +- Row 3 has three centred cells at `x = 191, 549, 907`, `y = 720`; the artwork is the 262×262 square at `x + 44`. + +**Files:** +- Create: `tools/extract-item-icons.ps1` +- Create: `apps/web/public/images/items/*.png` (12 files) + +**Interfaces:** +- Consumes: nothing. +- Produces: `/images/items/.png` for the 12 keys used by Task 5's seed: `worn-short-sword`, `bandit-blade`, `ash-blade`, `bandit-hood`, `reinforced-leather-jacket`, `raider-gloves`, `guardsman-legs`, `ash-boots`, `borderwatch-sigil`, `burned-captain-pendant`, `small-healing-potion`, `ash-pelt`. + +- [ ] **Step 1: Write the extraction script** + +```powershell +# tools/extract-item-icons.ps1 +# Crops the Tier-1 item icons out of art/Items.png into apps/web/public/images/items. +# Re-runnable: overwrites its own output and touches nothing else. +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +$root = Split-Path -Parent $PSScriptRoot +$sheet = Join-Path $root 'art\Items.png' +$ratPortrait = Join-Path $root 'art\enemies\AschenratteIcon.png' +$outDir = Join-Path $root 'apps\web\public\images\items' +New-Item -ItemType Directory -Force -Path $outDir | Out-Null + +function Export-Icon { + param( + [System.Drawing.Image] $Source, + [int] $X, [int] $Y, [int] $Size, + [string] $Key + ) + $bitmap = New-Object System.Drawing.Bitmap 256, 256 + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage( + $Source, + (New-Object System.Drawing.Rectangle 0, 0, 256, 256), + (New-Object System.Drawing.Rectangle $X, $Y, $Size, $Size), + [System.Drawing.GraphicsUnit]::Pixel) + $graphics.Dispose() + $bitmap.Save((Join-Path $outDir "$Key.png"), [System.Drawing.Imaging.ImageFormat]::Png) + $bitmap.Dispose() + Write-Host "wrote $Key.png" +} + +$items = [System.Drawing.Image]::FromFile($sheet) +try { + # Rows 1-2: cells are 350 wide with a caption strip at the bottom, so the + # artwork is the 300x300 square inset by 25px horizontally. + $columns = 8, 366, 724, 1082 + $rowOne = 'worn-short-sword', 'bandit-blade', 'ash-blade', 'bandit-hood' + $rowTwo = 'reinforced-leather-jacket', 'raider-gloves', 'guardsman-legs', 'ash-boots' + + for ($i = 0; $i -lt 4; $i++) { + Export-Icon -Source $items -X ($columns[$i] + 25) -Y 8 -Size 300 -Key $rowOne[$i] + Export-Icon -Source $items -X ($columns[$i] + 25) -Y 364 -Size 300 -Key $rowTwo[$i] + } + + # Row 3 is centred and shorter: three cells, 262x262 artwork inset by 44px. + $rowThreeColumns = 191, 549, 907 + $rowThree = 'borderwatch-sigil', 'burned-captain-pendant', 'small-healing-potion' + for ($i = 0; $i -lt 3; $i++) { + Export-Icon -Source $items -X ($rowThreeColumns[$i] + 44) -Y 720 -Size 262 -Key $rowThree[$i] + } +} +finally { + $items.Dispose() +} + +# STAND-IN ART: no Aschenfell artwork exists yet, so the pelt icon is a scorched +# hide patch cropped from the Aschenratte portrait. Replace when real art lands. +$rat = [System.Drawing.Image]::FromFile($ratPortrait) +try { + Export-Icon -Source $rat -X 760 -Y 300 -Size 320 -Key 'ash-pelt' +} +finally { + $rat.Dispose() +} +``` + +- [ ] **Step 2: Run the script** + +Run: `powershell -ExecutionPolicy Bypass -File tools/extract-item-icons.ps1` +Expected: twelve `wrote .png` lines. + +- [ ] **Step 3: Verify the crops visually** + +Open `apps/web/public/images/items/` and check all twelve 256×256 PNGs: +- the item fills the frame and is not clipped at any edge +- **no caption text is visible** in any icon +- `ash-pelt.png` reads as a dark fur/hide texture + +If a crop is off, adjust only that cell's `X`/`Y`/`Size` and re-run — the script is idempotent. + +- [ ] **Step 4: Commit** + +```bash +git add tools/extract-item-icons.ps1 apps/web/public/images/items +git commit -m "feat(items): extract tier-1 item icons from the art sheet" +``` + +--- + +## Task 5: Seed item definitions, loot tables, and loot entries + +Extend the existing idempotent seed. Stats come from `docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md` §19; drop chances from §27–28 and spec §15–16. + +**Files:** +- Create: `apps/api/src/database/seeds/item.constants.ts` +- Create: `apps/api/src/database/seeds/item-content.ts` +- Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts` +- Test: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts` + +**Interfaces:** +- Consumes: `ItemDefinition`, `CharacterItem`, `LootTable`, `LootTableEntry`, `ItemType`, `EquipmentSlot`, `ItemRarity` from Task 2. +- Produces: `ITEM_IDS` (record of key → uuid), `ASH_RAT_LOOT_TABLE_ID`, `ROAD_BANDIT_LOOT_TABLE_ID` from `item.constants.ts`; `ITEM_DEFINITIONS`, `LOOT_TABLES`, `LOOT_TABLE_ENTRIES` from `item-content.ts`; `seedVisibleVerticalSlice` now also seeds all of it and sets `monster_definitions.loot_table_id`. + +- [ ] **Step 1: Write the stable id constants** + +```ts +// apps/api/src/database/seeds/item.constants.ts +// Stable content ids, in art-sheet order. Aschenfell (no sheet entry) is last. +export const ITEM_IDS = { + 'worn-short-sword': '50000000-0000-4000-8000-000000000001', + 'bandit-blade': '50000000-0000-4000-8000-000000000002', + 'ash-blade': '50000000-0000-4000-8000-000000000003', + 'bandit-hood': '50000000-0000-4000-8000-000000000004', + 'reinforced-leather-jacket': '50000000-0000-4000-8000-000000000005', + 'raider-gloves': '50000000-0000-4000-8000-000000000006', + 'guardsman-legs': '50000000-0000-4000-8000-000000000007', + 'ash-boots': '50000000-0000-4000-8000-000000000008', + 'borderwatch-sigil': '50000000-0000-4000-8000-000000000009', + 'burned-captain-pendant': '50000000-0000-4000-8000-00000000000a', + 'small-healing-potion': '50000000-0000-4000-8000-00000000000b', + 'ash-pelt': '50000000-0000-4000-8000-00000000000c', +} as const; + +export type ItemKey = keyof typeof ITEM_IDS; + +export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001'; +export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002'; +``` + +- [ ] **Step 2: Write the content tables** + +```ts +// apps/api/src/database/seeds/item-content.ts +import { EquipmentSlot } from '../../items/equipment-slot.enum'; +import { ItemRarity } from '../../items/item-rarity.enum'; +import { ItemType } from '../../items/item-type.enum'; +import { + ASH_RAT_LOOT_TABLE_ID, + ITEM_IDS, + ItemKey, + ROAD_BANDIT_LOOT_TABLE_ID, +} from './item.constants'; + +export interface SeedItemDefinition { + id: string; + key: ItemKey; + name: string; + description: string; + type: ItemType; + equipmentSlot: EquipmentSlot | null; + rarity: ItemRarity; + tier: number; + requiredLevel: number; + weaponDamage: number; + bonusHp: number; + bonusAttack: number; + bonusArmor: number; + sellPrice: number; + iconPath: string; +} + +function item( + key: ItemKey, + name: string, + description: string, + type: ItemType, + equipmentSlot: EquipmentSlot | null, + rarity: ItemRarity, + stats: Partial> = {}, +): SeedItemDefinition { + return { + id: ITEM_IDS[key], + key, + name, + description, + type, + equipmentSlot, + rarity, + tier: 1, + requiredLevel: 1, + weaponDamage: stats.weaponDamage ?? 0, + bonusHp: stats.bonusHp ?? 0, + bonusAttack: stats.bonusAttack ?? 0, + bonusArmor: stats.bonusArmor ?? 0, + // Always 0: no merchants exist in Slice 0.4, and the balancing doc's + // Grenzmarken table lists purchase prices, not sell prices. + sellPrice: 0, + iconPath: `/images/items/${key}.png`, + }; +} + +// Stats from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19. +export const ITEM_DEFINITIONS: SeedItemDefinition[] = [ + item( + 'worn-short-sword', + 'Abgenutztes Kurzschwert', + 'Die Klinge eines Rekruten, öfter geschliffen als geführt.', + ItemType.WEAPON, + EquipmentSlot.WEAPON, + ItemRarity.COMMON, + { weaponDamage: 8 }, + ), + item( + 'bandit-blade', + 'Räuberklinge', + 'Eine grob gezahnte Klinge, geschmiedet für schnelle Überfälle.', + ItemType.WEAPON, + EquipmentSlot.WEAPON, + ItemRarity.COMMON, + { weaponDamage: 11, bonusAttack: 1 }, + ), + item( + 'ash-blade', + 'Aschenklinge', + 'In der Glut der Aschenfelder gehärtet; die Schneide glimmt noch.', + ItemType.WEAPON, + EquipmentSlot.WEAPON, + ItemRarity.RARE, + { weaponDamage: 15, bonusAttack: 2 }, + ), + item( + 'bandit-hood', + 'Räuberhaube', + 'Vernarbtes Leder, das Gesicht und Absicht des Trägers verbirgt.', + ItemType.ARMOR, + EquipmentSlot.HEAD, + ItemRarity.COMMON, + { bonusArmor: 3, bonusHp: 5 }, + ), + item( + 'reinforced-leather-jacket', + 'Verstärkte Lederjacke', + 'Mit Eisenplatten benähtes Leder, schwer und verlässlich.', + ItemType.ARMOR, + EquipmentSlot.CHEST, + ItemRarity.RARE, + { bonusArmor: 7, bonusHp: 10 }, + ), + item( + 'raider-gloves', + 'Plündererhandschuhe', + 'Beschlagene Handschuhe, abgegriffen von fremdem Gut.', + ItemType.ARMOR, + EquipmentSlot.HANDS, + ItemRarity.COMMON, + { bonusArmor: 3, bonusAttack: 1 }, + ), + item( + 'guardsman-legs', + 'Wachmannsbeinkleid', + 'Beinzeug der Grenzwacht, an den Knien geflickt.', + ItemType.ARMOR, + EquipmentSlot.LEGS, + ItemRarity.RARE, + { bonusArmor: 5, bonusHp: 5 }, + ), + item( + 'ash-boots', + 'Aschenstiefel', + 'Stiefel, die durch glimmende Felder getragen wurden und blieben.', + ItemType.ARMOR, + EquipmentSlot.FEET, + ItemRarity.RARE, + { bonusArmor: 4, bonusHp: 5 }, + ), + item( + 'borderwatch-sigil', + 'Zeichen der Grenzwacht', + 'Das Wappen eines Turms, den es nicht mehr gibt.', + ItemType.ARMOR, + EquipmentSlot.AMULET, + ItemRarity.RARE, + { bonusAttack: 3, bonusHp: 10 }, + ), + item( + 'burned-captain-pendant', + 'Anhänger des verbrannten Hauptmanns', + 'Ein Schädel aus Schlacke, in dem die Glut nie erlosch.', + ItemType.ARMOR, + EquipmentSlot.AMULET, + ItemRarity.EPIC, + { bonusAttack: 3, bonusHp: 15, bonusArmor: 2 }, + ), + // Seeded as content only. Slice 0.4 implements no consumable use, and the + // Straßenräuber loot entry for it is deliberately deferred (spec §16). + item( + 'small-healing-potion', + 'Kleiner Heiltrank', + 'Ein bitterer Sud, der Wunden für einen Atemzug vergessen lässt.', + ItemType.CONSUMABLE, + null, + ItemRarity.COMMON, + ), + item( + 'ash-pelt', + 'Aschenfell', + 'Versengtes Fell, zäh wie Leder und grau von Ascheflug.', + ItemType.MATERIAL, + null, + ItemRarity.COMMON, + ), +]; + +export const LOOT_TABLES = [ + { id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Aschenratte Beute' }, + { id: ROAD_BANDIT_LOOT_TABLE_ID, key: 'road-bandit-loot', name: 'Straßenräuber Beute' }, +]; + +export interface SeedLootTableEntry { + lootTableId: string; + itemDefinitionId: string; + position: number; + dropChance: string; + minQuantity: number; + maxQuantity: number; + enabled: boolean; +} + +function entry( + lootTableId: string, + key: ItemKey, + position: number, + dropChance: string, +): SeedLootTableEntry { + return { + lootTableId, + itemDefinitionId: ITEM_IDS[key], + position, + dropChance, + minQuantity: 1, + maxQuantity: 1, + enabled: true, + }; +} + +/** + * Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28. + * Every entry is an independent roll (spec §17), rolled in `position` order. + * + * DEFERRED: the Straßenräuber table also lists 10 % Kleiner Heiltrank. It is + * omitted here because Slice 0.4 implements no consumables (spec §16). + */ +export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [ + entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'), + entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'), + entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'), + entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'), + entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'), +]; +``` + +- [ ] **Step 3: Write the failing seed test** + +Append to `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`. Add these imports at the top: + +```ts +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootTable } from '../../loot/entities/loot-table.entity'; +import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; +import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants'; +``` + +Change `createDataSource` to take three more repositories and route them: + +```ts +function createDataSource( + locationRepository: InMemoryRepository, + connectionRepository: InMemoryRepository, + characterRepository: InMemoryRepository, + monsterRepository: InMemoryRepository, + locationMonsterRepository: InMemoryRepository, + itemRepository: InMemoryRepository = new InMemoryRepository(), + lootTableRepository: InMemoryRepository = new InMemoryRepository(), + lootEntryRepository: InMemoryRepository = new InMemoryRepository(), +): 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; + + throw new Error('Unexpected repository'); + }), + } as unknown as DataSource; +} +``` + +Then add this test: + +```ts + it('seeds the tier-1 items and both loot tables idempotently and wires them to the monsters', async () => { + const locationRepository = new InMemoryRepository(); + const connectionRepository = new InMemoryRepository(); + const characterRepository = new InMemoryRepository(); + const monsterRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const itemRepository = new InMemoryRepository(); + const lootTableRepository = new InMemoryRepository(); + const lootEntryRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + connectionRepository, + characterRepository, + monsterRepository, + locationMonsterRepository, + itemRepository, + lootTableRepository, + lootEntryRepository, + ); + + await seedVisibleVerticalSlice(dataSource); + await seedVisibleVerticalSlice(dataSource); + + expect(itemRepository.rows).toHaveLength(12); + expect(itemRepository.rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'bandit-blade', + name: 'Räuberklinge', + type: 'WEAPON', + equipmentSlot: 'WEAPON', + rarity: 'COMMON', + weaponDamage: 11, + bonusAttack: 1, + sellPrice: 0, + iconPath: '/images/items/bandit-blade.png', + }), + expect.objectContaining({ + key: 'ash-pelt', + name: 'Aschenfell', + type: 'MATERIAL', + equipmentSlot: null, + }), + ]), + ); + + expect(lootTableRepository.rows).toHaveLength(2); + expect(lootEntryRepository.rows).toHaveLength(5); + expect(lootEntryRepository.rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + lootTableId: ASH_RAT_LOOT_TABLE_ID, + itemDefinitionId: ITEM_IDS['ash-pelt'], + position: 1, + dropChance: '0.6000', + }), + expect.objectContaining({ + lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, + itemDefinitionId: ITEM_IDS['bandit-blade'], + position: 1, + dropChance: '0.1800', + }), + ]), + ); + + // The Kleiner Heiltrank entry is deliberately deferred (spec §16). + expect( + lootEntryRepository.rows.some( + (row) => row.itemDefinitionId === ITEM_IDS['small-healing-potion'], + ), + ).toBe(false); + + expect(monsterRepository.rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'ash-rat', lootTableId: ASH_RAT_LOOT_TABLE_ID }), + expect.objectContaining({ key: 'road-bandit', lootTableId: ROAD_BANDIT_LOOT_TABLE_ID }), + ]), + ); + }); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: FAIL — `itemRepository.rows` has length 0. + +- [ ] **Step 5: Extend the seed** + +In `apps/api/src/database/seeds/vertical-slice.seed.ts`, add the imports: + +```ts +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootTable } from '../../loot/entities/loot-table.entity'; +import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; +import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content'; +import { ASH_RAT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants'; +``` + +Add the repositories next to the existing ones at the top of `seedVisibleVerticalSlice`: + +```ts + const itemRepository = dataSource.getRepository(ItemDefinition); + const lootTableRepository = dataSource.getRepository(LootTable); + const lootEntryRepository = dataSource.getRepository(LootTableEntry); +``` + +Insert this block **before** the `const monsters = [...]` block, because monsters now reference loot tables: + +```ts + // Content is upserted by its stable key so re-running never duplicates rows + // and never touches player-owned character_items or combat_rewards. + await itemRepository.upsert(ITEM_DEFINITIONS, ['key']); + await lootTableRepository.upsert(LOOT_TABLES, ['key']); + await lootEntryRepository.upsert(LOOT_TABLE_ENTRIES, [ + 'lootTableId', + 'itemDefinitionId', + ]); +``` + +Then add `lootTableId` to each monster in the `monsters` array: + +```ts + { + id: ASH_RAT_MONSTER_ID, + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + maxHp: 45, + attack: 5, + armor: 0, + experienceReward: 8, + silverMin: 4, + silverMax: 7, + artworkPath: '/images/monsters/ash-rat.png', + lootTableId: ASH_RAT_LOOT_TABLE_ID, + }, + { + id: ROAD_BANDIT_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', + lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, + }, +``` + +Finally, add `silver: 0` to the demo-character `insert` call so a fresh database starts explicit: + +```ts + await characterRepository.insert({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + baseHp: 100, + baseAttack: 6, + currentHp: 100, + currentLocationId: southGateId, + }); +``` + +> The character block stays an insert-only guard (`if (!existing)`), so re-seeding never resets XP or silver (spec §38). + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: PASS — the existing two tests plus the new one. + +- [ ] **Step 7: Run the real seed twice** + +Run: `npm run db:seed && npm run db:seed` +Expected: both runs succeed. + +```bash +psql "$DATABASE_URL" -c "SELECT count(*) FROM item_definitions" -c "SELECT count(*) FROM loot_table_entries" -c "SELECT key, loot_table_id FROM monster_definitions" +``` + +Expected: 12 items, 5 loot entries after **both** runs; both monsters have a `loot_table_id`. + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/database/seeds +git commit -m "feat(loot): seed tier-1 items and the ash rat and road bandit loot tables" +``` + +--- + +## Task 6: LootService + +Rolls configured loot. Persists nothing — that separation is what makes the rolls unit-testable (spec §20). + +**Files:** +- Create: `apps/api/src/loot/loot.service.ts` +- Create: `apps/api/src/loot/loot.module.ts` +- Test: `apps/api/src/loot/loot.service.spec.ts` + +**Interfaces:** +- Consumes: `RANDOM_SOURCE`, `RandomSource`, `rollInclusive` (Task 1); `LootTableEntry` (Task 2). +- Produces: + - `interface LootRollItem { itemDefinitionId: string; quantity: number }` + - `interface LootRollResult { items: LootRollItem[] }` + - `LootService.rollLoot(lootTableId: string | null, manager?: EntityManager): Promise` + - `LootModule` — exports `LootService` + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/api/src/loot/loot.service.spec.ts +import { DataSource } from 'typeorm'; +import type { RandomSource } from '../shared/random-source'; +import { LootTableEntry } from './entities/loot-table-entry.entity'; +import { LootService } from './loot.service'; + +const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001'; +const ASH_PELT = '50000000-0000-4000-8000-00000000000c'; +const WORN_SHORT_SWORD = '50000000-0000-4000-8000-000000000001'; + +function entry(overrides: Partial): LootTableEntry { + return { + id: 'entry-1', + lootTableId: ASH_RAT_TABLE, + itemDefinitionId: ASH_PELT, + position: 1, + dropChance: '0.6000', + minQuantity: 1, + maxQuantity: 1, + enabled: true, + createdAt: new Date('2026-08-19T09:00:00.000Z'), + updatedAt: new Date('2026-08-19T09:00:00.000Z'), + } as LootTableEntry; +} + +// Hands out the queued values in order, so a test states exactly which roll +// each value answers. +function queuedRandom(...values: number[]): RandomSource { + let index = 0; + return { + next: () => { + if (index >= values.length) { + throw new Error('LootService consumed more random values than the test queued'); + } + return values[index++]; + }, + }; +} + +function dataSourceWith(entries: LootTableEntry[]): DataSource { + return { + getRepository: jest.fn(() => ({ + find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) => + entries + .filter( + (candidate) => + candidate.lootTableId === options.where.lootTableId && + candidate.enabled === options.where.enabled, + ) + .sort((a, b) => a.position - b.position), + ), + })), + } as unknown as DataSource; +} + +describe('LootService', () => { + const ashRatEntries = [ + entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.6000' }), + entry({ + id: 'entry-sword', + itemDefinitionId: WORN_SHORT_SWORD, + position: 2, + dropChance: '0.0800', + }), + ]; + + it('drops an entry when the roll falls under its chance', async () => { + const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.59, 0.07)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [ + { itemDefinitionId: ASH_PELT, quantity: 1 }, + { itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }, + ], + }); + }); + + it('skips an entry when the roll lands on or above its chance', async () => { + const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.6, 0.08)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] }); + }); + + it('rolls each entry independently, so one combat can drop only the second item', async () => { + const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.9, 0.01)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }], + }); + }); + + it('rolls entries in position order so injected values stay predictable', async () => { + const outOfOrder = [ + entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, position: 2, dropChance: '1.0000' }), + entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.0000' }), + ]; + const service = new LootService(dataSourceWith(outOfOrder), queuedRandom(0.5, 0.5)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }], + }); + }); + + it('ignores disabled entries', async () => { + const service = new LootService( + dataSourceWith([entry({ dropChance: '1.0000', enabled: false })]), + queuedRandom(), + ); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] }); + }); + + it('consumes no quantity roll when min and max match, and one when they differ', async () => { + const stackable = [entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 })]; + // First value drops the entry, second picks the quantity (0.5 -> 3). + const service = new LootService(dataSourceWith(stackable), queuedRandom(0.1, 0.5)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }], + }); + }); + + it('returns nothing for a monster without a loot table', async () => { + const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom()); + + await expect(service.rollLoot(null)).resolves.toEqual({ items: [] }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- loot.service` +Expected: FAIL — `Cannot find module './loot.service'`. + +- [ ] **Step 3: Implement LootService** + +```ts +// apps/api/src/loot/loot.service.ts +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { RANDOM_SOURCE } from '../shared/random-source'; +import type { RandomSource } from '../shared/random-source'; +import { rollInclusive } from '../shared/roll-range'; +import { LootTableEntry } from './entities/loot-table-entry.entity'; + +export interface LootRollItem { + itemDefinitionId: string; + quantity: number; +} + +export interface LootRollResult { + items: LootRollItem[]; +} + +@Injectable() +export class LootService { + constructor( + private readonly dataSource: DataSource, + @Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource, + ) {} + + /** + * Rolls a loot table without persisting anything (spec §20). + * + * Every enabled entry is one independent roll in `position` order, so a + * single combat may yield nothing, one item, or several (spec §17). The + * quantity roll is skipped entirely when `minQuantity === maxQuantity`, + * which keeps the random sequence stable for the seeded content. + */ + async rollLoot( + lootTableId: string | null, + manager?: EntityManager, + ): Promise { + if (!lootTableId) { + return { items: [] }; + } + + const entries = await ( + manager?.getRepository(LootTableEntry) ?? + this.dataSource.getRepository(LootTableEntry) + ).find({ + where: { lootTableId, enabled: true }, + order: { position: 'ASC' }, + }); + + const items: LootRollItem[] = []; + for (const entry of entries) { + if (this.randomSource.next() >= Number(entry.dropChance)) { + continue; + } + + items.push({ + itemDefinitionId: entry.itemDefinitionId, + quantity: rollInclusive( + this.randomSource, + entry.minQuantity, + entry.maxQuantity, + ), + }); + } + + return { items }; + } +} +``` + +```ts +// apps/api/src/loot/loot.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; +import { LootTable } from './entities/loot-table.entity'; +import { LootTableEntry } from './entities/loot-table-entry.entity'; +import { LootService } from './loot.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([LootTable, LootTableEntry])], + providers: [LootService, { provide: RANDOM_SOURCE, useValue: systemRandomSource }], + exports: [LootService], +}) +export class LootModule {} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/api -- loot.service` +Expected: PASS — 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/loot +git commit -m "feat(loot): add LootService with deterministic independent rolls" +``` + +--- + +## Task 7: CombatRewardService + +The single orchestration boundary for rewards (spec §21). It only ever runs inside a caller-supplied transaction. + +**Files:** +- Create: `apps/api/src/rewards/rewards.errors.ts` +- Create: `apps/api/src/rewards/combat-reward.service.ts` +- Create: `apps/api/src/rewards/rewards.module.ts` +- Test: `apps/api/src/rewards/combat-reward.service.spec.ts` + +**Interfaces:** +- Consumes: `LootService.rollLoot` (Task 6); `rollInclusive`, `RANDOM_SOURCE` (Task 1); `CombatReward`, `CombatRewardItem`, `CharacterItem`, `ItemDefinition` (Task 2); `Combat`, `CombatStatus` (Slice 0.3). +- Produces: + - `interface CombatRewardItemDto { characterItemId: string; item: { key: string; name: string; rarity: ItemRarity; iconPath: string }; quantity: number }` + - `interface CombatRewardDto { experience: number; silver: number; items: CombatRewardItemDto[] }` + - `CombatRewardService.grantVictoryRewards(manager: EntityManager, combat: Combat): Promise` + - `CombatRewardService.loadRewards(combatId: string, manager?: EntityManager): Promise` + - `combatNotWon()`, `rewardStateInvalid()` from `rewards.errors.ts` + - `RewardsModule` — exports `CombatRewardService` + +> **The DTO never exposes drop chance, roll results, or loot-table ids** (spec §26). + +- [ ] **Step 1: Write the errors** + +```ts +// apps/api/src/rewards/rewards.errors.ts +import { HttpException, HttpStatus } from '@nestjs/common'; + +export type RewardErrorCode = 'COMBAT_NOT_WON' | 'REWARD_STATE_INVALID'; + +export class RewardDomainError extends HttpException { + constructor( + public readonly code: RewardErrorCode, + status: HttpStatus, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +export function combatNotWon(): RewardDomainError { + return new RewardDomainError( + 'COMBAT_NOT_WON', + HttpStatus.CONFLICT, + 'Only a won combat can grant victory rewards.', + ); +} + +export function rewardStateInvalid(): RewardDomainError { + return new RewardDomainError( + 'REWARD_STATE_INVALID', + HttpStatus.INTERNAL_SERVER_ERROR, + 'The reward references unavailable data.', + ); +} +``` + +- [ ] **Step 2: Write the failing test** + +```ts +// apps/api/src/rewards/combat-reward.service.spec.ts +import { EntityManager, EntityTarget } from 'typeorm'; +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 { ItemRarity } from '../items/item-rarity.enum'; +import { ItemType } from '../items/item-type.enum'; +import { LootService } from '../loot/loot.service'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import type { RandomSource } from '../shared/random-source'; +import { CombatRewardService } from './combat-reward.service'; +import { CombatReward } from './entities/combat-reward.entity'; +import { CombatRewardItem } from './entities/combat-reward-item.entity'; +import { RewardDomainError } from './rewards.errors'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const COMBAT_ID = '20000000-0000-4000-8000-000000000001'; +const ASH_RAT_ID = '30000000-0000-4000-8000-000000000001'; +const ROAD_BANDIT_ID = '30000000-0000-4000-8000-000000000002'; +const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001'; +const ROAD_BANDIT_TABLE = '60000000-0000-4000-8000-000000000002'; +const BANDIT_BLADE = '50000000-0000-4000-8000-000000000002'; + +interface State { + characters: Character[]; + monsters: MonsterDefinition[]; + itemDefinitions: ItemDefinition[]; + characterItems: CharacterItem[]; + combatRewards: CombatReward[]; + combatRewardItems: CombatRewardItem[]; +} + +class FakeRepository { + constructor( + private readonly rows: T[], + private readonly prefix: string, + ) {} + + findOne(options: { where: Partial }): Promise { + return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); + } + + findOneBy(where: Partial): Promise { + return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null); + } + + find(options: { where: Partial }): Promise { + return Promise.resolve(this.rows.filter((row) => this.matches(row, options.where))); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + if (!entity.id) { + entity.id = `${this.prefix}-${this.rows.length + 1}`; + } + const index = this.rows.findIndex((row) => row.id === entity.id); + if (index === -1) { + this.rows.push(entity); + } else { + this.rows[index] = entity; + } + return Promise.resolve(entity); + } + + private matches(row: T, where: Partial): boolean { + return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + } +} + +function fakeManager(state: State): EntityManager { + return { + getRepository: (target: EntityTarget) => { + if (target === Character) return new FakeRepository(state.characters, 'character') as never; + if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never; + if (target === ItemDefinition) + return new FakeRepository(state.itemDefinitions, 'definition') as never; + if (target === CharacterItem) + return new FakeRepository(state.characterItems, 'character-item') as never; + if (target === CombatReward) + return new FakeRepository(state.combatRewards, 'reward') as never; + if (target === CombatRewardItem) + return new FakeRepository(state.combatRewardItems, 'reward-item') as never; + throw new Error('Unsupported repository'); + }, + } as unknown as EntityManager; +} + +function combat(overrides: Partial = {}): Combat { + return { + id: COMBAT_ID, + characterId: CHARACTER_ID, + monsterDefinitionId: ASH_RAT_ID, + status: CombatStatus.WON, + round: 4, + ...overrides, + } as Combat; +} + +function createState(overrides: Partial = {}): State { + return { + characters: [{ id: CHARACTER_ID, experience: 12, silver: 3 } as Character], + monsters: [ + { + id: ASH_RAT_ID, + key: 'ash-rat', + experienceReward: 8, + silverMin: 4, + silverMax: 7, + lootTableId: ASH_RAT_TABLE, + } as MonsterDefinition, + { + id: ROAD_BANDIT_ID, + key: 'road-bandit', + experienceReward: 16, + silverMin: 9, + silverMax: 15, + lootTableId: ROAD_BANDIT_TABLE, + } as MonsterDefinition, + ], + itemDefinitions: [ + { + id: BANDIT_BLADE, + key: 'bandit-blade', + name: 'Räuberklinge', + type: ItemType.WEAPON, + rarity: ItemRarity.COMMON, + iconPath: '/images/items/bandit-blade.png', + } as ItemDefinition, + ], + characterItems: [], + combatRewards: [], + combatRewardItems: [], + ...overrides, + }; +} + +function fakeLoot(...items: Array<{ itemDefinitionId: string; quantity: number }>): LootService { + return { rollLoot: jest.fn().mockResolvedValue({ items }) } as unknown as LootService; +} + +function fixedRandom(value: number): RandomSource { + return { next: () => value }; +} + +function service( + state: State, + loot: LootService = fakeLoot(), + random: RandomSource = fixedRandom(0.5), +): CombatRewardService { + return new CombatRewardService({} as never, loot, random); +} + +describe('CombatRewardService', () => { + describe('eligibility', () => { + it('rejects an ACTIVE combat', async () => { + const state = createState(); + + await expect( + service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.ACTIVE })), + ).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' }); + expect(state.combatRewards).toHaveLength(0); + expect(state.characters[0].experience).toBe(12); + expect(state.characters[0].silver).toBe(3); + }); + + it('rejects a LOST combat', async () => { + const state = createState(); + + await expect( + service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.LOST })), + ).rejects.toBeInstanceOf(RewardDomainError); + expect(state.combatRewards).toHaveLength(0); + }); + + it('grants rewards for a WON combat', async () => { + const state = createState(); + + const reward = await service(state).grantVictoryRewards(fakeManager(state), combat()); + + expect(reward).toEqual({ experience: 8, silver: 6, items: [] }); + expect(state.combatRewards).toHaveLength(1); + }); + }); + + describe('Aschenratte', () => { + it('grants 8 XP and a silver roll inside 4-7, persisted on the character', async () => { + const state = createState(); + + const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards( + fakeManager(state), + combat(), + ); + + expect(reward.experience).toBe(8); + expect(reward.silver).toBe(4); + expect(state.characters[0].experience).toBe(20); + expect(state.characters[0].silver).toBe(7); + }); + + it('rolls the top of the silver range from the top of the random range', async () => { + const state = createState(); + + const reward = await service(state, fakeLoot(), fixedRandom(0.99)).grantVictoryRewards( + fakeManager(state), + combat(), + ); + + expect(reward.silver).toBe(7); + }); + }); + + describe('Straßenräuber', () => { + const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID }); + + it('grants 16 XP and a silver roll inside 9-15', async () => { + const state = createState(); + + const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards( + fakeManager(state), + banditCombat, + ); + + expect(reward.experience).toBe(16); + expect(reward.silver).toBe(9); + }); + + it('persists a dropped Räuberklinge as a CharacterItem and references it in the reward', async () => { + const state = createState(); + + const reward = await service( + state, + fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }), + ).grantVictoryRewards(fakeManager(state), banditCombat); + + expect(state.characterItems).toEqual([ + expect.objectContaining({ + characterId: CHARACTER_ID, + itemDefinitionId: BANDIT_BLADE, + quantity: 1, + }), + ]); + expect(reward.items).toEqual([ + { + characterItemId: state.characterItems[0].id, + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: ItemRarity.COMMON, + iconPath: '/images/items/bandit-blade.png', + }, + quantity: 1, + }, + ]); + expect(state.combatRewardItems).toHaveLength(1); + }); + + it('reports no items when the Räuberklinge does not drop', async () => { + const state = createState(); + + const reward = await service(state, fakeLoot()).grantVictoryRewards( + fakeManager(state), + banditCombat, + ); + + expect(reward.items).toEqual([]); + expect(state.characterItems).toHaveLength(0); + expect(state.combatRewardItems).toHaveLength(0); + }); + + it('stacks a duplicate drop onto the existing CharacterItem without duplicate protection', async () => { + const state = createState({ + characterItems: [ + { + id: 'character-item-existing', + characterId: CHARACTER_ID, + itemDefinitionId: BANDIT_BLADE, + quantity: 1, + } as CharacterItem, + ], + }); + + const reward = await service( + state, + fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }), + ).grantVictoryRewards(fakeManager(state), banditCombat); + + expect(state.characterItems).toHaveLength(1); + expect(state.characterItems[0].quantity).toBe(2); + // The reward reports what THIS combat granted, not the stack total. + expect(reward.items[0].quantity).toBe(1); + }); + }); + + describe('idempotency', () => { + it('grants once and returns the same persisted reward on a repeat call', async () => { + const state = createState(); + const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); + const subject = service(state, loot, fixedRandom(0)); + const manager = fakeManager(state); + + const first = await subject.grantVictoryRewards(manager, combat()); + const second = await subject.grantVictoryRewards(manager, combat()); + + expect(second).toEqual(first); + expect(state.combatRewards).toHaveLength(1); + expect(state.combatRewardItems).toHaveLength(1); + expect(state.characterItems).toHaveLength(1); + expect(state.characterItems[0].quantity).toBe(1); + expect(state.characters[0].experience).toBe(20); + expect(state.characters[0].silver).toBe(7); + expect(loot.rollLoot).toHaveBeenCalledTimes(1); + }); + }); + + describe('loadRewards', () => { + it('returns null for a combat that was never rewarded', async () => { + const state = createState(); + + await expect( + service(state).loadRewards(COMBAT_ID, fakeManager(state)), + ).resolves.toBeNull(); + }); + + it('replays the persisted reward without rerolling', async () => { + const state = createState(); + const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); + const subject = service(state, loot, fixedRandom(0)); + const manager = fakeManager(state); + const granted = await subject.grantVictoryRewards(manager, combat()); + + const replayed = await subject.loadRewards(COMBAT_ID, manager); + + expect(replayed).toEqual(granted); + expect(loot.rollLoot).toHaveBeenCalledTimes(1); + }); + }); + + describe('failure handling', () => { + it('throws instead of half-granting when a rolled item definition is missing', async () => { + const state = createState(); + + await expect( + service(state, fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 })).grantVictoryRewards( + fakeManager(state), + combat(), + ), + ).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' }); + expect(state.combatRewardItems).toHaveLength(0); + }); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- combat-reward.service` +Expected: FAIL — `Cannot find module './combat-reward.service'`. + +- [ ] **Step 4: Implement CombatRewardService** + +```ts +// apps/api/src/rewards/combat-reward.service.ts +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +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 { ItemRarity } from '../items/item-rarity.enum'; +import { LootService } from '../loot/loot.service'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { RANDOM_SOURCE } from '../shared/random-source'; +import type { RandomSource } from '../shared/random-source'; +import { rollInclusive } from '../shared/roll-range'; +import { CombatReward } from './entities/combat-reward.entity'; +import { CombatRewardItem } from './entities/combat-reward-item.entity'; +import { combatNotWon, rewardStateInvalid } from './rewards.errors'; + +export interface CombatRewardItemDto { + characterItemId: string; + item: { + key: string; + name: string; + rarity: ItemRarity; + iconPath: string; + }; + quantity: number; +} + +export interface CombatRewardDto { + experience: number; + silver: number; + items: CombatRewardItemDto[]; +} + +// Both DataSource and EntityManager expose this; naming it keeps the read path +// usable inside and outside a transaction without a union type. +type RepositoryScope = Pick; + +@Injectable() +export class CombatRewardService { + constructor( + private readonly dataSource: DataSource, + private readonly lootService: LootService, + @Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource, + ) {} + + /** + * Grants a won combat's rewards exactly once (spec §7, §21). + * + * Runs inside the caller's transaction — `CombatService.performAction` + * already holds a pessimistic write lock on the combat row — so either + * everything below commits or nothing does. + * + * Roll order is fixed: silver first, then the loot table in `position` + * order. Tests depend on it. + */ + async grantVictoryRewards( + manager: EntityManager, + combat: Combat, + ): Promise { + if (combat.status !== CombatStatus.WON) { + throw combatNotWon(); + } + + const rewards = manager.getRepository(CombatReward); + const existing = await rewards.findOne({ where: { combatId: combat.id } }); + if (existing) { + // Already rewarded: replay rather than roll again. + return this.toDto(manager, existing); + } + + const monster = await manager + .getRepository(MonsterDefinition) + .findOneBy({ id: combat.monsterDefinitionId }); + if (!monster) { + throw rewardStateInvalid(); + } + + const experience = monster.experienceReward; + const silver = rollInclusive( + this.randomSource, + monster.silverMin, + monster.silverMax, + ); + const roll = await this.lootService.rollLoot(monster.lootTableId, manager); + + const characters = manager.getRepository(Character); + const character = await characters.findOne({ + where: { id: combat.characterId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!character) { + throw rewardStateInvalid(); + } + character.experience += experience; + character.silver += silver; + await characters.save(character); + + const reward = await rewards.save( + rewards.create({ + combatId: combat.id, + characterId: combat.characterId, + experienceGranted: experience, + silverGranted: silver, + }), + ); + + const definitions = manager.getRepository(ItemDefinition); + const characterItems = manager.getRepository(CharacterItem); + const rewardItems = manager.getRepository(CombatRewardItem); + const items: CombatRewardItemDto[] = []; + + for (const rolled of roll.items) { + const definition = await definitions.findOneBy({ + id: rolled.itemDefinitionId, + }); + if (!definition) { + throw rewardStateInvalid(); + } + + const existingStack = await characterItems.findOne({ + where: { + characterId: combat.characterId, + itemDefinitionId: rolled.itemDefinitionId, + }, + lock: { mode: 'pessimistic_write' }, + }); + // Duplicates stack; Slice 0.4 adds no duplicate protection (spec §28). + const characterItem = existingStack + ? Object.assign(existingStack, { + quantity: existingStack.quantity + rolled.quantity, + }) + : characterItems.create({ + characterId: combat.characterId, + itemDefinitionId: rolled.itemDefinitionId, + quantity: rolled.quantity, + }); + await characterItems.save(characterItem); + + await rewardItems.save( + rewardItems.create({ + combatRewardId: reward.id, + characterItemId: characterItem.id, + itemDefinitionId: definition.id, + quantity: rolled.quantity, + }), + ); + + items.push(this.toItemDto(characterItem.id, definition, rolled.quantity)); + } + + return { experience, silver, items }; + } + + /** Reads a persisted reward so a refresh replays it (spec §25, §48). */ + async loadRewards( + combatId: string, + manager?: EntityManager, + ): Promise { + const scope: RepositoryScope = manager ?? this.dataSource; + const reward = await scope + .getRepository(CombatReward) + .findOne({ where: { combatId } }); + + return reward ? this.toDto(scope, reward) : null; + } + + private async toDto( + scope: RepositoryScope, + reward: CombatReward, + ): Promise { + const rewardItems = await scope + .getRepository(CombatRewardItem) + .find({ where: { combatRewardId: reward.id } }); + const definitions = scope.getRepository(ItemDefinition); + + const items: CombatRewardItemDto[] = []; + for (const rewardItem of rewardItems) { + const definition = await definitions.findOneBy({ + id: rewardItem.itemDefinitionId, + }); + if (!definition) { + // combat_reward_items.item_definition_id is a RESTRICT FK. + throw rewardStateInvalid(); + } + items.push( + this.toItemDto(rewardItem.characterItemId, definition, rewardItem.quantity), + ); + } + + return { + experience: reward.experienceGranted, + silver: reward.silverGranted, + items, + }; + } + + private toItemDto( + characterItemId: string, + definition: ItemDefinition, + quantity: number, + ): CombatRewardItemDto { + // Drop chance, roll results, and loot-table ids never leave the server + // (spec §26). + return { + characterItemId, + item: { + key: definition.key, + name: definition.name, + rarity: definition.rarity, + iconPath: definition.iconPath, + }, + quantity, + }; + } +} +``` + +```ts +// apps/api/src/rewards/rewards.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootModule } from '../loot/loot.module'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; +import { CombatRewardService } from './combat-reward.service'; +import { CombatReward } from './entities/combat-reward.entity'; +import { CombatRewardItem } from './entities/combat-reward-item.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Character, + CharacterItem, + ItemDefinition, + MonsterDefinition, + CombatReward, + CombatRewardItem, + ]), + LootModule, + ], + providers: [ + CombatRewardService, + { provide: RANDOM_SOURCE, useValue: systemRandomSource }, + ], + exports: [CombatRewardService], +}) +export class RewardsModule {} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/api -- combat-reward.service` +Expected: PASS — 13 tests. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/rewards +git commit -m "feat(rewards): add CombatRewardService with idempotent victory rewards" +``` + +--- + +## Task 8: Wire rewards into the combat flow + +`CombatService` calls the reward service the moment the engine reports `WON`, inside the transaction it already opens. `CombatEngineService` is not touched at all (spec §30, §56). + +**Files:** +- Modify: `apps/api/src/combat/combat.service.ts` +- Modify: `apps/api/src/combat/combat.module.ts` +- Test: `apps/api/src/combat/combat.service.spec.ts` + +**Interfaces:** +- Consumes: `CombatRewardService.grantVictoryRewards`, `CombatRewardService.loadRewards`, `CombatRewardDto` (Task 7). +- Produces: `CombatDto.rewards: CombatRewardDto | null` on every combat response — `startCombat`, `getCombat`, `getActiveCombat`, and `performAction`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/src/combat/combat.service.spec.ts`. Add the import: + +```ts +import { CombatRewardService } from '../rewards/combat-reward.service'; +``` + +Add this fake near `fakeTravelService`: + +```ts +function fakeRewardService( + overrides: Partial<{ + grantVictoryRewards: jest.Mock; + loadRewards: jest.Mock; + }> = {}, +): CombatRewardService { + return { + grantVictoryRewards: + overrides.grantVictoryRewards ?? + jest.fn().mockResolvedValue({ experience: 8, silver: 6, items: [] }), + loadRewards: overrides.loadRewards ?? jest.fn().mockResolvedValue(null), + } as unknown as CombatRewardService; +} +``` + +Every existing `new CombatService(...)` call in this file gains a fourth argument. Find them with `git grep -n "new CombatService(" -- apps/api/src` and append `, fakeRewardService()` to each. + +Then add these tests: + +```ts + it('grants rewards inside the same transaction when the round ends in victory', async () => { + const dataSource = new FakeDataSource( + createState({ + combats: [ + { + id: 'combat-1', + characterId: CHARACTER_ID, + huntEncounterId: ENCOUNTER_ID, + monsterDefinitionId: MONSTER_ID, + status: CombatStatus.ACTIVE, + round: 3, + playerMaxHp: 100, + playerCurrentHp: 80, + monsterMaxHp: 45, + monsterCurrentHp: 1, + playerState: { attack: 6, weaponDamage: 8, armor: 6 }, + monsterState: { attack: 5, armor: 0 }, + completedAt: null, + } as Combat, + ], + }), + ); + const rewards = fakeRewardService({ + grantVictoryRewards: jest.fn().mockResolvedValue({ + experience: 8, + silver: 6, + items: [], + }), + }); + const service = new CombatService( + dataSource as unknown as DataSource, + fakeTravelService(), + new CombatEngineService(), + new CharacterCombatStatsService(), + rewards, + ); + + const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK); + + expect(result.status).toBe(CombatStatus.WON); + expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] }); + expect(rewards.grantVictoryRewards).toHaveBeenCalledTimes(1); + // The reward service receives the transaction manager, not the data source. + expect(rewards.grantVictoryRewards).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'combat-1', status: CombatStatus.WON }), + ); + }); + + it('grants no rewards when the round ends in defeat', async () => { + const dataSource = new FakeDataSource( + createState({ + combats: [ + { + id: 'combat-1', + characterId: CHARACTER_ID, + huntEncounterId: ENCOUNTER_ID, + monsterDefinitionId: MONSTER_ID, + status: CombatStatus.ACTIVE, + round: 3, + playerMaxHp: 100, + playerCurrentHp: 1, + monsterMaxHp: 45, + monsterCurrentHp: 45, + playerState: { attack: 1, weaponDamage: 1, armor: 0 }, + monsterState: { attack: 99, armor: 99 }, + completedAt: null, + } as Combat, + ], + }), + ); + const rewards = fakeRewardService(); + const service = new CombatService( + dataSource as unknown as DataSource, + fakeTravelService(), + new CombatEngineService(), + new CharacterCombatStatsService(), + rewards, + ); + + const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK); + + expect(result.status).toBe(CombatStatus.LOST); + expect(result.rewards).toBeNull(); + expect(rewards.grantVictoryRewards).not.toHaveBeenCalled(); + }); + + it('replays the persisted reward when a finished combat is read again', async () => { + const persisted = { + experience: 16, + silver: 12, + items: [ + { + characterItemId: 'character-item-1', + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: 'COMMON', + iconPath: '/images/items/bandit-blade.png', + }, + quantity: 1, + }, + ], + }; + const dataSource = new FakeDataSource( + createState({ + combats: [ + { + id: 'combat-1', + characterId: CHARACTER_ID, + huntEncounterId: ENCOUNTER_ID, + monsterDefinitionId: MONSTER_ID, + status: CombatStatus.WON, + round: 5, + playerMaxHp: 100, + playerCurrentHp: 62, + monsterMaxHp: 45, + monsterCurrentHp: 0, + playerState: { attack: 6, weaponDamage: 8, armor: 6 }, + monsterState: { attack: 5, armor: 0 }, + completedAt: new Date('2026-08-19T09:00:00.000Z'), + } as Combat, + ], + }), + ); + const rewards = fakeRewardService({ + loadRewards: jest.fn().mockResolvedValue(persisted), + grantVictoryRewards: jest.fn(), + }); + const service = new CombatService( + dataSource as unknown as DataSource, + fakeTravelService(), + new CombatEngineService(), + new CharacterCombatStatsService(), + rewards, + ); + + const result = await service.getCombat(CHARACTER_ID, 'combat-1'); + + expect(result.rewards).toEqual(persisted); + // Reading must never grant: only the ACTIVE -> WON transition does. + expect(rewards.grantVictoryRewards).not.toHaveBeenCalled(); + }); + + it('persists nothing at all when reward resolution fails mid-transaction', async () => { + const dataSource = new FakeDataSource( + createState({ + combats: [ + { + id: 'combat-1', + characterId: CHARACTER_ID, + huntEncounterId: ENCOUNTER_ID, + monsterDefinitionId: MONSTER_ID, + status: CombatStatus.ACTIVE, + round: 3, + playerMaxHp: 100, + playerCurrentHp: 80, + monsterMaxHp: 45, + monsterCurrentHp: 1, + playerState: { attack: 6, weaponDamage: 8, armor: 6 }, + monsterState: { attack: 5, armor: 0 }, + completedAt: null, + } as Combat, + ], + }), + ); + const service = new CombatService( + dataSource as unknown as DataSource, + fakeTravelService(), + new CombatEngineService(), + new CharacterCombatStatsService(), + fakeRewardService({ + grantVictoryRewards: jest.fn().mockRejectedValue(new Error('reward persistence failed')), + }), + ); + + await expect( + service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK), + ).rejects.toThrow('reward persistence failed'); + + // The whole round rolled back: the combat is still ACTIVE and unmodified, + // so no half-granted state can survive. + expect(dataSource.state.combats[0].status).toBe(CombatStatus.ACTIVE); + expect(dataSource.state.combats[0].monsterCurrentHp).toBe(1); + expect(dataSource.state.combatEvents).toHaveLength(0); + expect(dataSource.state.characters[0].experience).toBe(0); + expect(dataSource.state.characters[0].silver).toBe(0); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- combat.service` +Expected: FAIL — `CombatService` takes 4 constructor arguments, and `result.rewards` is `undefined`. + +- [ ] **Step 3: Wire the reward service into CombatService** + +In `apps/api/src/combat/combat.service.ts`: + +Add the import: + +```ts +import { CombatRewardService } from '../rewards/combat-reward.service'; +import type { CombatRewardDto } from '../rewards/combat-reward.service'; +``` + +Add `rewards` to `CombatDto`: + +```ts +export interface CombatDto { + id: string; + status: CombatStatus; + round: number; + player: CombatPlayerDto; + monster: CombatMonsterDto; + events: CombatEventDto[]; + rewards: CombatRewardDto | null; +} +``` + +Add the constructor parameter: + +```ts + constructor( + private readonly dataSource: DataSource, + private readonly travelService: TravelService, + private readonly combatEngine: CombatEngineService, + private readonly characterCombatStats: CharacterCombatStatsService, + private readonly combatRewards: CombatRewardService, + ) {} +``` + +Change `toCombatDto` to take rewards as its final argument: + +```ts + private toCombatDto( + combat: Combat, + playerName: string, + monster: MonsterDefinition, + events: CombatEvent[], + rewards: CombatRewardDto | null, + ): CombatDto { +``` + +and add `rewards,` to the returned object literal. + +Update the four call sites: +- `startCombat` — a brand new combat has none: `return this.toCombatDto(combat, character.name, monster, [], null);` +- `getCombat` — add `this.combatRewards.loadRewards(combat.id)` to the existing `Promise.all` and pass the result: + +```ts + const [character, monster, events, rewards] = await Promise.all([ + this.loadCharacter(combat.characterId), + this.loadMonster(combat.monsterDefinitionId), + this.loadEvents(combat.id), + this.combatRewards.loadRewards(combat.id), + ]); + + return this.toCombatDto(combat, character.name, monster, events, rewards); +``` + +- `getActiveCombat` — an ACTIVE combat cannot have rewards yet, so pass `null`: + +```ts + return this.toCombatDto(combat, character.name, monster, events, null); +``` + +- `performAction` — grant on the winning transition, **before** loading the response data, so the reward lands in the same transaction: + +```ts + // The engine decided the outcome; rewards are resolved here, outside it + // (spec §30). Running inside this transaction means a reward failure + // rolls the whole round back rather than leaving a half-granted victory. + const rewards = + combat.status === CombatStatus.WON + ? await this.combatRewards.grantVictoryRewards(manager, combat) + : null; + + const [character, monster, events] = await Promise.all([ + this.loadCharacter(combat.characterId, manager.getRepository(Character)), + this.loadMonster( + combat.monsterDefinitionId, + manager.getRepository(MonsterDefinition), + ), + this.loadEvents(combat.id, combatEvents), + ]); + + return this.toCombatDto(combat, character.name, monster, events, rewards); +``` + +- [ ] **Step 4: Import RewardsModule into CombatModule** + +In `apps/api/src/combat/combat.module.ts`, add the import and list it: + +```ts +import { RewardsModule } from '../rewards/rewards.module'; +``` + +```ts + imports: [ + TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]), + TravelModule, + CharactersModule, + RewardsModule, + ], +``` + +- [ ] **Step 5: Fix the controller spec fixtures** + +`apps/api/src/combat/combat.controller.spec.ts` and `hunt-encounter-attack.controller.spec.ts` build `CombatDto` fixtures. Add `rewards: null` to each. Find them with: + +Run: `git grep -n "events: \[\]" -- apps/api/src/combat` + +- [ ] **Step 6: Run the full API suite and build** + +Run: `npm test --workspace=@ashen-realms/api` +Expected: PASS — all suites green, including the 4 new combat-service tests. + +Run: `npm run build:api` +Expected: build succeeds. + +- [ ] **Step 7: Verify the engine stayed clean** + +Run: `git grep -n -i "reward\|silver\|loot\|item\|experience\|rarity" -- apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-damage.ts` +Expected: **no matches.** If anything matches, reward logic leaked into the engine — move it out (spec §56). + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/combat +git commit -m "feat(combat): resolve victory rewards in the combat completion transaction" +``` + +--- + +## Task 9: Expose silver on the character endpoint + +The TopBar needs authoritative silver (spec §35). + +**Files:** +- Modify: `apps/api/src/characters/characters.service.ts` +- Test: `apps/api/src/characters/characters.service.spec.ts` + +**Interfaces:** +- Consumes: `Character.silver` (Task 2). +- Produces: `GET /characters/me` response gains `silver: number`. + +- [ ] **Step 1: Write the failing test** + +Two changes in `apps/api/src/characters/characters.service.spec.ts`. First, add `silver: 0,` to the stubbed character in the existing "returns the demo character with its current location summary" test (after `experience: 0,` on line 15) and `silver: 0,` to that test's expected object (after `experience: 0,` on line 32) — the existing assertion uses an exact `toEqual`, so it fails without both. + +Then add this test: + +```ts + it('exposes the persisted silver so the HUD never has to guess', async () => { + const repository = { + findOne: jest.fn().mockResolvedValue({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 24, + silver: 18, + currentHp: 100, + baseHp: 100, + baseAttack: 6, + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + }, + }), + } as unknown as Repository; + const service = new CharactersService(repository); + + await expect(service.getDemoCharacter()).resolves.toEqual( + expect.objectContaining({ experience: 24, silver: 18 }), + ); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- characters.service` +Expected: FAIL — `silver` is missing from the returned object. + +- [ ] **Step 3: Add silver to the response** + +In `apps/api/src/characters/characters.service.ts`, add after `experience`: + +```ts + silver: character.silver, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/api -- characters.service` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/characters +git commit -m "feat(characters): expose persisted silver on the character endpoint" +``` + +--- + +## Task 10: Frontend reward models and the item card + +A compact, reusable item presentation: icon, name, rarity, quantity when relevant. **No** equip button, stat comparison, or slot replacement — those are Slice 0.5 (spec §33). + +**Files:** +- Modify: `apps/web/src/app/core/api/game-api.models.ts` +- Create: `apps/web/src/app/shared/item-card/item-card.component.ts` +- Create: `apps/web/src/app/shared/item-card/item-card.component.html` +- Create: `apps/web/src/app/shared/item-card/item-card.component.scss` +- Test: `apps/web/src/app/shared/item-card/item-card.component.spec.ts` + +**Interfaces:** +- Consumes: the reward DTO shape from Task 7. +- Produces: + - `type ItemRarity = 'COMMON' | 'RARE' | 'EPIC'` + - `interface RewardItemSummary { key: string; name: string; rarity: ItemRarity; iconPath: string }` + - `interface CombatRewardItem { characterItemId: string; item: RewardItemSummary; quantity: number }` + - `interface CombatReward { experience: number; silver: number; items: CombatRewardItem[] }` + - `Combat.rewards: CombatReward | null`, `CharacterResponse.silver: number` + - `ItemCardComponent` — selector `app-item-card`, inputs `item: RewardItemSummary` (required) and `quantity: number` (default `1`) + - `RARITY_LABELS: Readonly>` + +- [ ] **Step 1: Extend the API models** + +In `apps/web/src/app/core/api/game-api.models.ts`, add `silver: number;` to `CharacterResponse` right after `experience`, and append: + +```ts +export type ItemRarity = 'COMMON' | 'RARE' | 'EPIC'; + +export interface RewardItemSummary { + key: string; + name: string; + rarity: ItemRarity; + iconPath: string; +} + +export interface CombatRewardItem { + characterItemId: string; + item: RewardItemSummary; + quantity: number; +} + +export interface CombatReward { + experience: number; + silver: number; + items: CombatRewardItem[]; +} +``` + +and add to the `Combat` interface: + +```ts + rewards: CombatReward | null; +``` + +- [ ] **Step 2: Write the failing test** + +```ts +// apps/web/src/app/shared/item-card/item-card.component.spec.ts +import { TestBed } from '@angular/core/testing'; +import type { RewardItemSummary } from '../../core/api/game-api.models'; +import { ItemCardComponent } from './item-card.component'; + +const banditBlade: RewardItemSummary = { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: 'COMMON', + iconPath: '/images/items/bandit-blade.png', +}; + +async function setup(item: RewardItemSummary, quantity = 1) { + await TestBed.configureTestingModule({ imports: [ItemCardComponent] }).compileComponents(); + + const fixture = TestBed.createComponent(ItemCardComponent); + fixture.componentRef.setInput('item', item); + fixture.componentRef.setInput('quantity', quantity); + fixture.detectChanges(); + return fixture; +} + +describe('ItemCardComponent', () => { + it('renders the icon, name, and German rarity label', async () => { + const fixture = await setup(banditBlade); + const element = fixture.nativeElement as HTMLElement; + + const icon = element.querySelector('[data-item-icon]'); + expect(icon?.getAttribute('src')).toBe('/images/items/bandit-blade.png'); + expect(icon?.getAttribute('alt')).toBe('Räuberklinge'); + expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge'); + expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich'); + }); + + it('labels the other rarities in German too', async () => { + const rare = await setup({ ...banditBlade, rarity: 'RARE' }); + expect( + (rare.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent, + ).toContain('Selten'); + + const epic = await setup({ ...banditBlade, rarity: 'EPIC' }); + expect( + (epic.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent, + ).toContain('Episch'); + }); + + it('hides the quantity for a single item and shows it for a stack', async () => { + const single = await setup(banditBlade, 1); + expect((single.nativeElement as HTMLElement).querySelector('[data-item-quantity]')).toBeNull(); + + const stack = await setup(banditBlade, 3); + expect( + (stack.nativeElement as HTMLElement).querySelector('[data-item-quantity]')?.textContent, + ).toContain('3'); + }); + + it('offers no equip or comparison affordance yet', async () => { + const fixture = await setup(banditBlade); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('button')).toBeNull(); + expect(element.textContent).not.toContain('Anlegen'); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/web -- item-card` +Expected: FAIL — `Cannot find module './item-card.component'`. + +- [ ] **Step 4: Implement the component** + +```ts +// apps/web/src/app/shared/item-card/item-card.component.ts +import { Component, computed, input } from '@angular/core'; +import type { ItemRarity, RewardItemSummary } from '../../core/api/game-api.models'; + +export const RARITY_LABELS: Readonly> = { + COMMON: 'Gewöhnlich', + RARE: 'Selten', + EPIC: 'Episch', +}; + +/** + * Compact item presentation for loot (spec §33). + * + * Deliberately read-only: equipping, stat comparison, and slot replacement + * belong to Slice 0.5. + */ +@Component({ + selector: 'app-item-card', + templateUrl: './item-card.component.html', + styleUrl: './item-card.component.scss', +}) +export class ItemCardComponent { + readonly item = input.required(); + readonly quantity = input(1); + + protected readonly rarityLabel = computed(() => RARITY_LABELS[this.item().rarity]); + protected readonly rarityModifier = computed(() => `item-card--${this.item().rarity.toLowerCase()}`); +} +``` + +```html + +
+
+ + @if (quantity() > 1) { + ×{{ quantity() }} + } +
+

{{ item().name }}

+

{{ rarityLabel() }}

+
+``` + +```scss +/* apps/web/src/app/shared/item-card/item-card.component.scss */ +.item-card { + display: grid; + gap: var(--ar-space-1); + justify-items: center; + inline-size: 8.5rem; + text-align: center; +} + +.item-card__frame { + position: relative; + display: grid; + place-items: center; + inline-size: 6rem; + block-size: 6rem; + padding: var(--ar-space-1); + border: 1px solid var(--ar-border); + background: linear-gradient(180deg, #1b1f22, #0d1012); + box-shadow: var(--ar-shadow-raised); +} + +.item-card__icon { + inline-size: 100%; + block-size: 100%; + object-fit: contain; +} + +.item-card__quantity { + position: absolute; + inset-block-end: 0.1rem; + inset-inline-end: 0.25rem; + color: var(--ar-text); + font-size: var(--ar-font-sm); + text-shadow: 0 0 0.3rem #000; +} + +.item-card__name { + margin: 0; + color: var(--ar-text); + font-family: Georgia, 'Times New Roman', serif; + line-height: 1.2; +} + +.item-card__rarity { + margin: 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Restrained rarity emphasis: the frame edge carries it, nothing flashes. */ +.item-card--rare .item-card__frame { + border-color: var(--ar-blue); +} + +.item-card--rare .item-card__rarity { + color: var(--ar-blue); +} + +.item-card--epic .item-card__frame { + border-color: var(--ar-border-highlight); +} + +.item-card--epic .item-card__rarity { + color: var(--ar-gold); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/web -- item-card` +Expected: PASS — 4 tests. + +- [ ] **Step 6: Fix any Combat fixtures the new required field breaks** + +`Combat` now requires `rewards`. Add `rewards: null` to every `Combat` fixture: + +Run: `git grep -ln "status: 'ACTIVE'" -- apps/web/src` + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS — all suites green. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/shared/item-card apps/web/src/app +git commit -m "feat(web): add reward models and the reusable item card" +``` + +--- + +## Task 11: Victory reward summary + +Replace the Slice 0.3 placeholder ("Belohnungen werden im nächsten Schritt verarbeitet") with the real summary. Presentation order is Sieg → XP → Silber → Beute (spec §32). No loot-box or slot-machine treatment (spec §50). + +**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:83-91` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.scss` +- Test: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts` + +**Interfaces:** +- Consumes: `Combat.rewards`, `CombatReward`, `ItemCardComponent` (Task 10). +- Produces: victory panel markup with `[data-combat-rewards]`, `[data-reward-experience]`, `[data-reward-silver]`, `[data-reward-empty]`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`: + +```ts + const wonWithRewards: Combat = { + ...activeCombat, + status: 'WON', + monster: { ...activeCombat.monster, currentHp: 0 }, + rewards: { experience: 8, silver: 6, items: [] }, + }; + + it('shows the granted XP and silver on the victory screen', async () => { + const fixture = await setup(wonWithRewards); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-rewards]')).toBeTruthy(); + expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('8'); + expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('6'); + }); + + it('renders a dropped item with its icon, name, and rarity', async () => { + const fixture = await setup({ + ...wonWithRewards, + monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Straßenräuber', currentHp: 0 }, + rewards: { + experience: 16, + silver: 12, + items: [ + { + characterItemId: 'character-item-1', + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: 'COMMON', + iconPath: '/images/items/bandit-blade.png', + }, + quantity: 1, + }, + ], + }, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-item-icon]')?.getAttribute('src')).toBe( + '/images/items/bandit-blade.png', + ); + expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge'); + expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich'); + expect(element.querySelector('[data-reward-empty]')).toBeNull(); + }); + + it('treats a victory without loot as complete, not as a failure', async () => { + const fixture = await setup(wonWithRewards); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-reward-empty]')?.textContent).toContain( + 'Keine besondere Beute gefunden.', + ); + expect(element.querySelector('[role="alert"]')).toBeNull(); + // XP and silver still carry the screen. + expect(element.querySelector('[data-reward-experience]')).toBeTruthy(); + }); + + it('renders the persisted rewards of an already-won combat loaded from the server', async () => { + // Simulates a browser refresh: the page loads the combat by id and shows + // exactly what the server persisted, without rerolling anything. + const fixture = await setup({ + ...wonWithRewards, + rewards: { experience: 16, silver: 12, items: [] }, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1'); + expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('16'); + expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('12'); + }); + + it('still shows a plain victory when the server reports no reward record', async () => { + const fixture = await setup({ ...wonWithRewards, rewards: null }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy(); + expect(element.querySelector('[data-combat-rewards]')).toBeNull(); + }); +``` + +Also add `rewards: null` to the existing `activeCombat` fixture at the top of the file. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/web -- combat-page` +Expected: FAIL — `[data-combat-rewards]` is null. + +- [ ] **Step 3: Import the item card into the component** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.ts`, add the import and register it: + +```ts +import { ItemCardComponent } from '../../../shared/item-card/item-card.component'; +``` + +and add to the `@Component` decorator (it currently has no `imports` array — add one): + +```ts + imports: [ItemCardComponent], +``` + +- [ ] **Step 4: Replace the victory panel** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.html`, replace the whole `@if (combat.status === 'WON') { ... }` block (lines 83–91) with: + +```html + @if (combat.status === 'WON') { +
+

Sieg

+

{{ combat.monster.name }} wurde besiegt.

+ + @if (combat.rewards; as rewards) { +
+

Belohnungen

+ +
+
+
Erfahrung
+
+{{ rewards.experience }} XP
+
+
+
Silber
+
+{{ rewards.silver }}
+
+
+ + @if (rewards.items.length) { +

Beute

+
    + @for (reward of rewards.items; track reward.characterItemId) { +
  • + +
  • + } +
+ } @else { +

Keine besondere Beute gefunden.

+ } +
+ } + + +
+ } @else if (combat.status === 'LOST') { +``` + +- [ ] **Step 5: Style the summary** + +Append to `apps/web/src/app/features/combat/combat-page/combat-page.component.scss`: + +```scss +/* Reward summary: dark stone and bronze, large readable values, restrained + rarity emphasis. No confetti, no popups, no slot-machine reveal (spec §50). */ +.rewards { + display: grid; + gap: var(--ar-space-3); + justify-items: center; + inline-size: 100%; + margin-block-start: var(--ar-space-3); + padding-block-start: var(--ar-space-3); + border-block-start: 1px solid var(--ar-border); +} + +.rewards__title { + margin: 0; + color: var(--ar-text-muted); + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--ar-font-sm); + font-weight: 400; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.rewards__currencies { + display: flex; + gap: var(--ar-space-6); + margin: 0; +} + +.rewards__currency { + display: grid; + gap: var(--ar-space-1); + justify-items: center; +} + +.rewards__currency dt { + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rewards__currency dd { + margin: 0; + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(1.25rem, 2.5vw, 1.6rem); +} + +.rewards__loot { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-4); + justify-content: center; + margin: 0; + padding: 0; + list-style: none; +} +``` + +The victory panel is now taller than the Slice 0.3 placeholder. Widen it so a loot card fits: + +```scss +.outcome--won { + inline-size: min(32rem, 90%); +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/web -- combat-page` +Expected: PASS — the existing tests plus the 5 new ones. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/features/combat +git commit -m "feat(combat): replace the victory placeholder with the reward summary" +``` + +--- + +## Task 12: TopBar silver and XP + +After a victory the HUD must refresh from authoritative server data, never optimistically (spec §35). + +**Files:** +- Modify: `apps/web/src/app/features/world/world.store.ts` +- Modify: `apps/web/src/app/layout/top-bar/top-bar.component.html` +- Modify: `apps/web/src/app/layout/top-bar/top-bar.component.scss` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` +- Test: `apps/web/src/app/features/world/world.store.spec.ts` +- Test: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts` + +**Interfaces:** +- Consumes: `CharacterResponse.silver` (Tasks 9, 10). +- Produces: `WorldStore.refreshCharacter(): Promise` — re-reads `GET /characters/me` and replaces the character signal. + +- [ ] **Step 1: Write the failing store test** + +First add `silver: 0,` to the `character` fixture in `apps/web/src/app/features/world/world.store.spec.ts` (after `experience: 0,` on line 17) — `CharacterResponse` now requires it. + +Then add these tests. The file's `beforeEach` already builds `api` and `store`, so they use those directly: + +```ts + it('refreshCharacter replaces the character from authoritative server data', async () => { + await store.load(); + + api.getCharacter.mockReturnValue(of({ ...character, experience: 32, silver: 18 })); + await store.refreshCharacter(); + + expect(store.character()?.experience).toBe(32); + expect(store.character()?.silver).toBe(18); + }); + + it('keeps the previous character when the refresh fails', async () => { + await store.load(); + + api.getCharacter.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500 })), + ); + await store.refreshCharacter(); + + expect(store.character()?.silver).toBe(0); + expect(store.error()).toBeNull(); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/web -- world.store` +Expected: FAIL — `store.refreshCharacter is not a function`. + +- [ ] **Step 3: Add refreshCharacter to WorldStore** + +In `apps/web/src/app/features/world/world.store.ts`, add this public method after `selectConnection`: + +```ts + /** + * Re-reads the character from the server, e.g. after a combat granted XP and + * silver. Never mutates the values locally: the server owns them (spec §35). + * A failed refresh leaves the last known character in place rather than + * blanking the HUD. + */ + async refreshCharacter(): Promise { + if (this.destroyed) { + return; + } + + try { + const character = await firstValueFrom(this.api.getCharacter()); + if (!this.destroyed) { + this.characterState.set(character); + } + } catch { + // Keep the previous character; the next load() will resync. + } + } +``` + +- [ ] **Step 4: Show silver and XP in the TopBar** + +In `apps/web/src/app/layout/top-bar/top-bar.component.html`, add a resources block after the `top-bar__health` div (inside the same `@if (character(); as character)` branch): + +```html +
+
+
Silber
+
{{ character.silver }}
+
+
+
XP
+
{{ character.experience }}
+
+
+``` + +Append to `apps/web/src/app/layout/top-bar/top-bar.component.scss`: + +```scss +.top-bar__resources { + display: flex; + gap: var(--ar-space-4); + margin: 0; + padding-inline-start: var(--ar-space-4); + border-inline-start: 1px solid var(--ar-border); +} + +.top-bar__resource { + display: grid; + gap: var(--ar-space-1); +} + +.top-bar__resource dt { + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.top-bar__resource dd { + margin: 0; + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; +} +``` + +- [ ] **Step 5: Refresh the HUD after a victory** + +Add to `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`: + +```ts + it('refreshes the character from the server once a combat is won', async () => { + const fixture = await setup(activeCombat); + combatStore.attack.mockImplementation(async () => { + combatStore.combat.set({ + ...activeCombat, + status: 'WON', + monster: { ...activeCombat.monster, currentHp: 0 }, + rewards: { experience: 8, silver: 6, items: [] }, + events: [ + ...activeCombat.events, + { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 }, + { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, + ], + }); + }); + vi.useFakeTimers(); + + const element = fixture.nativeElement as HTMLElement; + element.querySelector('[data-combat-attack]')?.click(); + await vi.advanceTimersByTimeAsync(540); + fixture.detectChanges(); + + expect(worldStore.refreshCharacter).toHaveBeenCalledOnce(); + }); +``` + +Extend the spec's `setup()` to provide a `WorldStore` stub — add to the `providers` array: + +```ts + { provide: WorldStore, useValue: worldStore }, +``` + +with, alongside the `combatStore` declaration: + +```ts + let worldStore: { refreshCharacter: ReturnType }; +``` + +and inside `setup()`: + +```ts + worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; +``` + +Import `WorldStore` from `'../../world/world.store'` in the spec. + +In `combat-page.component.ts`, inject the store and call it when the fight ends in victory: + +```ts +import { WorldStore } from '../../world/world.store'; +``` + +```ts + private readonly worldStore = inject(WorldStore); +``` + +Then in `attack()`, right after `const after = this.combatStore.combat();` and its null guard, add: + +```ts + if (after.status === 'WON') { + // The server already granted XP and silver; pull the authoritative + // character so the HUD matches (spec §35). + void this.worldStore.refreshCharacter(); + } +``` + +- [ ] **Step 6: Run the web suite and build** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS — all suites green. + +Run: `npm run build:web` +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/features/world apps/web/src/app/layout/top-bar apps/web/src/app/features/combat +git commit -m "feat(web): show authoritative silver and XP in the top bar after victory" +``` + +--- + +## Task 13: Full verification + +Everything spec §52 requires, in one pass. Nothing here is optional. + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md` (record results below) + +**Interfaces:** +- Consumes: every prior task. +- Produces: a verified, demonstrable slice. + +- [ ] **Step 1: Run every automated check** + +```bash +npm test --workspace=@ashen-realms/api +npm test --workspace=@ashen-realms/web +npm run build:api +npm run build:web +``` + +Expected: four green results. Record the API and web test counts. + +- [ ] **Step 2: Verify migration and seed from a clean state** + +```bash +npm run db:revert && npm run db:migrate && npm run db:seed && npm run db:seed +``` + +Expected: revert, re-apply, and two seed runs all succeed. + +```bash +psql "$DATABASE_URL" -c "SELECT count(*) FROM item_definitions" -c "SELECT count(*) FROM loot_table_entries" +``` + +Expected: still 12 and 5 — the seed did not duplicate. + +- [ ] **Step 3: Browser walkthrough — Aschenratte victory** + +Start both apps (`npm run dev:api`, `npm run dev:web`), then walk: Südtor → Verbrannte Straße → Jagd beginnen → pick an Aschenratte → attack until it dies. + +Verify: +- the victory panel shows **+8 XP** and a silver value **between 4 and 7** +- the TopBar silver and XP both increased by exactly those amounts +- with no drop: "Keine besondere Beute gefunden." and no error styling +- repeat hunts until a drop occurs (60% Aschenfell): the item card shows icon, "Aschenfell", "Gewöhnlich" + +- [ ] **Step 4: Browser walkthrough — Straßenräuber victory** + +Hunt until a Straßenräuber appears and win. + +Verify: +- **+16 XP** and silver between 9 and 15 +- repeat until a Räuberklinge drops (18%): icon, name, and "Gewöhnlich" render + +```bash +psql "$DATABASE_URL" -c "SELECT ci.quantity, d.key FROM character_items ci JOIN item_definitions d ON d.id = ci.item_definition_id" +``` + +Expected: the dropped items exist as real rows — persistent player state, not a cosmetic card (spec §27). + +- [ ] **Step 5: Verify refresh does not reroll** + +On the victory screen, note the exact XP, silver, and items. Press F5. + +Verify: +- identical values reappear +- the item list is identical + +```bash +psql "$DATABASE_URL" -c "SELECT count(*) FROM combat_rewards WHERE combat_id = ''" +``` + +Expected: exactly `1`. + +- [ ] **Step 6: Verify a repeated reward request cannot farm** + +```bash +curl -s -X POST http://localhost:3000/api/combats//actions \ + -H 'Content-Type: application/json' -d '{"action":"ATTACK"}' +curl -s http://localhost:3000/api/combats/ +``` + +Expected: the POST is rejected with `COMBAT_ALREADY_FINISHED`; the GET returns the same reward. Then confirm nothing moved: + +```bash +psql "$DATABASE_URL" -c "SELECT count(*) FROM combat_rewards" -c "SELECT experience, silver FROM characters" +``` + +Expected: the reward count and the character's totals are unchanged from Step 5. + +- [ ] **Step 7: Verify the return to hunt** + +Click "Zur Jagd". + +Verify: `/hunt` loads, the consumed encounter is **not** attackable again, and "Neu suchen" starts a fresh hunt. + +- [ ] **Step 8: Record the results and commit** + +Append a "## Verification Results" section to this plan with the test counts, the observed silver rolls, which items dropped, and confirmation of steps 5–7. + +```bash +git add docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md +git commit -m "docs(slice-0.4): record First Loot verification results" +``` + +--- + +## Acceptance Criteria Traceability + +| Spec §53 criterion | Where it is satisfied | +|---|---| +| only WON combats receive victory rewards | Task 7 Step 4 guard; Task 7 eligibility tests; Task 8 defeat test | +| one combat grants rewards at most once | Task 3 unique index `IDX_combat_rewards_combat`; Task 7 idempotency test | +| XP is persisted | Task 7 `character.experience += experience`; Task 7 Aschenratte test | +| silver is persisted | Task 2 `characters.silver`; Task 3 migration; Task 7 Aschenratte test | +| silver ranges match the content definitions | Task 7 tests assert 4 and 7 (rat), 9 (bandit) from `monster_definitions` | +| loot rolls happen only on the backend | Task 6 `LootService`; no roll code ships to the client | +| loot probabilities are data-driven | Task 5 `loot_table_entries.drop_chance` rows | +| reward randomness is deterministic in tests | Task 1 `RandomSource`; Task 6 `queuedRandom`; Task 7 `fixedRandom` | +| dropped items become persistent CharacterItems | Task 7 CharacterItem test; Task 13 Step 4 SQL check | +| reward data survives browser refresh | Task 8 `loadRewards` in `getCombat`; Task 11 refresh test; Task 13 Step 5 | +| reward results never reroll on reload | Task 7 `loadRewards` never rolls; Task 8 "reading must never grant" assertion | +| Angular only renders server-provided reward state | Task 11 renders `combat.rewards` verbatim; no client-side generation | +| item drops are visibly presented | Task 10 `ItemCardComponent`; Task 11 item-drop test | +| a no-item victory is handled cleanly | Task 11 "treats a victory without loot as complete" test | +| CombatEngineService contains no reward logic | Task 8 Step 7 grep gate | +| no equipment functionality implemented prematurely | Task 10 "offers no equip or comparison affordance yet" test | +| all tests pass | Task 13 Step 1 | +| backend and frontend builds succeed | Task 13 Step 1 | + + + + +