139 KiB
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 globalValidationPipe({ whitelist: true, forbidNonWhitelisted: true })inapps/api/src/app.config.tsalready rejects unknown body fields with 400. - Only
status = WONcombats grant rewards (spec §6).ACTIVEandLOSTmust 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.performActionalready opens. A mid-way failure must leave no XP, no silver, no items, and no reward record. CombatEngineServicemust 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
RandomSourceinjected via theRANDOM_SOURCEtoken (spec §19). No new random abstraction, no directMath.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. AminQuantity === maxQuantityentry consumes no quantity random. - Loot probabilities are data-driven — they live in
loot_table_entriesrows, 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 viaautoLoadEntities: 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_IDfromapps/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 buildand 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.
- 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
ItemDefinitionis seeded (it costs nothing and Slice 0.5+ will want it); only theloot_table_entryis deferred. sellPriceis0for 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.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.- No level-up. Spec §10: the project has no level-up implementation to reuse, so XP is persisted and nothing more.
CombatRewardDtostays shape-compatible with future level information. - 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 fromhunting/; the one random abstractionapps/api/src/shared/roll-range.ts+ spec — pure inclusive integer rollapps/api/src/items/item-type.enum.ts—WEAPON | ARMOR | MATERIAL | CONSUMABLEapps/api/src/items/equipment-slot.enum.ts—WEAPON | HEAD | CHEST | HANDS | LEGS | FEET | AMULETapps/api/src/items/item-rarity.enum.ts—COMMON | RARE | EPICapps/api/src/items/entities/item-definition.entity.tsapps/api/src/items/entities/character-item.entity.tsapps/api/src/loot/entities/loot-table.entity.tsapps/api/src/loot/entities/loot-table-entry.entity.tsapps/api/src/loot/loot.service.ts+ spec — pure rolls, no persistenceapps/api/src/loot/loot.module.tsapps/api/src/rewards/entities/combat-reward.entity.tsapps/api/src/rewards/entities/combat-reward-item.entity.tsapps/api/src/rewards/rewards.errors.tsapps/api/src/rewards/combat-reward.service.ts+ spec — grant + readapps/api/src/rewards/rewards.module.tsapps/api/src/database/migrations/1788600000000-CreateLootAndRewards.tsapps/api/src/database/migrations/loot-and-rewards.migration.spec.tsapps/api/src/database/seeds/item.constants.ts— stable item/loot-table UUIDs and keysapps/api/src/database/seeds/item-content.ts— the Tier-1 item + loot-table content tablestools/extract-item-icons.ps1— sheet → 256×256 icons
Backend — modified:
apps/api/src/hunting/random-source.ts— deleted, imports repointedapps/api/src/hunting/hunting.module.ts,hunting.service.ts,hunting.service.spec.ts— import fromshared/apps/api/src/characters/entities/character.entity.ts— addsilverapps/api/src/characters/characters.service.ts+ spec — exposesilverapps/api/src/monsters/entities/monster-definition.entity.ts— add nullablelootTableId+ relationapps/api/src/combat/combat.service.ts+ spec — grant onWON, includerewardsin every DTOapps/api/src/combat/combat.module.ts— importRewardsModuleapps/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+ specapps/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.silverapps/web/src/app/features/combat/combat-page/combat-page.component.ts+.html+.scss+ spec — reward summaryapps/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(): numberuniform in[0, 1)),RANDOM_SOURCE(injection token symbol),systemRandomSource— all fromapps/api/src/shared/random-source.ts.rollInclusive(random: RandomSource, min: number, max: number): numberfromapps/api/src/shared/roll-range.ts. -
Step 1: Write the failing test
// 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:
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:
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:
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
In apps/api/src/hunting/hunting.service.ts, replace lines 19–20:
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:
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
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:
ItemTypeenum:WEAPON | ARMOR | MATERIAL | CONSUMABLEEquipmentSlotenum:WEAPON | HEAD | CHEST | HANDS | LEGS | FEET | AMULETItemRarityenum:COMMON | RARE | EPICItemDefinition—id, key, name, description, type, equipmentSlot: EquipmentSlot | null, rarity, tier, requiredLevel, weaponDamage, bonusHp, bonusAttack, bonusArmor, sellPrice, iconPath, createdAt, updatedAtCharacterItem—id, characterId, itemDefinitionId, quantity, createdAt, updatedAtLootTable—id, key, name, createdAt, updatedAtLootTableEntry—id, lootTableId, itemDefinitionId, position, dropChance: string, minQuantity, maxQuantity, enabled, createdAt, updatedAtCombatReward—id, combatId, characterId, experienceGranted, silverGranted, createdAtCombatRewardItem—id, combatRewardId, characterItemId, itemDefinitionId, quantity, createdAtCharacter.silver: numberMonsterDefinition.lootTableId: string | null
dropChanceis typedstring, notnumber. TypeORM returns PostgreSQLnumericas a string (the existingLocationConnection.ambushChancedoes the same — see the'0.0500'values in the seed).LootServiceparses it withNumber(...)exactly once.
- Step 1: Write the failing schema test
// 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
// apps/api/src/items/item-type.enum.ts
export enum ItemType {
WEAPON = 'WEAPON',
ARMOR = 'ARMOR',
MATERIAL = 'MATERIAL',
CONSUMABLE = 'CONSUMABLE',
}
// 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',
}
// 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
// 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;
}
// 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
// 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;
}
// 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
// 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;
}
// 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):
@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:
import { LootTable } from '../../loot/entities/loot-table.entity';
and add after the artworkPath column:
@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
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; columnscharacters.silver,monster_definitions.loot_table_id; enum typesitem_type_enum,equipment_slot_enum,item_rarity_enum. -
Step 1: Write the migration
// 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):
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
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, 1082withy = 8andy = 364; the artwork is the 300×300 square atx + 25. - Row 3 has three centred cells at
x = 191, 549, 907,y = 720; the artwork is the 262×262 square atx + 44.
Files:
- Create:
tools/extract-item-icons.ps1 - Create:
apps/web/public/images/items/*.png(12 files)
Interfaces:
-
Consumes: nothing.
-
Produces:
/images/items/<key>.pngfor 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
# 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.pngreads 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
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,ItemRarityfrom Task 2. -
Produces:
ITEM_IDS(record of key → uuid),ASH_RAT_LOOT_TABLE_ID,ROAD_BANDIT_LOOT_TABLE_IDfromitem.constants.ts;ITEM_DEFINITIONS,LOOT_TABLES,LOOT_TABLE_ENTRIESfromitem-content.ts;seedVisibleVerticalSlicenow also seeds all of it and setsmonster_definitions.loot_table_id. -
Step 1: Write the stable id constants
// 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
// 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:
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:
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:
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:
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:
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:
// 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:
{
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:
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.
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
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— exportsLootService
-
Step 1: Write the failing test
// 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
// 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 };
}
}
// 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
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()fromrewards.errors.tsRewardsModule— exportsCombatRewardService
The DTO never exposes drop chance, roll results, or loot-table ids (spec §26).
- Step 1: Write the errors
// 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
// 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
// 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,
};
}
}
// 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
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 | nullon every combat response —startCombat,getCombat,getActiveCombat, andperformAction. -
Step 1: Write the failing tests
Add to apps/api/src/combat/combat.service.spec.ts. Add the import:
import { CombatRewardService } from '../rewards/combat-reward.service';
Add this fake near fakeTravelService:
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:
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:
import { CombatRewardService } from '../rewards/combat-reward.service';
import type { CombatRewardDto } from '../rewards/combat-reward.service';
Add rewards to CombatDto:
export interface CombatDto {
id: string;
status: CombatStatus;
round: number;
player: CombatPlayerDto;
monster: CombatMonsterDto;
events: CombatEventDto[];
rewards: CombatRewardDto | null;
}
Add the constructor parameter:
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:
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— addthis.combatRewards.loadRewards(combat.id)to the existingPromise.alland pass the result:
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 passnull:
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:
// 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:
import { RewardsModule } from '../rewards/rewards.module';
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
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/meresponse gainssilver: 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:
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:
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
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: numberItemCardComponent— selectorapp-item-card, inputsitem: RewardItemSummary(required) andquantity: number(default1)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:
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:
rewards: CombatReward | null;
- Step 2: Write the failing test
// 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
// 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()}`);
}
<!-- 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>
/* 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
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:
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:
import { ItemCardComponent } from '../../../shared/item-card/item-card.component';
and add to the @Component decorator (it currently has no imports array — add one):
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:
@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:
/* 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:
.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
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-readsGET /characters/meand 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:
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:
/**
* 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):
<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:
.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:
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:
{ provide: WorldStore, useValue: worldStore },
with, alongside the combatStore declaration:
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
and inside setup():
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:
import { WorldStore } from '../../world/world.store';
private readonly worldStore = inject(WorldStore);
Then in attack(), right after const after = this.combatStore.combat(); and its null guard, add:
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
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
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
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.
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
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
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
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:
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.
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-existingcombat-page.component.scssbudget 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_itemsverified via SQL:bandit-blade,bandit-hood,raider-gloves,worn-short-sword, andash-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.