# 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 | --- ## Verification Results Verified 2026-08-20 against a live PostgreSQL instance (Docker container `awesome_babbage`) with dev servers run on alternate ports (API 3901, web 4901) to avoid colliding with a sibling worktree's own dev servers already occupying 3000/4200. **Step 1 — automated checks:** all four green. - API: 23 test suites, 119 tests, 0 failures. - Web: 13 test suites, 104 tests, 0 failures. - `npm run build:api`: succeeds. - `npm run build:web`: succeeds (pre-existing `combat-page.component.scss` budget warning only — 9.14 kB vs. the 4 kB advisory threshold, well under the 12 kB error threshold; not a new regression, tracked since Task 11). **Step 2 — migration/seed round-trip:** `db:revert` → `db:migrate` → `db:seed` → `db:seed` all succeeded. Post-cycle counts: `item_definitions` = 12, `loot_table_entries` = 5 — unchanged by the second seed run. **Step 3 — Aschenratte victory (browser, Playwright-driven Chromium against the real dev servers):** - No-drop victory: +8 XP, +4 Silber, "Keine besondere Beute gefunden." with no error styling. - Drop victory: +8 XP, +5 Silber, item card showing icon, "Abgenutztes Kurzschwert", "GEWÖHNLICH" (the 8% starter-equipment branch of the ash-rat table, hit before the 60% Aschenfell branch in this run — both are real, data-driven rolls from the same table). - TopBar silver/XP increased by exactly the granted amounts after each victory. **Step 4 — Straßenräuber victory:** - +16 XP and silver rolls of 12, 13, and 10 across three wins — all within the documented 9–15 range. - One win dropped two items in a single combat (Räuberhaube + Plündererhandschuhe), directly demonstrating independent per-entry rolls (spec §17). - A later win dropped Räuberklinge alone; icon, name, and "GEWÖHNLICH" rendered correctly. - `character_items` verified via SQL: `bandit-blade`, `bandit-hood`, `raider-gloves`, `worn-short-sword`, and `ash-pelt` (from prior sessions) all persisted as real rows, quantity 1 each — not cosmetic (spec §27). **Step 5 — refresh does not reroll:** navigated to `/combat/` as a fresh page load (equivalent to F5 — no client cache reused). Rendered XP (+16), silver (+10), and item (Räuberklinge) were byte-identical to the original grant. `SELECT count(*) FROM combat_rewards WHERE combat_id = ''` = exactly `1`. **Step 6 — repeated reward request cannot farm:** `POST /api/combats//actions` on the already-WON combat returned `409 COMBAT_ALREADY_FINISHED`. The subsequent `GET` returned the identical persisted reward. Confirmed via SQL that `combat_rewards` count and the character's `experience`/`silver` totals were unchanged from Step 5. **Step 7 — return to hunt:** "Zur Jagd" returns to `/hunt` and redisplays the same encounter list, including the just-consumed encounter. Re-attacking that consumed encounter is rejected server-side with `HUNT_ENCOUNTER_ALREADY_CONSUMED` ("Diese Begegnung wurde bereits genutzt.") — no duplicate combat, no duplicate reward. "Neu suchen" / "Jagd beginnen" successfully starts a fresh hunt with new encounters. **Environment notes for future runs:** the worktree's `.env` (untracked, copied from the main checkout) needs its own `PORT` and the web app needs `ng serve --proxy-config ` whenever a sibling worktree's dev servers are already running on 3000/4200. `psql` is not on PATH — use `docker exec awesome_babbage psql -U ashen -d ashen_realms`.