3950 lines
139 KiB
Markdown
3950 lines
139 KiB
Markdown
# 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<void> {
|
||
// 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<void> {
|
||
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/<key>.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 <key>.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<Pick<SeedItemDefinition, 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'>> = {},
|
||
): 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<LootRollResult>`
|
||
- `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>): 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<LootRollResult> {
|
||
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<CombatRewardDto>`
|
||
- `CombatRewardService.loadRewards(combatId: string, manager?: EntityManager): Promise<CombatRewardDto | null>`
|
||
- `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<T extends { id: string }> {
|
||
constructor(
|
||
private readonly rows: T[],
|
||
private readonly prefix: string,
|
||
) {}
|
||
|
||
findOne(options: { where: Partial<T> }): Promise<T | null> {
|
||
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null);
|
||
}
|
||
|
||
findOneBy(where: Partial<T>): Promise<T | null> {
|
||
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null);
|
||
}
|
||
|
||
find(options: { where: Partial<T> }): Promise<T[]> {
|
||
return Promise.resolve(this.rows.filter((row) => this.matches(row, options.where)));
|
||
}
|
||
|
||
create(values: Partial<T>): T {
|
||
return { ...values } as T;
|
||
}
|
||
|
||
save(entity: T): Promise<T> {
|
||
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<T>): boolean {
|
||
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
|
||
}
|
||
}
|
||
|
||
function fakeManager(state: State): EntityManager {
|
||
return {
|
||
getRepository: <T extends { id: string }>(target: EntityTarget<T>) => {
|
||
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> = {}): 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> = {}): 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<DataSource, 'getRepository'>;
|
||
|
||
@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<CombatRewardDto> {
|
||
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<CombatRewardDto | null> {
|
||
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<CombatRewardDto> {
|
||
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<Character>;
|
||
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<Record<ItemRarity, string>>`
|
||
|
||
- [ ] **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<HTMLImageElement>('[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<Record<ItemRarity, string>> = {
|
||
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<RewardItemSummary>();
|
||
readonly quantity = input(1);
|
||
|
||
protected readonly rarityLabel = computed(() => RARITY_LABELS[this.item().rarity]);
|
||
protected readonly rarityModifier = computed(() => `item-card--${this.item().rarity.toLowerCase()}`);
|
||
}
|
||
```
|
||
|
||
```html
|
||
<!-- apps/web/src/app/shared/item-card/item-card.component.html -->
|
||
<article class="item-card" [class]="rarityModifier()">
|
||
<div class="item-card__frame">
|
||
<img class="item-card__icon" data-item-icon [src]="item().iconPath" [alt]="item().name" />
|
||
@if (quantity() > 1) {
|
||
<span class="item-card__quantity" data-item-quantity>×{{ quantity() }}</span>
|
||
}
|
||
</div>
|
||
<p class="item-card__name" data-item-name>{{ item().name }}</p>
|
||
<p class="item-card__rarity" data-item-rarity>{{ rarityLabel() }}</p>
|
||
</article>
|
||
```
|
||
|
||
```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<HTMLImageElement>('[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') {
|
||
<div class="outcome outcome--won" data-combat-result="WON">
|
||
<h2 class="outcome__title">Sieg</h2>
|
||
<p>{{ combat.monster.name }} wurde besiegt.</p>
|
||
|
||
@if (combat.rewards; as rewards) {
|
||
<section class="rewards" data-combat-rewards aria-label="Belohnungen">
|
||
<h3 class="rewards__title">Belohnungen</h3>
|
||
|
||
<dl class="rewards__currencies">
|
||
<div class="rewards__currency" data-reward-experience>
|
||
<dt>Erfahrung</dt>
|
||
<dd>+{{ rewards.experience }} XP</dd>
|
||
</div>
|
||
<div class="rewards__currency" data-reward-silver>
|
||
<dt>Silber</dt>
|
||
<dd>+{{ rewards.silver }}</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
@if (rewards.items.length) {
|
||
<h3 class="rewards__title">Beute</h3>
|
||
<ul class="rewards__loot">
|
||
@for (reward of rewards.items; track reward.characterItemId) {
|
||
<li>
|
||
<app-item-card [item]="reward.item" [quantity]="reward.quantity" />
|
||
</li>
|
||
}
|
||
</ul>
|
||
} @else {
|
||
<p class="outcome__hint" data-reward-empty>Keine besondere Beute gefunden.</p>
|
||
}
|
||
</section>
|
||
}
|
||
|
||
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
|
||
Zur Jagd
|
||
</button>
|
||
</div>
|
||
} @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<void>` — 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<void> {
|
||
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
|
||
<dl class="top-bar__resources">
|
||
<div class="top-bar__resource" data-top-bar-silver>
|
||
<dt>Silber</dt>
|
||
<dd>{{ character.silver }}</dd>
|
||
</div>
|
||
<div class="top-bar__resource" data-top-bar-experience>
|
||
<dt>XP</dt>
|
||
<dd>{{ character.experience }}</dd>
|
||
</div>
|
||
</dl>
|
||
```
|
||
|
||
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<HTMLButtonElement>('[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<typeof vi.fn> };
|
||
```
|
||
|
||
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 = '<the combat id from the URL>'"
|
||
```
|
||
|
||
Expected: exactly `1`.
|
||
|
||
- [ ] **Step 6: Verify a repeated reward request cannot farm**
|
||
|
||
```bash
|
||
curl -s -X POST http://localhost:3000/api/combats/<won-combat-id>/actions \
|
||
-H 'Content-Type: application/json' -d '{"action":"ATTACK"}'
|
||
curl -s http://localhost:3000/api/combats/<won-combat-id>
|
||
```
|
||
|
||
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/<won-combat-id>` 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 = '<id>'` = exactly `1`.
|
||
|
||
**Step 6 — repeated reward request cannot farm:** `POST /api/combats/<id>/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 <temp-file-pointing-at-that-port>` 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`.
|
||
|
||
|