diff --git a/apps/api/src/characters/characters.service.spec.ts b/apps/api/src/characters/characters.service.spec.ts index 0f27a1f..63940ed 100644 --- a/apps/api/src/characters/characters.service.spec.ts +++ b/apps/api/src/characters/characters.service.spec.ts @@ -13,6 +13,7 @@ describe('CharactersService', () => { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, currentHp: 100, baseHp: 100, baseAttack: 6, @@ -30,6 +31,7 @@ describe('CharactersService', () => { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, currentHp: 100, maxHp: 100, attack: 6, @@ -45,6 +47,31 @@ describe('CharactersService', () => { }); }); + it('exposes the persisted silver so the HUD never has to guess', async () => { + const repository = { + findOne: jest.fn().mockResolvedValue({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 24, + silver: 18, + currentHp: 100, + baseHp: 100, + baseAttack: 6, + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'Südtor von Graufurt', + }, + }), + } as unknown as Repository; + const service = new CharactersService(repository); + + await expect(service.getDemoCharacter()).resolves.toEqual( + expect.objectContaining({ experience: 24, silver: 18 }), + ); + }); + it('reports a missing demo seed as not found', async () => { const repository = { findOne: jest.fn().mockResolvedValue(null), diff --git a/apps/api/src/characters/characters.service.ts b/apps/api/src/characters/characters.service.ts index 2d76ccc..ce5a099 100644 --- a/apps/api/src/characters/characters.service.ts +++ b/apps/api/src/characters/characters.service.ts @@ -26,6 +26,7 @@ export class CharactersService { name: character.name, level: character.level, experience: character.experience, + silver: character.silver, currentHp: character.currentHp, maxHp: character.baseHp, attack: character.baseAttack, diff --git a/apps/api/src/characters/entities/character.entity.ts b/apps/api/src/characters/entities/character.entity.ts index 19faf68..5318267 100644 --- a/apps/api/src/characters/entities/character.entity.ts +++ b/apps/api/src/characters/entities/character.entity.ts @@ -23,6 +23,9 @@ export class Character { @Column({ name: 'experience', type: 'integer' }) experience!: number; + @Column({ name: 'silver', type: 'integer' }) + silver!: number; + @Column({ name: 'base_hp', type: 'integer' }) baseHp!: number; diff --git a/apps/api/src/combat/combat.controller.spec.ts b/apps/api/src/combat/combat.controller.spec.ts index 578a32e..ab89048 100644 --- a/apps/api/src/combat/combat.controller.spec.ts +++ b/apps/api/src/combat/combat.controller.spec.ts @@ -34,7 +34,7 @@ describe('CombatController', () => { }); it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] }; + const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null }; getCombat.mockResolvedValue(combat); const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200); @@ -44,7 +44,7 @@ describe('CombatController', () => { }); it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [] }; + const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [], rewards: null }; getActiveCombat.mockResolvedValue(combat); const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200); @@ -65,7 +65,7 @@ describe('CombatController', () => { }); it('delegates POST /api/combats/:combatId/actions with only the action field', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] }; + const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [], rewards: null }; performAction.mockResolvedValue(combat); const response = await request(app.getHttpServer()) diff --git a/apps/api/src/combat/combat.module.ts b/apps/api/src/combat/combat.module.ts index 10ace1b..432e59e 100644 --- a/apps/api/src/combat/combat.module.ts +++ b/apps/api/src/combat/combat.module.ts @@ -5,6 +5,7 @@ import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { RewardsModule } from '../rewards/rewards.module'; import { TravelModule } from '../travel/travel.module'; import { CombatEngineService } from './combat-engine.service'; import { CombatController } from './combat.controller'; @@ -18,6 +19,7 @@ import { HuntEncounterAttackController } from './hunt-encounter-attack.controlle TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]), TravelModule, CharactersModule, + RewardsModule, ], controllers: [CombatController, HuntEncounterAttackController], providers: [CombatService, CombatEngineService], diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 3fc0898..200d80c 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -5,6 +5,7 @@ import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { HuntStatus } from '../hunting/hunt-status.enum'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { CombatRewardService } from '../rewards/combat-reward.service'; import { TravelService } from '../travel/travel.service'; import { CombatAction } from './combat-action.enum'; import { CombatEngineService } from './combat-engine.service'; @@ -173,6 +174,7 @@ function character(overrides: Partial = {}): Character { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, @@ -247,6 +249,20 @@ function fakeTravelService( } as unknown as TravelService; } +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; +} + function createService( options: { state?: FakeState; travelService?: TravelService } = {}, ) { @@ -260,6 +276,7 @@ function createService( travelService, combatEngine, characterCombatStats, + fakeRewardService(), ); return { dataSource, service, travelService }; } @@ -532,7 +549,10 @@ describe('CombatService', () => { }); it('rejects actions from a character who does not own the combat', async () => { - const { service, combatId } = await startedCombat(); + const state = createState({ + characters: [character(), character({ id: OTHER_CHARACTER_ID })], + }); + const { service, combatId } = await startedCombat(state); await expectCombatDomainError( service.performAction( @@ -663,4 +683,215 @@ describe('CombatService', () => { expect(reloaded.player.currentHp).toBe(0); }); }); + + describe('rewards', () => { + 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 must receive the transaction manager, not the data + // source: `expect.anything()` would pass even if the code handed over + // `this.dataSource`, so assert on the captured argument's identity. + const passedManager = (rewards.grantVictoryRewards as jest.Mock).mock + .calls[0][0]; + expect(passedManager).not.toBe(dataSource); + expect(rewards.grantVictoryRewards).toHaveBeenCalledWith( + passedManager, + 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({ + // Genuinely write XP/silver through the transaction's manager + // before failing, so the assertions below prove the rollback + // discards those writes rather than passing vacuously because + // nothing was ever written. + grantVictoryRewards: jest.fn().mockImplementation(async (manager: EntityManager) => { + const characters = manager.getRepository(Character); + const combatCharacter = await characters.findOneBy({ + id: CHARACTER_ID, + }); + if (combatCharacter) { + combatCharacter.experience += 8; + combatCharacter.silver += 6; + await characters.save(combatCharacter); + } + throw 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); + }); + }); }); diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 60eb4e0..d8f566d 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -6,6 +6,8 @@ import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { HuntStatus } from '../hunting/hunt-status.enum'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { CombatRewardService } from '../rewards/combat-reward.service'; +import type { CombatRewardDto } from '../rewards/combat-reward.service'; import { TravelService } from '../travel/travel.service'; import { TravelStatus } from '../travel/travel-status.enum'; import { CombatAction } from './combat-action.enum'; @@ -57,6 +59,7 @@ export interface CombatDto { player: CombatPlayerDto; monster: CombatMonsterDto; events: CombatEventDto[]; + rewards: CombatRewardDto | null; } @Injectable() @@ -66,6 +69,7 @@ export class CombatService { private readonly travelService: TravelService, private readonly combatEngine: CombatEngineService, private readonly characterCombatStats: CharacterCombatStatsService, + private readonly combatRewards: CombatRewardService, ) {} async startCombat( @@ -146,7 +150,7 @@ export class CombatService { encounter.consumedAt = new Date(); await encounters.save(encounter); - return this.toCombatDto(combat, character.name, monster, []); + return this.toCombatDto(combat, character.name, monster, [], null); }); } @@ -159,13 +163,14 @@ export class CombatService { throw combatNotFound(); } - const [character, monster, events] = await Promise.all([ + 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); + return this.toCombatDto(combat, character.name, monster, events, rewards); } async getActiveCombat(characterId: string): Promise { @@ -183,7 +188,7 @@ export class CombatService { this.loadEvents(combat.id), ]); - return this.toCombatDto(combat, character.name, monster, events); + return this.toCombatDto(combat, character.name, monster, events, null); } async performAction( @@ -192,9 +197,18 @@ export class CombatService { action: CombatAction, ): Promise { return this.dataSource.transaction(async (manager) => { + const characters = manager.getRepository(Character); const combats = manager.getRepository(Combat); const combatEvents = manager.getRepository(CombatEvent); + // Lock the character before the combat row here, matching the order + // startCombat already uses (character, then combat). grantVictoryRewards + // locks the character again later in this same transaction, which is a + // no-op re-lock — but locking it first here keeps both code paths + // consistent and avoids a lock-order inversion that could deadlock two + // concurrent requests against the same character. Do not reorder this. + await this.lockCharacter(characters, characterId); + const combat = await combats.findOne({ where: { id: combatId, characterId }, lock: { mode: 'pessimistic_write' }, @@ -236,6 +250,14 @@ export class CombatService { await combatEvents.save(entity); } + // 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, @@ -248,7 +270,7 @@ export class CombatService { this.loadEvents(combat.id, combatEvents), ]); - return this.toCombatDto(combat, character.name, monster, events); + return this.toCombatDto(combat, character.name, monster, events, rewards); }); } @@ -326,6 +348,7 @@ export class CombatService { playerName: string, monster: MonsterDefinition, events: CombatEvent[], + rewards: CombatRewardDto | null, ): CombatDto { return { id: combat.id, @@ -352,6 +375,7 @@ export class CombatService { target: event.target, amount: event.amount ?? undefined, })), + rewards, }; } } diff --git a/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts b/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts index a862876..3d94e0e 100644 --- a/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts +++ b/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts @@ -28,7 +28,7 @@ describe('HuntEncounterAttackController', () => { }); it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] }; + const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null }; startCombat.mockResolvedValue(combat); const response = await request(app.getHttpServer()) diff --git a/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts b/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts new file mode 100644 index 0000000..87db448 --- /dev/null +++ b/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts @@ -0,0 +1,174 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateLootAndRewards1788600000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Existing characters keep their progression; silver simply starts at 0. + await queryRunner.query( + 'ALTER TABLE "characters" ADD COLUMN "silver" integer NOT NULL DEFAULT 0', + ); + + await queryRunner.query( + "CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')", + ); + await queryRunner.query( + "CREATE TYPE \"equipment_slot_enum\" AS ENUM ('WEAPON', 'HEAD', 'CHEST', 'HANDS', 'LEGS', 'FEET', 'AMULET')", + ); + await queryRunner.query( + "CREATE TYPE \"item_rarity_enum\" AS ENUM ('COMMON', 'RARE', 'EPIC')", + ); + + await queryRunner.query(`CREATE TABLE "item_definitions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "description" text NOT NULL, + "type" "item_type_enum" NOT NULL, + "equipment_slot" "equipment_slot_enum", + "rarity" "item_rarity_enum" NOT NULL, + "tier" integer NOT NULL, + "required_level" integer NOT NULL, + "weapon_damage" integer NOT NULL DEFAULT 0, + "bonus_hp" integer NOT NULL DEFAULT 0, + "bonus_attack" integer NOT NULL DEFAULT 0, + "bonus_armor" integer NOT NULL DEFAULT 0, + "sell_price" integer NOT NULL DEFAULT 0, + "icon_path" character varying(255) NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_item_definitions" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_item_definitions_key" ON "item_definitions" ("key")', + ); + + await queryRunner.query(`CREATE TABLE "loot_tables" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "key" character varying(100) NOT NULL, + "name" character varying(150) NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_loot_tables" PRIMARY KEY ("id") + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_tables_key" ON "loot_tables" ("key")', + ); + + await queryRunner.query(`CREATE TABLE "loot_table_entries" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "loot_table_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "position" integer NOT NULL, + "drop_chance" numeric(5,4) NOT NULL, + "min_quantity" integer NOT NULL DEFAULT 1, + "max_quantity" integer NOT NULL DEFAULT 1, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_loot_table_entries" PRIMARY KEY ("id"), + CONSTRAINT "CHK_loot_table_entries_drop_chance" CHECK ("drop_chance" >= 0 AND "drop_chance" <= 1), + CONSTRAINT "CHK_loot_table_entries_quantity" CHECK ("min_quantity" >= 1 AND "max_quantity" >= "min_quantity"), + CONSTRAINT "FK_loot_table_entries_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_loot_table_entries_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_position" ON "loot_table_entries" ("loot_table_id", "position")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_item" ON "loot_table_entries" ("loot_table_id", "item_definition_id")', + ); + + await queryRunner.query( + 'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id" uuid', + ); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" ADD CONSTRAINT "FK_monster_definitions_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE RESTRICT ON UPDATE NO ACTION', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_monster_definitions_loot_table" ON "monster_definitions" ("loot_table_id")', + ); + + await queryRunner.query(`CREATE TABLE "character_items" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "character_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "quantity" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_items" PRIMARY KEY ("id"), + CONSTRAINT "CHK_character_items_quantity" CHECK ("quantity" >= 1), + CONSTRAINT "FK_character_items_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_character_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_character_items_character_item" ON "character_items" ("character_id", "item_definition_id")', + ); + + await queryRunner.query(`CREATE TABLE "combat_rewards" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "combat_id" uuid NOT NULL, + "character_id" uuid NOT NULL, + "experience_granted" integer NOT NULL, + "silver_granted" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_combat_rewards" PRIMARY KEY ("id"), + CONSTRAINT "FK_combat_rewards_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_rewards_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + // The database half of the "one reward per combat" invariant (spec §7). + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_combat_rewards_combat" ON "combat_rewards" ("combat_id")', + ); + await queryRunner.query( + 'CREATE INDEX "IDX_combat_rewards_character" ON "combat_rewards" ("character_id")', + ); + + await queryRunner.query(`CREATE TABLE "combat_reward_items" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "combat_reward_id" uuid NOT NULL, + "character_item_id" uuid NOT NULL, + "item_definition_id" uuid NOT NULL, + "quantity" integer NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_combat_reward_items" PRIMARY KEY ("id"), + CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1), + CONSTRAINT "FK_combat_reward_items_reward" FOREIGN KEY ("combat_reward_id") REFERENCES "combat_rewards"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_reward_items_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE RESTRICT ON UPDATE NO ACTION, + CONSTRAINT "FK_combat_reward_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION + )`); + await queryRunner.query( + 'CREATE INDEX "IDX_combat_reward_items_reward" ON "combat_reward_items" ("combat_reward_id")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item" ON "combat_reward_items" ("combat_reward_id", "item_definition_id")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward_item"'); + await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward"'); + await queryRunner.query('DROP TABLE "combat_reward_items"'); + await queryRunner.query('DROP INDEX "IDX_combat_rewards_character"'); + await queryRunner.query('DROP INDEX "IDX_combat_rewards_combat"'); + await queryRunner.query('DROP TABLE "combat_rewards"'); + await queryRunner.query('DROP INDEX "IDX_character_items_character_item"'); + await queryRunner.query('DROP TABLE "character_items"'); + await queryRunner.query('DROP INDEX "IDX_monster_definitions_loot_table"'); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" DROP CONSTRAINT "FK_monster_definitions_loot_table"', + ); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"', + ); + await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"'); + await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"'); + await queryRunner.query('DROP TABLE "loot_table_entries"'); + await queryRunner.query('DROP INDEX "IDX_loot_tables_key"'); + await queryRunner.query('DROP TABLE "loot_tables"'); + await queryRunner.query('DROP INDEX "IDX_item_definitions_key"'); + await queryRunner.query('DROP TABLE "item_definitions"'); + await queryRunner.query('DROP TYPE "item_rarity_enum"'); + await queryRunner.query('DROP TYPE "equipment_slot_enum"'); + await queryRunner.query('DROP TYPE "item_type_enum"'); + await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "silver"'); + } +} diff --git a/apps/api/src/database/migrations/1788600000000-CreateLocalLocationView.ts b/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts similarity index 98% rename from apps/api/src/database/migrations/1788600000000-CreateLocalLocationView.ts rename to apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts index 117f16b..4d234b5 100644 --- a/apps/api/src/database/migrations/1788600000000-CreateLocalLocationView.ts +++ b/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts @@ -7,7 +7,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; * alone — the map and hunt screens still render them. Backfill defaults keep * existing rows valid; the seed replaces them with authored content. */ -export class CreateLocalLocationView1788600000000 +export class CreateLocalLocationView1788700000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { diff --git a/apps/api/src/database/migrations/local-location-view.migration.spec.ts b/apps/api/src/database/migrations/local-location-view.migration.spec.ts index 4f3e4a7..0d3bafe 100644 --- a/apps/api/src/database/migrations/local-location-view.migration.spec.ts +++ b/apps/api/src/database/migrations/local-location-view.migration.spec.ts @@ -6,7 +6,7 @@ import { MonsterDefinition } from '../../monsters/entities/monster-definition.en import { LocationDefinition } from '../../world/entities/location-definition.entity'; const MIGRATION_SQL = readFileSync( - join(__dirname, '1788600000000-CreateLocalLocationView.ts'), + join(__dirname, '1788700000000-CreateLocalLocationView.ts'), 'utf8', ); diff --git a/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts b/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts new file mode 100644 index 0000000..35b369e --- /dev/null +++ b/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts @@ -0,0 +1,147 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage, QueryRunner } from 'typeorm'; +import { CreateLootAndRewards1788600000000 } from './1788600000000-CreateLootAndRewards'; +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); + }); + + it('emits the real SQL that enforces the schema invariants, not just entity decorators', async () => { + // synchronize: false means entity decorators never touch the real database - + // only the raw SQL emitted by the migration itself does. Assert on that SQL + // directly so deleting a constraint here would fail this test. + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateLootAndRewards1788600000000(); + + await migration.up(queryRunner); + + const upQueries = query.mock.calls.map(([sql]) => sql as string); + + expect(upQueries).toEqual( + expect.arrayContaining([ + // The database half of the "one reward per combat" invariant (spec §7, §37). + expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'), + expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'), + expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'), + expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "silver"'), + expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'), + ]), + ); + + const checkConstraints = upQueries.filter((sql) => sql.includes('CHECK (')); + expect(checkConstraints.length).toBeGreaterThan(0); + expect( + checkConstraints.some( + (sql) => + sql.includes('CHK_loot_table_entries_drop_chance') || + sql.includes('CHK_character_items_quantity'), + ), + ).toBe(true); + + await migration.down(queryRunner); + + const downQueries = query.mock.calls + .slice(upQueries.length) + .map(([sql]) => sql as string); + + // Proves down() is real and reverses the up() migration, not a no-op. + expect(downQueries).toEqual( + expect.arrayContaining([ + expect.stringContaining('DROP TABLE "combat_rewards"'), + expect.stringContaining('DROP TABLE "character_items"'), + 'ALTER TABLE "characters" DROP COLUMN "silver"', + ]), + ); + }); +}); diff --git a/apps/api/src/database/seeds/item-content.ts b/apps/api/src/database/seeds/item-content.ts new file mode 100644 index 0000000..8ac04ac --- /dev/null +++ b/apps/api/src/database/seeds/item-content.ts @@ -0,0 +1,216 @@ +import { EquipmentSlot } from '../../items/equipment-slot.enum'; +import { ItemRarity } from '../../items/item-rarity.enum'; +import { ItemType } from '../../items/item-type.enum'; +import { + ASH_RAT_LOOT_TABLE_ID, + ITEM_IDS, + ItemKey, + ROAD_BANDIT_LOOT_TABLE_ID, +} from './item.constants'; + +export interface SeedItemDefinition { + id: string; + key: ItemKey; + name: string; + description: string; + type: ItemType; + equipmentSlot: EquipmentSlot | null; + rarity: ItemRarity; + tier: number; + requiredLevel: number; + weaponDamage: number; + bonusHp: number; + bonusAttack: number; + bonusArmor: number; + sellPrice: number; + iconPath: string; +} + +function item( + key: ItemKey, + name: string, + description: string, + type: ItemType, + equipmentSlot: EquipmentSlot | null, + rarity: ItemRarity, + stats: Partial> = {}, +): SeedItemDefinition { + return { + id: ITEM_IDS[key], + key, + name, + description, + type, + equipmentSlot, + rarity, + tier: 1, + requiredLevel: 1, + weaponDamage: stats.weaponDamage ?? 0, + bonusHp: stats.bonusHp ?? 0, + bonusAttack: stats.bonusAttack ?? 0, + bonusArmor: stats.bonusArmor ?? 0, + // Always 0: no merchants exist in Slice 0.4, and the balancing doc's + // Grenzmarken table lists purchase prices, not sell prices. + sellPrice: 0, + iconPath: `/images/items/${key}.png`, + }; +} + +// Stats from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19. +export const ITEM_DEFINITIONS: SeedItemDefinition[] = [ + item( + 'worn-short-sword', + 'Abgenutztes Kurzschwert', + 'Die Klinge eines Rekruten, öfter geschliffen als geführt.', + ItemType.WEAPON, + EquipmentSlot.WEAPON, + ItemRarity.COMMON, + { weaponDamage: 8 }, + ), + item( + 'bandit-blade', + 'Räuberklinge', + 'Eine grob gezahnte Klinge, geschmiedet für schnelle Überfälle.', + ItemType.WEAPON, + EquipmentSlot.WEAPON, + ItemRarity.COMMON, + { weaponDamage: 11, bonusAttack: 1 }, + ), + item( + 'ash-blade', + 'Aschenklinge', + 'In der Glut der Aschenfelder gehärtet; die Schneide glimmt noch.', + ItemType.WEAPON, + EquipmentSlot.WEAPON, + ItemRarity.RARE, + { weaponDamage: 15, bonusAttack: 2 }, + ), + item( + 'bandit-hood', + 'Räuberhaube', + 'Vernarbtes Leder, das Gesicht und Absicht des Trägers verbirgt.', + ItemType.ARMOR, + EquipmentSlot.HEAD, + ItemRarity.COMMON, + { bonusArmor: 3, bonusHp: 5 }, + ), + item( + 'reinforced-leather-jacket', + 'Verstärkte Lederjacke', + 'Mit Eisenplatten benähtes Leder, schwer und verlässlich.', + ItemType.ARMOR, + EquipmentSlot.CHEST, + ItemRarity.RARE, + { bonusArmor: 7, bonusHp: 10 }, + ), + item( + 'raider-gloves', + 'Plündererhandschuhe', + 'Beschlagene Handschuhe, abgegriffen von fremdem Gut.', + ItemType.ARMOR, + EquipmentSlot.HANDS, + ItemRarity.COMMON, + { bonusArmor: 3, bonusAttack: 1 }, + ), + item( + 'guardsman-legs', + 'Wachmannsbeinkleid', + 'Beinzeug der Grenzwacht, an den Knien geflickt.', + ItemType.ARMOR, + EquipmentSlot.LEGS, + ItemRarity.RARE, + { bonusArmor: 5, bonusHp: 5 }, + ), + item( + 'ash-boots', + 'Aschenstiefel', + 'Stiefel, die durch glimmende Felder getragen wurden und blieben.', + ItemType.ARMOR, + EquipmentSlot.FEET, + ItemRarity.RARE, + { bonusArmor: 4, bonusHp: 5 }, + ), + item( + 'borderwatch-sigil', + 'Zeichen der Grenzwacht', + 'Das Wappen eines Turms, den es nicht mehr gibt.', + ItemType.ARMOR, + EquipmentSlot.AMULET, + ItemRarity.RARE, + { bonusAttack: 3, bonusHp: 10 }, + ), + item( + 'burned-captain-pendant', + 'Anhänger des verbrannten Hauptmanns', + 'Ein Schädel aus Schlacke, in dem die Glut nie erlosch.', + ItemType.ARMOR, + EquipmentSlot.AMULET, + ItemRarity.EPIC, + { bonusAttack: 3, bonusHp: 15, bonusArmor: 2 }, + ), + // Seeded as content only. Slice 0.4 implements no consumable use, and the + // Straßenräuber loot entry for it is deliberately deferred (spec §16). + item( + 'small-healing-potion', + 'Kleiner Heiltrank', + 'Ein bitterer Sud, der Wunden für einen Atemzug vergessen lässt.', + ItemType.CONSUMABLE, + null, + ItemRarity.COMMON, + ), + item( + 'ash-pelt', + 'Aschenfell', + 'Versengtes Fell, zäh wie Leder und grau von Ascheflug.', + ItemType.MATERIAL, + null, + ItemRarity.COMMON, + ), +]; + +export const LOOT_TABLES = [ + { id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Aschenratte Beute' }, + { id: ROAD_BANDIT_LOOT_TABLE_ID, key: 'road-bandit-loot', name: 'Straßenräuber Beute' }, +]; + +export interface SeedLootTableEntry { + lootTableId: string; + itemDefinitionId: string; + position: number; + dropChance: string; + minQuantity: number; + maxQuantity: number; + enabled: boolean; +} + +function entry( + lootTableId: string, + key: ItemKey, + position: number, + dropChance: string, +): SeedLootTableEntry { + return { + lootTableId, + itemDefinitionId: ITEM_IDS[key], + position, + dropChance, + minQuantity: 1, + maxQuantity: 1, + enabled: true, + }; +} + +/** + * Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28. + * Every entry is an independent roll (spec §17), rolled in `position` order. + * + * DEFERRED: the Straßenräuber table also lists 10 % Kleiner Heiltrank. It is + * omitted here because Slice 0.4 implements no consumables (spec §16). + */ +export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [ + entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'), + entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'), + entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'), + entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'), + entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'), +]; diff --git a/apps/api/src/database/seeds/item.constants.ts b/apps/api/src/database/seeds/item.constants.ts new file mode 100644 index 0000000..7a614ef --- /dev/null +++ b/apps/api/src/database/seeds/item.constants.ts @@ -0,0 +1,20 @@ +// 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'; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 41104ec..d907a9b 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -1,9 +1,13 @@ import { DataSource } from 'typeorm'; import { Character } from '../../characters/entities/character.entity'; +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; +import { LootTable } from '../../loot/entities/loot-table.entity'; import { LocationMonster } from '../../monsters/entities/location-monster.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { ASH_RAT_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID } from './item.constants'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; type Row = Record; @@ -69,24 +73,20 @@ function createDataSource( 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 === 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'); }), @@ -369,4 +369,84 @@ describe('seedVisibleVerticalSlice', () => { }), ]); }); + + 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 }), + ]), + ); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 0c8af6a..07448a7 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -1,11 +1,19 @@ import { DataSource } from 'typeorm'; import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants'; import { Character } from '../../characters/entities/character.entity'; +import { ItemDefinition } from '../../items/entities/item-definition.entity'; +import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; +import { LootTable } from '../../loot/entities/loot-table.entity'; import { EncounterType } from '../../monsters/entities/encounter-type.enum'; import { LocationMonster } from '../../monsters/entities/location-monster.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.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'; import { BURNED_ROAD_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT, @@ -27,6 +35,9 @@ export async function seedVisibleVerticalSlice( const characterRepository = dataSource.getRepository(Character); const monsterRepository = dataSource.getRepository(MonsterDefinition); const locationMonsterRepository = dataSource.getRepository(LocationMonster); + const itemRepository = dataSource.getRepository(ItemDefinition); + const lootTableRepository = dataSource.getRepository(LootTable); + const lootEntryRepository = dataSource.getRepository(LootTableEntry); const locations = [ { @@ -98,6 +109,15 @@ export async function seedVisibleVerticalSlice( ['fromLocationId', 'toLocationId'], ); + // 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', + ]); + const monsters = [ { id: ASH_RAT_MONSTER_ID, @@ -112,6 +132,7 @@ export async function seedVisibleVerticalSlice( silverMax: 7, artworkPath: '/images/monsters/ash-rat.png', iconPath: '/images/monsters/icons/ash-rat-128.png', + lootTableId: ASH_RAT_LOOT_TABLE_ID, }, { id: WILD_ROAD_DOG_MONSTER_ID, @@ -126,6 +147,9 @@ export async function seedVisibleVerticalSlice( silverMax: 9, artworkPath: '/images/monsters/wild-road-dog.png', iconPath: '/images/monsters/icons/wild-road-dog-128.png', + // Shares the beast table: both are scorched road animals that leave a + // pelt behind. A table of its own waits for content that differs. + lootTableId: ASH_RAT_LOOT_TABLE_ID, }, { id: ROAD_BANDIT_MONSTER_ID, @@ -140,6 +164,7 @@ export async function seedVisibleVerticalSlice( silverMax: 15, artworkPath: '/images/monsters/road-bandit.png', iconPath: '/images/monsters/icons/road-bandit-128.png', + lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, }, { id: CHARRED_LOOTER_MONSTER_ID, @@ -154,6 +179,8 @@ export async function seedVisibleVerticalSlice( silverMax: 19, artworkPath: '/images/monsters/charred-looter.png', iconPath: '/images/monsters/icons/charred-looter-128.png', + // Shares the raider table: same gear, taken from the same caravans. + lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, }, ]; @@ -204,6 +231,7 @@ export async function seedVisibleVerticalSlice( name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, diff --git a/apps/api/src/hunting/hunting.module.ts b/apps/api/src/hunting/hunting.module.ts index e963735..2ae552b 100644 --- a/apps/api/src/hunting/hunting.module.ts +++ b/apps/api/src/hunting/hunting.module.ts @@ -8,7 +8,7 @@ import { Hunt } from './entities/hunt.entity'; import { HuntEncounter } from './entities/hunt-encounter.entity'; import { HuntingController } from './hunting.controller'; import { HuntingService } from './hunting.service'; -import { RANDOM_SOURCE, systemRandomSource } from './random-source'; +import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; @Module({ imports: [ diff --git a/apps/api/src/hunting/hunting.service.spec.ts b/apps/api/src/hunting/hunting.service.spec.ts index 7d9783c..57f6762 100644 --- a/apps/api/src/hunting/hunting.service.spec.ts +++ b/apps/api/src/hunting/hunting.service.spec.ts @@ -12,7 +12,7 @@ import { HuntEncounter } from './entities/hunt-encounter.entity'; import { HuntStatus } from './hunt-status.enum'; import { HuntingDomainError } from './hunting.errors'; import { HuntingService } from './hunting.service'; -import type { RandomSource } from './random-source'; +import type { RandomSource } from '../shared/random-source'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001'; @@ -211,6 +211,7 @@ function character(currentLocation: LocationDefinition): Character { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, diff --git a/apps/api/src/hunting/hunting.service.ts b/apps/api/src/hunting/hunting.service.ts index 55e71b7..f70c82d 100644 --- a/apps/api/src/hunting/hunting.service.ts +++ b/apps/api/src/hunting/hunting.service.ts @@ -16,8 +16,8 @@ import { huntingNotAvailable, noHuntEncountersAvailable, } from './hunting.errors'; -import { RANDOM_SOURCE } from './random-source'; -import type { RandomSource } from './random-source'; +import { RANDOM_SOURCE } from '../shared/random-source'; +import type { RandomSource } from '../shared/random-source'; export interface MonsterSummary { key: string; diff --git a/apps/api/src/items/entities/character-item.entity.ts b/apps/api/src/items/entities/character-item.entity.ts new file mode 100644 index 0000000..41a536a --- /dev/null +++ b/apps/api/src/items/entities/character-item.entity.ts @@ -0,0 +1,51 @@ +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; +} diff --git a/apps/api/src/items/entities/item-definition.entity.ts b/apps/api/src/items/entities/item-definition.entity.ts new file mode 100644 index 0000000..c89adc8 --- /dev/null +++ b/apps/api/src/items/entities/item-definition.entity.ts @@ -0,0 +1,74 @@ +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; +} diff --git a/apps/api/src/items/equipment-slot.enum.ts b/apps/api/src/items/equipment-slot.enum.ts new file mode 100644 index 0000000..c5596db --- /dev/null +++ b/apps/api/src/items/equipment-slot.enum.ts @@ -0,0 +1,10 @@ +// 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', +} diff --git a/apps/api/src/items/item-rarity.enum.ts b/apps/api/src/items/item-rarity.enum.ts new file mode 100644 index 0000000..877f9ee --- /dev/null +++ b/apps/api/src/items/item-rarity.enum.ts @@ -0,0 +1,7 @@ +// 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', +} diff --git a/apps/api/src/items/item-type.enum.ts b/apps/api/src/items/item-type.enum.ts new file mode 100644 index 0000000..9050b87 --- /dev/null +++ b/apps/api/src/items/item-type.enum.ts @@ -0,0 +1,6 @@ +export enum ItemType { + WEAPON = 'WEAPON', + ARMOR = 'ARMOR', + MATERIAL = 'MATERIAL', + CONSUMABLE = 'CONSUMABLE', +} diff --git a/apps/api/src/loot/entities/loot-table-entry.entity.ts b/apps/api/src/loot/entities/loot-table-entry.entity.ts new file mode 100644 index 0000000..91b3c5f --- /dev/null +++ b/apps/api/src/loot/entities/loot-table-entry.entity.ts @@ -0,0 +1,63 @@ +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; +} diff --git a/apps/api/src/loot/entities/loot-table.entity.ts b/apps/api/src/loot/entities/loot-table.entity.ts new file mode 100644 index 0000000..82dfcae --- /dev/null +++ b/apps/api/src/loot/entities/loot-table.entity.ts @@ -0,0 +1,27 @@ +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; +} diff --git a/apps/api/src/loot/loot.module.ts b/apps/api/src/loot/loot.module.ts new file mode 100644 index 0000000..c2973c8 --- /dev/null +++ b/apps/api/src/loot/loot.module.ts @@ -0,0 +1,13 @@ +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 {} diff --git a/apps/api/src/loot/loot.service.spec.ts b/apps/api/src/loot/loot.service.spec.ts new file mode 100644 index 0000000..e6b315e --- /dev/null +++ b/apps/api/src/loot/loot.service.spec.ts @@ -0,0 +1,128 @@ +import { DataSource } from 'typeorm'; +import type { RandomSource } from '../shared/random-source'; +import { LootTableEntry } from './entities/loot-table-entry.entity'; +import { LootService } from './loot.service'; + +const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001'; +const ASH_PELT = '50000000-0000-4000-8000-00000000000c'; +const WORN_SHORT_SWORD = '50000000-0000-4000-8000-000000000001'; + +function entry(overrides: Partial): LootTableEntry { + return { + id: 'entry-1', + lootTableId: ASH_RAT_TABLE, + itemDefinitionId: ASH_PELT, + position: 1, + dropChance: '0.6000', + minQuantity: 1, + maxQuantity: 1, + enabled: true, + createdAt: new Date('2026-08-19T09:00:00.000Z'), + updatedAt: new Date('2026-08-19T09:00:00.000Z'), + ...overrides, + } 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: [] }); + }); +}); diff --git a/apps/api/src/loot/loot.service.ts b/apps/api/src/loot/loot.service.ts new file mode 100644 index 0000000..2fcbf9b --- /dev/null +++ b/apps/api/src/loot/loot.service.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { RANDOM_SOURCE } from '../shared/random-source'; +import type { RandomSource } from '../shared/random-source'; +import { rollInclusive } from '../shared/roll-range'; +import { LootTableEntry } from './entities/loot-table-entry.entity'; + +export interface LootRollItem { + itemDefinitionId: string; + quantity: number; +} + +export interface LootRollResult { + items: LootRollItem[]; +} + +@Injectable() +export class LootService { + constructor( + private readonly dataSource: DataSource, + @Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource, + ) {} + + /** + * Rolls a loot table without persisting anything (spec §20). + * + * Every enabled entry is one independent roll in `position` order, so a + * single combat may yield nothing, one item, or several (spec §17). The + * quantity roll is skipped entirely when `minQuantity === maxQuantity`, + * which keeps the random sequence stable for the seeded content. + */ + async rollLoot( + lootTableId: string | null, + manager?: EntityManager, + ): Promise { + if (!lootTableId) { + return { items: [] }; + } + + const entries = await ( + manager?.getRepository(LootTableEntry) ?? + this.dataSource.getRepository(LootTableEntry) + ).find({ + where: { lootTableId, enabled: true }, + order: { position: 'ASC' }, + }); + + const items: LootRollItem[] = []; + for (const entry of entries) { + if (this.randomSource.next() >= Number(entry.dropChance)) { + continue; + } + + items.push({ + itemDefinitionId: entry.itemDefinitionId, + quantity: rollInclusive( + this.randomSource, + entry.minQuantity, + entry.maxQuantity, + ), + }); + } + + return { items }; + } +} diff --git a/apps/api/src/monsters/entities/monster-definition.entity.ts b/apps/api/src/monsters/entities/monster-definition.entity.ts index 7f85638..efc79aa 100644 --- a/apps/api/src/monsters/entities/monster-definition.entity.ts +++ b/apps/api/src/monsters/entities/monster-definition.entity.ts @@ -3,9 +3,12 @@ import { CreateDateColumn, Entity, Index, + JoinColumn, + ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { LootTable } from '../../loot/entities/loot-table.entity'; @Entity({ name: 'monster_definitions' }) @Index('IDX_monster_definitions_key', ['key'], { unique: true }) @@ -49,9 +52,16 @@ export class MonsterDefinition { @Column({ name: 'icon_path', type: 'varchar', length: 255 }) iconPath!: string; + @Column({ name: 'loot_table_id', type: 'uuid', nullable: true }) + lootTableId!: string | null; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt!: Date; + + @ManyToOne(() => LootTable, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'loot_table_id' }) + lootTable!: LootTable | null; } diff --git a/apps/api/src/rewards/combat-reward.service.spec.ts b/apps/api/src/rewards/combat-reward.service.spec.ts new file mode 100644 index 0000000..a2c57df --- /dev/null +++ b/apps/api/src/rewards/combat-reward.service.spec.ts @@ -0,0 +1,445 @@ +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'; +const BANDIT_HOOD = '50000000-0000-4000-8000-000000000001'; + +interface State { + characters: Character[]; + monsters: MonsterDefinition[]; + itemDefinitions: ItemDefinition[]; + characterItems: CharacterItem[]; + combatRewards: CombatReward[]; + combatRewardItems: CombatRewardItem[]; +} + +class FakeRepository { + constructor( + private readonly rows: T[], + private readonly prefix: string, + ) {} + + findOne(options: { where: Partial }): Promise { + return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); + } + + findOneBy(where: Partial): Promise { + return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null); + } + + find(options: { + where: Partial; + order?: Partial>; + }): Promise { + const matched = this.rows.filter((row) => this.matches(row, options.where)); + if (!options.order) { + return Promise.resolve(matched); + } + + // Mirrors TypeORM's `order` clause so tests can prove ordering comes from + // the query, not from insertion order happening to line up. + const [key, direction] = Object.entries(options.order)[0] as [keyof T, 'ASC' | 'DESC']; + const sorted = [...matched].sort((a, b) => { + const left = a[key]; + const right = b[key]; + const comparison = left < right ? -1 : left > right ? 1 : 0; + return direction === 'DESC' ? -comparison : comparison; + }); + return Promise.resolve(sorted); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + if (!entity.id) { + entity.id = `${this.prefix}-${this.rows.length + 1}`; + } + const index = this.rows.findIndex((row) => row.id === entity.id); + if (index === -1) { + this.rows.push(entity); + } else { + this.rows[index] = entity; + } + return Promise.resolve(entity); + } + + private matches(row: T, where: Partial): boolean { + return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + } +} + +function fakeManager(state: State): EntityManager { + return { + getRepository: (target: EntityTarget) => { + if (target === Character) return new FakeRepository(state.characters, 'character') as never; + if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never; + if (target === ItemDefinition) + return new FakeRepository(state.itemDefinitions, 'definition') as never; + if (target === CharacterItem) + return new FakeRepository(state.characterItems, 'character-item') as never; + if (target === CombatReward) + return new FakeRepository(state.combatRewards, 'reward') as never; + if (target === CombatRewardItem) + return new FakeRepository(state.combatRewardItems, 'reward-item') as never; + throw new Error('Unsupported repository'); + }, + } as unknown as EntityManager; +} + +function combat(overrides: Partial = {}): Combat { + return { + id: COMBAT_ID, + characterId: CHARACTER_ID, + monsterDefinitionId: ASH_RAT_ID, + status: CombatStatus.WON, + round: 4, + ...overrides, + } as Combat; +} + +function createState(overrides: Partial = {}): State { + return { + characters: [{ id: CHARACTER_ID, experience: 12, silver: 3 } as Character], + monsters: [ + { + id: ASH_RAT_ID, + key: 'ash-rat', + experienceReward: 8, + silverMin: 4, + silverMax: 7, + lootTableId: ASH_RAT_TABLE, + } as MonsterDefinition, + { + id: ROAD_BANDIT_ID, + key: 'road-bandit', + experienceReward: 16, + silverMin: 9, + silverMax: 15, + lootTableId: ROAD_BANDIT_TABLE, + } as MonsterDefinition, + ], + itemDefinitions: [ + { + id: BANDIT_BLADE, + key: 'bandit-blade', + name: 'Räuberklinge', + type: ItemType.WEAPON, + rarity: ItemRarity.COMMON, + iconPath: '/images/items/bandit-blade.png', + } as ItemDefinition, + ], + characterItems: [], + combatRewards: [], + combatRewardItems: [], + ...overrides, + }; +} + +function fakeDataSource(state: State): EntityManager { + // `loadRewards`'s no-manager branch only calls `getRepository`, which + // `fakeManager` already implements identically for `DataSource`; reusing + // it (rather than duplicating the repository-resolution switch) keeps this + // fake backed by the exact same `state` rows as the transactional path. + return fakeManager(state); +} + +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); + }); + + it('reads a persisted reward through the injected DataSource when no manager is passed', async () => { + const state = createState(); + const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); + const dataSource = fakeDataSource(state); + const subject = new CombatRewardService(dataSource as never, loot, fixedRandom(0)); + const manager = fakeManager(state); + + const granted = await subject.grantVictoryRewards(manager, combat()); + + // No manager argument: this is the non-transactional read the next + // task uses to render a reward screen. + const replayed = await subject.loadRewards(COMBAT_ID); + + expect(replayed).toEqual(granted); + }); + }); + + 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); + // Every rolled item definition is resolved before any mutation, so a + // missing one must leave no reward row and no character grant behind. + expect(state.combatRewards).toHaveLength(0); + expect(state.characters[0].experience).toBe(12); + expect(state.characters[0].silver).toBe(3); + }); + }); + + describe('item order', () => { + it('orders items by itemDefinitionId in the immediate grant and the replayed reward, regardless of roll order', async () => { + const state = createState({ + itemDefinitions: [ + { + id: BANDIT_BLADE, + key: 'bandit-blade', + name: 'Räuberklinge', + type: ItemType.WEAPON, + rarity: ItemRarity.COMMON, + iconPath: '/images/items/bandit-blade.png', + } as ItemDefinition, + { + id: BANDIT_HOOD, + key: 'bandit-hood', + name: 'Räuberkapuze', + type: ItemType.ARMOR, + rarity: ItemRarity.COMMON, + iconPath: '/images/items/bandit-hood.png', + } as ItemDefinition, + ], + }); + const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID }); + // Roll order is blade-then-hood, but BANDIT_HOOD's id sorts before + // BANDIT_BLADE's, so this only passes if both response paths sort by + // itemDefinitionId rather than returning rows in roll/insertion order. + const loot = fakeLoot( + { itemDefinitionId: BANDIT_BLADE, quantity: 1 }, + { itemDefinitionId: BANDIT_HOOD, quantity: 1 }, + ); + const subject = service(state, loot, fixedRandom(0)); + const manager = fakeManager(state); + + const granted = await subject.grantVictoryRewards(manager, banditCombat); + const replayed = await subject.loadRewards(banditCombat.id, manager); + + const expectedKeys = ['bandit-hood', 'bandit-blade']; + expect(granted.items.map((item) => item.item.key)).toEqual(expectedKeys); + expect(replayed?.items.map((item) => item.item.key)).toEqual(expectedKeys); + expect(replayed).toEqual(granted); + }); + }); +}); diff --git a/apps/api/src/rewards/combat-reward.service.ts b/apps/api/src/rewards/combat-reward.service.ts new file mode 100644 index 0000000..5cd8147 --- /dev/null +++ b/apps/api/src/rewards/combat-reward.service.ts @@ -0,0 +1,244 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CombatStatus } from '../combat/combat-status.enum'; +import { Combat } from '../combat/entities/combat.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { ItemRarity } from '../items/item-rarity.enum'; +import { LootService } from '../loot/loot.service'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { RANDOM_SOURCE } from '../shared/random-source'; +import type { RandomSource } from '../shared/random-source'; +import { rollInclusive } from '../shared/roll-range'; +import { CombatReward } from './entities/combat-reward.entity'; +import { CombatRewardItem } from './entities/combat-reward-item.entity'; +import { combatNotWon, rewardStateInvalid } from './rewards.errors'; + +export interface CombatRewardItemDto { + characterItemId: string; + item: { + key: string; + name: string; + rarity: ItemRarity; + iconPath: string; + }; + quantity: number; +} + +export interface CombatRewardDto { + experience: number; + silver: number; + items: CombatRewardItemDto[]; +} + +// Both DataSource and EntityManager expose this; naming it keeps the read path +// usable inside and outside a transaction without a union type. +type RepositoryScope = Pick; + +@Injectable() +export class CombatRewardService { + constructor( + private readonly dataSource: DataSource, + private readonly lootService: LootService, + @Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource, + ) {} + + /** + * Grants a won combat's rewards exactly once (spec §7, §21). + * + * Runs inside the caller's transaction — `CombatService.performAction` + * already holds a pessimistic write lock on the combat row — so either + * everything below commits or nothing does. + * + * Roll order is fixed: silver first, then the loot table in `position` + * order. Tests depend on it. + */ + async grantVictoryRewards( + manager: EntityManager, + combat: Combat, + ): Promise { + if (combat.status !== CombatStatus.WON) { + throw combatNotWon(); + } + + const rewards = manager.getRepository(CombatReward); + const existing = await rewards.findOne({ where: { combatId: combat.id } }); + if (existing) { + // Already rewarded: replay rather than roll again. + return this.toDto(manager, existing); + } + + const monster = await manager + .getRepository(MonsterDefinition) + .findOneBy({ id: combat.monsterDefinitionId }); + if (!monster) { + throw rewardStateInvalid(); + } + + const experience = monster.experienceReward; + const silver = rollInclusive( + this.randomSource, + monster.silverMin, + monster.silverMax, + ); + const roll = await this.lootService.rollLoot(monster.lootTableId, manager); + + // Resolve every rolled item definition up front, before any mutation, so + // a missing definition throws `rewardStateInvalid()` before the + // character's XP/silver are touched or a `CombatReward` row is created. + // This keeps a failed grant from leaving partial writes behind. + const definitions = manager.getRepository(ItemDefinition); + const resolvedDefinitions = new Map(); + for (const rolled of roll.items) { + if (resolvedDefinitions.has(rolled.itemDefinitionId)) { + continue; + } + const definition = await definitions.findOneBy({ + id: rolled.itemDefinitionId, + }); + if (!definition) { + throw rewardStateInvalid(); + } + resolvedDefinitions.set(rolled.itemDefinitionId, definition); + } + + 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 characterItems = manager.getRepository(CharacterItem); + const rewardItems = manager.getRepository(CombatRewardItem); + const granted: Array<{ itemDefinitionId: string; dto: CombatRewardItemDto }> = []; + + for (const rolled of roll.items) { + const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!; + + 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, + }), + ); + + granted.push({ + itemDefinitionId: rolled.itemDefinitionId, + dto: this.toItemDto(characterItem.id, definition, rolled.quantity), + }); + } + + // The immediate response and a later `loadRewards` replay must agree on + // item order; both sort on the same stable key (itemDefinitionId, which + // `toDto`'s query also orders by) rather than roll order. + granted.sort((a, b) => a.itemDefinitionId.localeCompare(b.itemDefinitionId)); + const items = granted.map((entry) => entry.dto); + + return { experience, silver, items }; + } + + /** Reads a persisted reward so a refresh replays it (spec §25, §48). */ + async loadRewards( + combatId: string, + manager?: EntityManager, + ): Promise { + const scope: RepositoryScope = manager ?? this.dataSource; + const reward = await scope + .getRepository(CombatReward) + .findOne({ where: { combatId } }); + + return reward ? this.toDto(scope, reward) : null; + } + + private async toDto( + scope: RepositoryScope, + reward: CombatReward, + ): Promise { + // Ordered by itemDefinitionId to agree with the sort `grantVictoryRewards` + // applies to its own response — the immediate grant and a later replay + // must list items identically. + const rewardItems = await scope + .getRepository(CombatRewardItem) + .find({ + where: { combatRewardId: reward.id }, + order: { itemDefinitionId: 'ASC' }, + }); + 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, + }; + } +} diff --git a/apps/api/src/rewards/entities/combat-reward-item.entity.ts b/apps/api/src/rewards/entities/combat-reward-item.entity.ts new file mode 100644 index 0000000..ec862c8 --- /dev/null +++ b/apps/api/src/rewards/entities/combat-reward-item.entity.ts @@ -0,0 +1,56 @@ +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; +} diff --git a/apps/api/src/rewards/entities/combat-reward.entity.ts b/apps/api/src/rewards/entities/combat-reward.entity.ts new file mode 100644 index 0000000..ff467c0 --- /dev/null +++ b/apps/api/src/rewards/entities/combat-reward.entity.ts @@ -0,0 +1,48 @@ +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; +} diff --git a/apps/api/src/rewards/rewards.errors.ts b/apps/api/src/rewards/rewards.errors.ts new file mode 100644 index 0000000..7594221 --- /dev/null +++ b/apps/api/src/rewards/rewards.errors.ts @@ -0,0 +1,29 @@ +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.', + ); +} diff --git a/apps/api/src/rewards/rewards.module.ts b/apps/api/src/rewards/rewards.module.ts new file mode 100644 index 0000000..3b2419e --- /dev/null +++ b/apps/api/src/rewards/rewards.module.ts @@ -0,0 +1,31 @@ +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 {} diff --git a/apps/api/src/hunting/random-source.ts b/apps/api/src/shared/random-source.ts similarity index 100% rename from apps/api/src/hunting/random-source.ts rename to apps/api/src/shared/random-source.ts diff --git a/apps/api/src/shared/roll-range.spec.ts b/apps/api/src/shared/roll-range.spec.ts new file mode 100644 index 0000000..17389cf --- /dev/null +++ b/apps/api/src/shared/roll-range.spec.ts @@ -0,0 +1,28 @@ +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); + }); +}); diff --git a/apps/api/src/shared/roll-range.ts b/apps/api/src/shared/roll-range.ts new file mode 100644 index 0000000..1c2c97a --- /dev/null +++ b/apps/api/src/shared/roll-range.ts @@ -0,0 +1,19 @@ +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))); +} diff --git a/apps/api/src/travel/travel.service.spec.ts b/apps/api/src/travel/travel.service.spec.ts index d6996f0..dbfa6d1 100644 --- a/apps/api/src/travel/travel.service.spec.ts +++ b/apps/api/src/travel/travel.service.spec.ts @@ -179,6 +179,7 @@ function createState(): FakeState { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, diff --git a/apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (5).png b/apps/web/public/assets/hud-elements/drop-card.png similarity index 100% rename from apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (5).png rename to apps/web/public/assets/hud-elements/drop-card.png diff --git a/apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (6).png b/apps/web/public/assets/hud-elements/highlight-frame.png similarity index 100% rename from apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (6).png rename to apps/web/public/assets/hud-elements/highlight-frame.png diff --git a/apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (3).png b/apps/web/public/assets/hud-elements/list-bg.png similarity index 100% rename from apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (3).png rename to apps/web/public/assets/hud-elements/list-bg.png diff --git a/apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (1).png b/apps/web/public/assets/hud-elements/panel-bg.png similarity index 100% rename from apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (1).png rename to apps/web/public/assets/hud-elements/panel-bg.png diff --git a/apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (2).png b/apps/web/public/assets/hud-elements/sidepanel-left.png similarity index 100% rename from apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (2).png rename to apps/web/public/assets/hud-elements/sidepanel-left.png diff --git a/apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (8).png b/apps/web/public/assets/hud-elements/statusbar-bg.png similarity index 100% rename from apps/web/public/assets/hud-elements/ChatGPT Image 19. Aug. 2026, 21_29_37 (8).png rename to apps/web/public/assets/hud-elements/statusbar-bg.png diff --git a/apps/web/public/images/items/ash-blade.png b/apps/web/public/images/items/ash-blade.png new file mode 100644 index 0000000..a0365f3 Binary files /dev/null and b/apps/web/public/images/items/ash-blade.png differ diff --git a/apps/web/public/images/items/ash-boots.png b/apps/web/public/images/items/ash-boots.png new file mode 100644 index 0000000..0e6d7fa Binary files /dev/null and b/apps/web/public/images/items/ash-boots.png differ diff --git a/apps/web/public/images/items/ash-pelt.png b/apps/web/public/images/items/ash-pelt.png new file mode 100644 index 0000000..a6c49bc Binary files /dev/null and b/apps/web/public/images/items/ash-pelt.png differ diff --git a/apps/web/public/images/items/bandit-blade.png b/apps/web/public/images/items/bandit-blade.png new file mode 100644 index 0000000..ef701f9 Binary files /dev/null and b/apps/web/public/images/items/bandit-blade.png differ diff --git a/apps/web/public/images/items/bandit-hood.png b/apps/web/public/images/items/bandit-hood.png new file mode 100644 index 0000000..9d881e0 Binary files /dev/null and b/apps/web/public/images/items/bandit-hood.png differ diff --git a/apps/web/public/images/items/borderwatch-sigil.png b/apps/web/public/images/items/borderwatch-sigil.png new file mode 100644 index 0000000..2239257 Binary files /dev/null and b/apps/web/public/images/items/borderwatch-sigil.png differ diff --git a/apps/web/public/images/items/burned-captain-pendant.png b/apps/web/public/images/items/burned-captain-pendant.png new file mode 100644 index 0000000..26a4d02 Binary files /dev/null and b/apps/web/public/images/items/burned-captain-pendant.png differ diff --git a/apps/web/public/images/items/guardsman-legs.png b/apps/web/public/images/items/guardsman-legs.png new file mode 100644 index 0000000..9392dd3 Binary files /dev/null and b/apps/web/public/images/items/guardsman-legs.png differ diff --git a/apps/web/public/images/items/raider-gloves.png b/apps/web/public/images/items/raider-gloves.png new file mode 100644 index 0000000..3de8a40 Binary files /dev/null and b/apps/web/public/images/items/raider-gloves.png differ diff --git a/apps/web/public/images/items/reinforced-leather-jacket.png b/apps/web/public/images/items/reinforced-leather-jacket.png new file mode 100644 index 0000000..6c44d43 Binary files /dev/null and b/apps/web/public/images/items/reinforced-leather-jacket.png differ diff --git a/apps/web/public/images/items/small-healing-potion.png b/apps/web/public/images/items/small-healing-potion.png new file mode 100644 index 0000000..6465bb4 Binary files /dev/null and b/apps/web/public/images/items/small-healing-potion.png differ diff --git a/apps/web/public/images/items/worn-short-sword.png b/apps/web/public/images/items/worn-short-sword.png new file mode 100644 index 0000000..bd81fae Binary files /dev/null and b/apps/web/public/images/items/worn-short-sword.png differ diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 760ebeb..4dd3f38 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -86,6 +86,7 @@ describe('App', () => { name: 'Mara Ashfall', level: 7, experience: 320, + silver: 150, currentHp: 52, maxHp: 80, attack: 12, @@ -99,6 +100,8 @@ describe('App', () => { ); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('Stufe 7'); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('52 / 80'); + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('150'); + expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320'); }); it('redirects root and unknown routes to the world shell', () => { diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index 87c8938..34694e9 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -9,6 +9,7 @@ export interface CharacterResponse { name: string; level: number; experience: number; + silver: number; currentHp: number; maxHp: number; attack: number; @@ -180,4 +181,26 @@ export interface Combat { player: CombatPlayer; monster: CombatMonster; events: CombatEvent[]; + rewards: CombatReward | null; +} + +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[]; } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index d214f1d..2926081 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -86,7 +86,37 @@

Sieg

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

-

Belohnungen werden im nächsten Schritt verarbeitet.

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

Belohnungen

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

Beute

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

Keine besondere Beute gefunden.

+ } +
+ } + diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index ccd4d81..270597e 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -457,6 +457,10 @@ text-transform: uppercase; } +.outcome--won { + inline-size: min(32rem, 90%); +} + .outcome--won .outcome__title { color: var(--ar-gold); } @@ -650,3 +654,61 @@ max-inline-size: none; } } + +/* 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; +} diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index d048986..64edb5d 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angul import { vi } from 'vitest'; import type { Combat } from '../../../core/api/game-api.models'; import { CombatStore } from '../combat.store'; +import { WorldStore } from '../../world/world.store'; import { CombatPageComponent } from './combat-page.component'; const activeCombat: Combat = { @@ -23,6 +24,7 @@ const activeCombat: Combat = { { round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, { round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, ], + rewards: null, }; const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.'; @@ -40,6 +42,7 @@ describe('CombatPageComponent', () => { loadCombat: ReturnType; attack: ReturnType; }; + let worldStore: { refreshCharacter: ReturnType }; let router: Router; async function setup(combat: Combat | null) { @@ -51,12 +54,14 @@ describe('CombatPageComponent', () => { loadCombat: vi.fn(() => Promise.resolve()), attack: vi.fn(() => Promise.resolve()), }; + worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; await TestBed.configureTestingModule({ imports: [CombatPageComponent], providers: [ provideRouter([]), { provide: CombatStore, useValue: combatStore }, + { provide: WorldStore, useValue: worldStore }, { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } }, @@ -209,6 +214,31 @@ describe('CombatPageComponent', () => { expect(monster?.classList.contains('sprite--lunge')).toBe(false); }); + it('refreshes the character from the server once a combat is won', async () => { + const fixture = await setup(activeCombat); + combatStore.attack.mockImplementation(async () => { + combatStore.combat.set({ + ...activeCombat, + status: 'WON', + monster: { ...activeCombat.monster, currentHp: 0 }, + rewards: { experience: 8, silver: 6, items: [] }, + events: [ + ...activeCombat.events, + { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 }, + { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, + ], + }); + }); + vi.useFakeTimers(); + + const element = fixture.nativeElement as HTMLElement; + element.querySelector('[data-combat-attack]')?.click(); + await vi.advanceTimersByTimeAsync(540); + fixture.detectChanges(); + + expect(worldStore.refreshCharacter).toHaveBeenCalledOnce(); + }); + it('disables Angriff while an action is pending', async () => { const fixture = await setup(activeCombat); combatStore.actionPending.set(true); @@ -258,4 +288,85 @@ describe('CombatPageComponent', () => { expect(combatStore.loadCombat).toHaveBeenCalledTimes(2); }); + + const wonWithRewards: Combat = { + ...activeCombat, + status: 'WON', + monster: { ...activeCombat.monster, currentHp: 0 }, + rewards: { experience: 8, silver: 6, items: [] }, + }; + + it('shows the granted XP and silver on the victory screen', async () => { + const fixture = await setup(wonWithRewards); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-rewards]')).toBeTruthy(); + expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('8'); + expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('6'); + }); + + it('renders a dropped item with its icon, name, and rarity', async () => { + const fixture = await setup({ + ...wonWithRewards, + monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Straßenräuber', currentHp: 0 }, + rewards: { + experience: 16, + silver: 12, + items: [ + { + characterItemId: 'character-item-1', + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: 'COMMON', + iconPath: '/images/items/bandit-blade.png', + }, + quantity: 1, + }, + ], + }, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-item-icon]')?.getAttribute('src')).toBe( + '/images/items/bandit-blade.png', + ); + expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge'); + expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich'); + expect(element.querySelector('[data-reward-empty]')).toBeNull(); + }); + + it('treats a victory without loot as complete, not as a failure', async () => { + const fixture = await setup(wonWithRewards); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-reward-empty]')?.textContent).toContain( + 'Keine besondere Beute gefunden.', + ); + expect(element.querySelector('[role="alert"]')).toBeNull(); + // XP and silver still carry the screen. + expect(element.querySelector('[data-reward-experience]')).toBeTruthy(); + }); + + it('renders the persisted rewards of an already-won combat loaded from the server', async () => { + // Simulates a browser refresh: the page loads the combat by id and shows + // exactly what the server persisted, without rerolling anything. + const fixture = await setup({ + ...wonWithRewards, + rewards: { experience: 16, silver: 12, items: [] }, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1'); + expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('16'); + expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('12'); + }); + + it('still shows a plain victory when the server reports no reward record', async () => { + const fixture = await setup({ ...wonWithRewards, rewards: null }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy(); + expect(element.querySelector('[data-combat-rewards]')).toBeNull(); + }); }); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 38f13ed..4af1f47 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -7,6 +7,8 @@ import { monsterIconPath, runtimeMonsterArtworkPath, } from '../../../shared/monster-artwork'; +import { ItemCardComponent } from '../../../shared/item-card/item-card.component'; +import { WorldStore } from '../../world/world.store'; import { CombatStore } from '../combat.store'; interface CombatLogRound { @@ -35,9 +37,11 @@ const STAGE_SHAKE_MS = 200; selector: 'app-combat-page', templateUrl: './combat-page.component.html', styleUrl: './combat-page.component.scss', + imports: [ItemCardComponent], }) export class CombatPageComponent implements OnInit { protected readonly combatStore = inject(CombatStore); + private readonly worldStore = inject(WorldStore); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); @@ -95,6 +99,12 @@ export class CombatPageComponent implements OnInit { return; } + 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(); + } + const riposte = after.events.find( (event) => event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER', diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts index 0add925..4a2a1ee 100644 --- a/apps/web/src/app/features/combat/combat.store.spec.ts +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -20,6 +20,7 @@ const startedCombat: Combat = { artworkPath: '/images/monsters/ash-rat.png', }, events: [], + rewards: null, }; const afterAttack: Combat = { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index b516e3b..9e790e4 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -58,6 +58,7 @@ const startedCombat: Combat = { artworkPath: '/images/enemies/RoadBandit.png', }, events: [], + rewards: null, }; describe('HuntPageComponent', () => { diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 3d5730d..7ab6fee 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -16,6 +16,7 @@ const character: CharacterResponse = { name: 'Aric Duskwalker', level: 1, experience: 0, + silver: 0, currentHp: 100, maxHp: 100, attack: 6, @@ -324,4 +325,26 @@ describe('WorldStore', () => { expect(api.getCurrentTravel).toHaveBeenCalledTimes(2); expect(store.currentTravel()).toEqual(travelling); }); + + 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(); + }); }); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index 3f466e3..95d9c52 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -77,6 +77,27 @@ export class WorldStore implements OnDestroy { this.selectedConnectionState.set(connection); } + /** + * Re-reads the character from the server, e.g. after a combat granted XP and + * silver. Never mutates the values locally: the server owns them (spec §35). + * A failed refresh leaves the last known character in place rather than + * blanking the HUD. + */ + async refreshCharacter(): Promise { + if (this.destroyed) { + return; + } + + try { + const character = await firstValueFrom(this.api.getCharacter()); + if (!this.destroyed) { + this.characterState.set(character); + } + } catch { + // Keep the previous character; the next load() will resync. + } + } + async startTravel(): Promise { if (this.destroyed) { return; diff --git a/apps/web/src/app/layout/top-bar/top-bar.component.html b/apps/web/src/app/layout/top-bar/top-bar.component.html index dde5327..eb34269 100644 --- a/apps/web/src/app/layout/top-bar/top-bar.component.html +++ b/apps/web/src/app/layout/top-bar/top-bar.component.html @@ -15,6 +15,16 @@ >
+
+
+
Silber
+
{{ character.silver }}
+
+
+
XP
+
{{ character.experience }}
+
+
} @else { Charakterdaten werden geladen } diff --git a/apps/web/src/app/layout/top-bar/top-bar.component.scss b/apps/web/src/app/layout/top-bar/top-bar.component.scss index a979a7d..fd68c3e 100644 --- a/apps/web/src/app/layout/top-bar/top-bar.component.scss +++ b/apps/web/src/app/layout/top-bar/top-bar.component.scss @@ -110,3 +110,29 @@ min-inline-size: 5rem; } } + +.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; +} diff --git a/apps/web/src/app/shared/item-card/item-card.component.html b/apps/web/src/app/shared/item-card/item-card.component.html new file mode 100644 index 0000000..45a8b49 --- /dev/null +++ b/apps/web/src/app/shared/item-card/item-card.component.html @@ -0,0 +1,10 @@ +
+
+ + @if (quantity() > 1) { + ×{{ quantity() }} + } +
+

{{ item().name }}

+

{{ rarityLabel() }}

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

{{ item().name }}

+

{{ rarityLabel() }}

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

Sieg

+

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

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

Belohnungen

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

Beute

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

Keine besondere Beute gefunden.

+ } +
+ } + + +
+ } @else if (combat.status === 'LOST') { +``` + +- [ ] **Step 5: Style the summary** + +Append to `apps/web/src/app/features/combat/combat-page/combat-page.component.scss`: + +```scss +/* Reward summary: dark stone and bronze, large readable values, restrained + rarity emphasis. No confetti, no popups, no slot-machine reveal (spec §50). */ +.rewards { + display: grid; + gap: var(--ar-space-3); + justify-items: center; + inline-size: 100%; + margin-block-start: var(--ar-space-3); + padding-block-start: var(--ar-space-3); + border-block-start: 1px solid var(--ar-border); +} + +.rewards__title { + margin: 0; + color: var(--ar-text-muted); + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--ar-font-sm); + font-weight: 400; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.rewards__currencies { + display: flex; + gap: var(--ar-space-6); + margin: 0; +} + +.rewards__currency { + display: grid; + gap: var(--ar-space-1); + justify-items: center; +} + +.rewards__currency dt { + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rewards__currency dd { + margin: 0; + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(1.25rem, 2.5vw, 1.6rem); +} + +.rewards__loot { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-4); + justify-content: center; + margin: 0; + padding: 0; + list-style: none; +} +``` + +The victory panel is now taller than the Slice 0.3 placeholder. Widen it so a loot card fits: + +```scss +.outcome--won { + inline-size: min(32rem, 90%); +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/web -- combat-page` +Expected: PASS — the existing tests plus the 5 new ones. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/features/combat +git commit -m "feat(combat): replace the victory placeholder with the reward summary" +``` + +--- + +## Task 12: TopBar silver and XP + +After a victory the HUD must refresh from authoritative server data, never optimistically (spec §35). + +**Files:** +- Modify: `apps/web/src/app/features/world/world.store.ts` +- Modify: `apps/web/src/app/layout/top-bar/top-bar.component.html` +- Modify: `apps/web/src/app/layout/top-bar/top-bar.component.scss` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` +- Test: `apps/web/src/app/features/world/world.store.spec.ts` +- Test: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts` + +**Interfaces:** +- Consumes: `CharacterResponse.silver` (Tasks 9, 10). +- Produces: `WorldStore.refreshCharacter(): Promise` — re-reads `GET /characters/me` and replaces the character signal. + +- [ ] **Step 1: Write the failing store test** + +First add `silver: 0,` to the `character` fixture in `apps/web/src/app/features/world/world.store.spec.ts` (after `experience: 0,` on line 17) — `CharacterResponse` now requires it. + +Then add these tests. The file's `beforeEach` already builds `api` and `store`, so they use those directly: + +```ts + it('refreshCharacter replaces the character from authoritative server data', async () => { + await store.load(); + + api.getCharacter.mockReturnValue(of({ ...character, experience: 32, silver: 18 })); + await store.refreshCharacter(); + + expect(store.character()?.experience).toBe(32); + expect(store.character()?.silver).toBe(18); + }); + + it('keeps the previous character when the refresh fails', async () => { + await store.load(); + + api.getCharacter.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500 })), + ); + await store.refreshCharacter(); + + expect(store.character()?.silver).toBe(0); + expect(store.error()).toBeNull(); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/web -- world.store` +Expected: FAIL — `store.refreshCharacter is not a function`. + +- [ ] **Step 3: Add refreshCharacter to WorldStore** + +In `apps/web/src/app/features/world/world.store.ts`, add this public method after `selectConnection`: + +```ts + /** + * Re-reads the character from the server, e.g. after a combat granted XP and + * silver. Never mutates the values locally: the server owns them (spec §35). + * A failed refresh leaves the last known character in place rather than + * blanking the HUD. + */ + async refreshCharacter(): Promise { + if (this.destroyed) { + return; + } + + try { + const character = await firstValueFrom(this.api.getCharacter()); + if (!this.destroyed) { + this.characterState.set(character); + } + } catch { + // Keep the previous character; the next load() will resync. + } + } +``` + +- [ ] **Step 4: Show silver and XP in the TopBar** + +In `apps/web/src/app/layout/top-bar/top-bar.component.html`, add a resources block after the `top-bar__health` div (inside the same `@if (character(); as character)` branch): + +```html +
+
+
Silber
+
{{ character.silver }}
+
+
+
XP
+
{{ character.experience }}
+
+
+``` + +Append to `apps/web/src/app/layout/top-bar/top-bar.component.scss`: + +```scss +.top-bar__resources { + display: flex; + gap: var(--ar-space-4); + margin: 0; + padding-inline-start: var(--ar-space-4); + border-inline-start: 1px solid var(--ar-border); +} + +.top-bar__resource { + display: grid; + gap: var(--ar-space-1); +} + +.top-bar__resource dt { + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.top-bar__resource dd { + margin: 0; + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; +} +``` + +- [ ] **Step 5: Refresh the HUD after a victory** + +Add to `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`: + +```ts + it('refreshes the character from the server once a combat is won', async () => { + const fixture = await setup(activeCombat); + combatStore.attack.mockImplementation(async () => { + combatStore.combat.set({ + ...activeCombat, + status: 'WON', + monster: { ...activeCombat.monster, currentHp: 0 }, + rewards: { experience: 8, silver: 6, items: [] }, + events: [ + ...activeCombat.events, + { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 }, + { round: 2, sequence: 4, type: 'COMBAT_WON', source: 'PLAYER', target: 'MONSTER' }, + ], + }); + }); + vi.useFakeTimers(); + + const element = fixture.nativeElement as HTMLElement; + element.querySelector('[data-combat-attack]')?.click(); + await vi.advanceTimersByTimeAsync(540); + fixture.detectChanges(); + + expect(worldStore.refreshCharacter).toHaveBeenCalledOnce(); + }); +``` + +Extend the spec's `setup()` to provide a `WorldStore` stub — add to the `providers` array: + +```ts + { provide: WorldStore, useValue: worldStore }, +``` + +with, alongside the `combatStore` declaration: + +```ts + let worldStore: { refreshCharacter: ReturnType }; +``` + +and inside `setup()`: + +```ts + worldStore = { refreshCharacter: vi.fn(() => Promise.resolve()) }; +``` + +Import `WorldStore` from `'../../world/world.store'` in the spec. + +In `combat-page.component.ts`, inject the store and call it when the fight ends in victory: + +```ts +import { WorldStore } from '../../world/world.store'; +``` + +```ts + private readonly worldStore = inject(WorldStore); +``` + +Then in `attack()`, right after `const after = this.combatStore.combat();` and its null guard, add: + +```ts + if (after.status === 'WON') { + // The server already granted XP and silver; pull the authoritative + // character so the HUD matches (spec §35). + void this.worldStore.refreshCharacter(); + } +``` + +- [ ] **Step 6: Run the web suite and build** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS — all suites green. + +Run: `npm run build:web` +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/features/world apps/web/src/app/layout/top-bar apps/web/src/app/features/combat +git commit -m "feat(web): show authoritative silver and XP in the top bar after victory" +``` + +--- + +## Task 13: Full verification + +Everything spec §52 requires, in one pass. Nothing here is optional. + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md` (record results below) + +**Interfaces:** +- Consumes: every prior task. +- Produces: a verified, demonstrable slice. + +- [ ] **Step 1: Run every automated check** + +```bash +npm test --workspace=@ashen-realms/api +npm test --workspace=@ashen-realms/web +npm run build:api +npm run build:web +``` + +Expected: four green results. Record the API and web test counts. + +- [ ] **Step 2: Verify migration and seed from a clean state** + +```bash +npm run db:revert && npm run db:migrate && npm run db:seed && npm run db:seed +``` + +Expected: revert, re-apply, and two seed runs all succeed. + +```bash +psql "$DATABASE_URL" -c "SELECT count(*) FROM item_definitions" -c "SELECT count(*) FROM loot_table_entries" +``` + +Expected: still 12 and 5 — the seed did not duplicate. + +- [ ] **Step 3: Browser walkthrough — Aschenratte victory** + +Start both apps (`npm run dev:api`, `npm run dev:web`), then walk: Südtor → Verbrannte Straße → Jagd beginnen → pick an Aschenratte → attack until it dies. + +Verify: +- the victory panel shows **+8 XP** and a silver value **between 4 and 7** +- the TopBar silver and XP both increased by exactly those amounts +- with no drop: "Keine besondere Beute gefunden." and no error styling +- repeat hunts until a drop occurs (60% Aschenfell): the item card shows icon, "Aschenfell", "Gewöhnlich" + +- [ ] **Step 4: Browser walkthrough — Straßenräuber victory** + +Hunt until a Straßenräuber appears and win. + +Verify: +- **+16 XP** and silver between 9 and 15 +- repeat until a Räuberklinge drops (18%): icon, name, and "Gewöhnlich" render + +```bash +psql "$DATABASE_URL" -c "SELECT ci.quantity, d.key FROM character_items ci JOIN item_definitions d ON d.id = ci.item_definition_id" +``` + +Expected: the dropped items exist as real rows — persistent player state, not a cosmetic card (spec §27). + +- [ ] **Step 5: Verify refresh does not reroll** + +On the victory screen, note the exact XP, silver, and items. Press F5. + +Verify: +- identical values reappear +- the item list is identical + +```bash +psql "$DATABASE_URL" -c "SELECT count(*) FROM combat_rewards WHERE combat_id = ''" +``` + +Expected: exactly `1`. + +- [ ] **Step 6: Verify a repeated reward request cannot farm** + +```bash +curl -s -X POST http://localhost:3000/api/combats//actions \ + -H 'Content-Type: application/json' -d '{"action":"ATTACK"}' +curl -s http://localhost:3000/api/combats/ +``` + +Expected: the POST is rejected with `COMBAT_ALREADY_FINISHED`; the GET returns the same reward. Then confirm nothing moved: + +```bash +psql "$DATABASE_URL" -c "SELECT count(*) FROM combat_rewards" -c "SELECT experience, silver FROM characters" +``` + +Expected: the reward count and the character's totals are unchanged from Step 5. + +- [ ] **Step 7: Verify the return to hunt** + +Click "Zur Jagd". + +Verify: `/hunt` loads, the consumed encounter is **not** attackable again, and "Neu suchen" starts a fresh hunt. + +- [ ] **Step 8: Record the results and commit** + +Append a "## Verification Results" section to this plan with the test counts, the observed silver rolls, which items dropped, and confirmation of steps 5–7. + +```bash +git add docs/superpowers/plans/2026-08-19-playable-slice-0.4-first-loot.md +git commit -m "docs(slice-0.4): record First Loot verification results" +``` + +--- + +## Acceptance Criteria Traceability + +| Spec §53 criterion | Where it is satisfied | +|---|---| +| only WON combats receive victory rewards | Task 7 Step 4 guard; Task 7 eligibility tests; Task 8 defeat test | +| one combat grants rewards at most once | Task 3 unique index `IDX_combat_rewards_combat`; Task 7 idempotency test | +| XP is persisted | Task 7 `character.experience += experience`; Task 7 Aschenratte test | +| silver is persisted | Task 2 `characters.silver`; Task 3 migration; Task 7 Aschenratte test | +| silver ranges match the content definitions | Task 7 tests assert 4 and 7 (rat), 9 (bandit) from `monster_definitions` | +| loot rolls happen only on the backend | Task 6 `LootService`; no roll code ships to the client | +| loot probabilities are data-driven | Task 5 `loot_table_entries.drop_chance` rows | +| reward randomness is deterministic in tests | Task 1 `RandomSource`; Task 6 `queuedRandom`; Task 7 `fixedRandom` | +| dropped items become persistent CharacterItems | Task 7 CharacterItem test; Task 13 Step 4 SQL check | +| reward data survives browser refresh | Task 8 `loadRewards` in `getCombat`; Task 11 refresh test; Task 13 Step 5 | +| reward results never reroll on reload | Task 7 `loadRewards` never rolls; Task 8 "reading must never grant" assertion | +| Angular only renders server-provided reward state | Task 11 renders `combat.rewards` verbatim; no client-side generation | +| item drops are visibly presented | Task 10 `ItemCardComponent`; Task 11 item-drop test | +| a no-item victory is handled cleanly | Task 11 "treats a victory without loot as complete" test | +| CombatEngineService contains no reward logic | Task 8 Step 7 grep gate | +| no equipment functionality implemented prematurely | Task 10 "offers no equip or comparison affordance yet" test | +| all tests pass | Task 13 Step 1 | +| backend and frontend builds succeed | Task 13 Step 1 | + +--- + +## Verification Results + +Verified 2026-08-20 against a live PostgreSQL instance (Docker container `awesome_babbage`) with dev servers run on alternate ports (API 3901, web 4901) to avoid colliding with a sibling worktree's own dev servers already occupying 3000/4200. + +**Step 1 — automated checks:** all four green. +- API: 23 test suites, 119 tests, 0 failures. +- Web: 13 test suites, 104 tests, 0 failures. +- `npm run build:api`: succeeds. +- `npm run build:web`: succeeds (pre-existing `combat-page.component.scss` budget warning only — 9.14 kB vs. the 4 kB advisory threshold, well under the 12 kB error threshold; not a new regression, tracked since Task 11). + +**Step 2 — migration/seed round-trip:** `db:revert` → `db:migrate` → `db:seed` → `db:seed` all succeeded. Post-cycle counts: `item_definitions` = 12, `loot_table_entries` = 5 — unchanged by the second seed run. + +**Step 3 — Aschenratte victory (browser, Playwright-driven Chromium against the real dev servers):** +- No-drop victory: +8 XP, +4 Silber, "Keine besondere Beute gefunden." with no error styling. +- Drop victory: +8 XP, +5 Silber, item card showing icon, "Abgenutztes Kurzschwert", "GEWÖHNLICH" (the 8% starter-equipment branch of the ash-rat table, hit before the 60% Aschenfell branch in this run — both are real, data-driven rolls from the same table). +- TopBar silver/XP increased by exactly the granted amounts after each victory. + +**Step 4 — Straßenräuber victory:** +- +16 XP and silver rolls of 12, 13, and 10 across three wins — all within the documented 9–15 range. +- One win dropped two items in a single combat (Räuberhaube + Plündererhandschuhe), directly demonstrating independent per-entry rolls (spec §17). +- A later win dropped Räuberklinge alone; icon, name, and "GEWÖHNLICH" rendered correctly. +- `character_items` verified via SQL: `bandit-blade`, `bandit-hood`, `raider-gloves`, `worn-short-sword`, and `ash-pelt` (from prior sessions) all persisted as real rows, quantity 1 each — not cosmetic (spec §27). + +**Step 5 — refresh does not reroll:** navigated to `/combat/` as a fresh page load (equivalent to F5 — no client cache reused). Rendered XP (+16), silver (+10), and item (Räuberklinge) were byte-identical to the original grant. `SELECT count(*) FROM combat_rewards WHERE combat_id = ''` = exactly `1`. + +**Step 6 — repeated reward request cannot farm:** `POST /api/combats//actions` on the already-WON combat returned `409 COMBAT_ALREADY_FINISHED`. The subsequent `GET` returned the identical persisted reward. Confirmed via SQL that `combat_rewards` count and the character's `experience`/`silver` totals were unchanged from Step 5. + +**Step 7 — return to hunt:** "Zur Jagd" returns to `/hunt` and redisplays the same encounter list, including the just-consumed encounter. Re-attacking that consumed encounter is rejected server-side with `HUNT_ENCOUNTER_ALREADY_CONSUMED` ("Diese Begegnung wurde bereits genutzt.") — no duplicate combat, no duplicate reward. "Neu suchen" / "Jagd beginnen" successfully starts a fresh hunt with new encounters. + +**Environment notes for future runs:** the worktree's `.env` (untracked, copied from the main checkout) needs its own `PORT` and the web app needs `ng serve --proxy-config ` whenever a sibling worktree's dev servers are already running on 3000/4200. `psql` is not on PATH — use `docker exec awesome_babbage psql -U ashen -d ashen_realms`. + + diff --git a/docs/superpowers/specs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md b/docs/superpowers/specs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md new file mode 100644 index 0000000..0686678 --- /dev/null +++ b/docs/superpowers/specs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md @@ -0,0 +1,1471 @@ +# Ashen Realms – Loot Bags & Monster Loot Categories Specification V1 + +## Zweck dieser Dokumentation + +Dieses Dokument definiert das **Taschen- und Beutekategoriesystem** von Ashen Realms. + +Das System ergänzt: + +- Monster +- Loot +- Inventar +- Händler +- Ruf +- Expeditionen +- spätere Crafting-Systeme + +Die zentrale Idee lautet: + +> Gegner hinterlassen thematisch passende Beute. Diese Beute wird nicht unbegrenzt im normalen Inventar getragen, sondern benötigt passende spezialisierte Taschen. + +Dadurch entsteht ein natürlicher Expeditionsrhythmus: + +**Gebiet verlassen → Gegner bekämpfen → Beutetaschen füllen → zurückkehren → Beute verwerten → erneut aufbrechen** + +Das System soll keine klassische Gewichtssimulation darstellen. + +Es soll leicht verständliche, sichtbare und langfristig verbesserbare Kapazitätsgrenzen erzeugen. + +--- + +# 1. Grundprinzip + +Ashen Realms unterscheidet zwischen mehreren Arten transportierbarer Gegenstände. + +## Normales Inventar + +Enthält insbesondere: + +- Waffen +- Rüstung +- Amulette +- normale Verbrauchsgegenstände +- besondere Gegenstände +- nicht kategorisierte Items + +Das normale Inventar bleibt weiterhin slotbasiert. + +--- + +## Kampftasche + +Die bereits bestehende Kampftasche bleibt ein separates System. + +Sie bestimmt, welche Verbrauchsgegenstände während eines Kampfes verwendet werden können. + +Beispiele: + +- Heiltränke +- Gegengifte +- spätere Kampfverbrauchsgegenstände + +Die Kampftasche besitzt keine Verbindung zu normalen Beutetaschen. + +--- + +## Questinventar + +Questgegenstände belegen grundsätzlich keine Kapazität einer normalen Beutetasche. + +Beispiele: + +- Briefe +- Schlüssel +- Questrelikte +- Beweisstücke +- Storygegenstände + +Questfortschritt darf nicht blockiert werden, weil eine Beutetasche voll ist. + +--- + +## Beutetaschen + +Bestimmte Materialien und Handelswaren benötigen eine passende Beutetasche. + +Beispiele: + +- Felle +- Klauen +- Zähne +- Plünderergut +- Münzen +- Relikte + +Jede Tasche unterstützt genau eine definierte **Loot Category** oder eine explizite Gruppe kompatibler Kategorien. + +--- + +# 2. Designziele + +Das Taschensystem besitzt folgende Ziele: + +1. Expeditionen sollen eine natürliche Länge erhalten. +2. Rückkehr zu Händlern und sicheren Orten soll spielerisch sinnvoll sein. +3. Taschen sollen selbst Teil der Progression sein. +4. Unterschiedliche Gegnerarten sollen unterschiedliche wirtschaftliche Identitäten erhalten. +5. Beute soll thematisch zum Gegner passen. +6. Größere Taschen sollen spürbaren Komfort und Fortschritt erzeugen. +7. Das System darf keine komplizierte Gewichtssimulation erzeugen. +8. Neue Gegner und Materialien sollen datengetrieben ergänzt werden können. + +--- + +# 3. Kein Gewichtssystem + +Beutetaschen besitzen ausschließlich eine diskrete Kapazität. + +Beispiel: + +**Einfacher Fellbeutel** + +Kapazität: + +`5` + +Bedeutung: + +Der Spieler kann insgesamt fünf Einheiten kompatibler Fell-Beute tragen. + +Es existiert keine Berechnung wie: + +`Wolfspelz = 2,4 kg` + +oder: + +`Maximales Gewicht = 32 kg` + +Alle kompatiblen Gegenstände verbrauchen standardmäßig: + +**1 Kapazität pro Einheit** + +Spätere Sondergegenstände dürfen bei Bedarf einen abweichenden Kapazitätsverbrauch besitzen. + +Für V1 ist dies nicht notwendig. + +--- + +# 4. Loot Categories + +Alle Materialien oder Handelswaren, die das Taschensystem verwenden, besitzen eine definierte Loot Category. + +V1 verwendet zunächst wenige breite Kategorien. + +## HIDE + +Tierhäute und Felle. + +Beispiele: + +- Aschenfell +- Zähes Fell +- Dämmerfell +- Schwarzmähnenfell +- Hirschhaut + +Passende Tasche: + +**Fellbeutel** + +--- + +## TROPHY + +Kleine Jagdtrophäen und hochwertige Bestienteile. + +Beispiele: + +- Zahn +- Klaue +- Horn +- Giftdrüse +- Bestienherz + +Passende Tasche: + +**Jagdtasche** + +--- + +## HUMANOID_SPOILS + +Beute von humanoiden Gegnern. + +Beispiele: + +- Plündererabzeichen +- Stoffreste +- gestohlene Handelsware +- Waffenfragmente +- militärische Insignien + +Passende Tasche: + +**Plünderbeutel** + +--- + +## COIN + +Münzen und ähnliche kleine Wertgegenstände. + +Wichtig: + +Diese Kategorie bezeichnet nicht die globale Charakterwährung Silber. + +Sie enthält physische Beutegegenstände wie: + +- alte Münzen +- fremde Münzen +- Münzrollen +- wertvolle Token +- gestohlene Münzbeutel + +Passende Tasche: + +**Münztasche** + +Diese Gegenstände können später bei passenden Händlern gegen reguläres Silber oder Ruf eingetauscht werden. + +--- + +## RELIC + +Archäologische, magische und untote Beute. + +Beispiele: + +- Knochenfragment +- Siegelbruch +- Grabbeigabe +- Reliktfragment +- verdorbene Reliquie + +Passende Tasche: + +**Reliquienbehälter** + +--- + +# 5. Erweiterbare Kategorien + +Das System muss so aufgebaut sein, dass später weitere Kategorien ergänzt werden können. + +Mögliche spätere Kategorien: + +- HERB +- ORE +- GEM +- FISH +- ALCHEMY +- ARCANE +- DEMONIC +- INSECT +- FOOD + +Diese Kategorien sind nicht Bestandteil von V1. + +Neue Kategorien dürfen erst hinzugefügt werden, wenn entsprechender Content tatsächlich existiert. + +--- + +# 6. Monster Categories + +Monster erhalten zusätzlich eine oder mehrere **Monster Categories**. + +Monster Categories beschreiben, welcher übergeordneten Gegnerart ein Gegner angehört. + +V1 sollte mindestens folgende Kategorien unterstützen: + +- BEAST +- HUMANOID +- UNDEAD +- INSECT +- ABERRATION + +Optional später: + +- CONSTRUCT +- DEMON +- ELEMENTAL +- DRAGON +- SPIRIT + +--- + +# 7. Zweck der Monster Categories + +Monster Categories dienen nicht direkt als Taschenzuordnung. + +Sie besitzen mehrere mögliche Funktionen: + +- Standard-Loot festlegen +- Loot-Tabellen organisieren +- Quests filtern +- Händleraufträge definieren +- Fähigkeiten gegen bestimmte Gegnertypen ermöglichen +- Sets oder Items mit Boni gegen Gegnertypen unterstützen +- Statistiken und Bestiarium ermöglichen + +Beispiel: + +**Aschenratte** + +Monster Category: + +`BEAST` + +Typischer Loot: + +`HIDE` + +--- + +**Straßenräuber** + +Monster Category: + +`HUMANOID` + +Typischer Loot: + +`HUMANOID_SPOILS` + +--- + +**Giftspinne** + +Monster Category: + +`INSECT` + +Typischer Loot: + +`TROPHY` + +--- + +**Knochenritter** + +Monster Category: + +`UNDEAD` + +Typischer Loot: + +`RELIC` + +--- + +# 8. Monster Category und Loot Category bleiben getrennt + +Diese beiden Systeme dürfen technisch nicht identisch sein. + +Beispiel: + +Ein Wolf ist: + +`MonsterCategory.BEAST` + +Er kann aber gleichzeitig droppen: + +- Fell → `HIDE` +- Zahn → `TROPHY` + +Ein Straßenräuber ist: + +`MonsterCategory.HUMANOID` + +Er kann droppen: + +- Abzeichen → `HUMANOID_SPOILS` +- gestohlene Münzen → `COIN` + +Dadurch bleibt das System flexibel. + +Grundsatz: + +> Monster Category beschreibt den Gegner. Loot Category beschreibt den Gegenstand. + +--- + +# 9. ItemDefinition-Erweiterung + +Materialien und Handelsgegenstände benötigen künftig eine optionale Loot Category. + +Konzeptionell: + +```ts +ItemDefinition { + ... + itemType + lootCategory? +} +``` + +Beispiel: + +```ts +{ + key: 'ash-hide', + name: 'Aschenfell', + itemType: 'TRADE_GOOD', + lootCategory: 'HIDE' +} +``` + +Equipment besitzt keine Loot Category. + +Beispiel: + +```ts +{ + key: 'bandit-blade', + name: 'Räuberklinge', + itemType: 'EQUIPMENT', + lootCategory: null +} +``` + +Eine Räuberklinge landet weiterhin im normalen Inventar. + +--- + +# 10. MonsterDefinition-Erweiterung + +MonsterDefinition erhält mindestens: + +```ts +monsterCategories: MonsterCategory[] +``` + +V1 kann technisch auch genau eine Hauptkategorie verwenden. + +Das Modell sollte jedoch eine spätere Mehrfachzuordnung nicht unnötig verhindern. + +Beispiel: + +```ts +{ + key: 'ash-rat', + name: 'Aschenratte', + monsterCategories: ['BEAST'] +} +``` + +Später könnte ein Gegner beispielsweise sein: + +```ts +['UNDEAD', 'BEAST'] +``` + +--- + +# 11. Loot bleibt über Loot Tables definiert + +Monster Categories erzeugen nicht automatisch sämtliche Drops. + +Die existierenden Loot Tables bleiben die verbindliche Quelle dafür, was ein konkreter Gegner hinterlassen kann. + +Beispiel: + +```text +Aschenratte +→ 70 % Aschenfell +→ 15 % Rattenzahn +→ 5 % besonderes Item +``` + +Die Monster Category kann jedoch genutzt werden, um: + +- Default-Regeln +- Content-Validierung +- Editor-Vorschläge +- Questfilter + +zu unterstützen. + +Grundsatz: + +> Monster Categories unterstützen Loot-Definitionen. Sie ersetzen keine individuellen Loot Tables. + +--- + +# 12. BagDefinition + +Taschen sollten als datengetriebene Definitionen existieren. + +Konzeptionell: + +```ts +BagDefinition { + id + key + name + description + supportedLootCategories + capacity + tier + iconPath +} +``` + +Beispiel: + +```ts +{ + key: 'simple-hide-pouch', + name: 'Einfacher Fellbeutel', + supportedLootCategories: ['HIDE'], + capacity: 5, + tier: 1 +} +``` + +--- + +# 13. CharacterBag + +Ein Charakter besitzt konkrete freigeschaltete oder ausgerüstete Taschen. + +Konzeptionell: + +```ts +CharacterBag { + id + characterId + bagDefinitionId + equipped +} +``` + +Die tatsächliche Beute bleibt als CharacterItem beziehungsweise entsprechender Player-State gespeichert. + +Die Tasche bestimmt lediglich, wie viel davon gleichzeitig getragen werden darf. + +--- + +# 14. V1-Ausrüstungsmodell für Taschen + +Für V1 wird ein möglichst einfaches Modell empfohlen. + +Der Spieler besitzt pro Loot Category genau **eine aktive Tasche**. + +Beispiel: + +```text +HIDE +→ Einfacher Fellbeutel +→ 5 Kapazität + +TROPHY +→ Kleine Jagdtasche +→ 4 Kapazität + +HUMANOID_SPOILS +→ Kleiner Plünderbeutel +→ 5 Kapazität +``` + +Wird eine bessere Tasche ausgerüstet, ersetzt sie die bisher aktive Tasche dieser Kategorie. + +Keine Addition mehrerer identischer Taschen. + +Nicht: + +```text +5 Fellbeutel × 5 Plätze = 25 +``` + +Sondern: + +```text +aktiver Fellbeutel = 10 Plätze +``` + +Dadurch bleibt das System verständlich und leicht zu balancieren. + +--- + +# 15. Kapazitätsberechnung + +Für jede Loot Category wird berechnet: + +```text +verwendete Kapazität +/ +maximale Kapazität +``` + +Beispiel: + +```text +Fellbeutel +3 / 5 +``` + +Wenn der Spieler ein weiteres Fell erhält: + +```text +4 / 5 +``` + +Bei: + +```text +5 / 5 +``` + +ist die Tasche voll. + +--- + +# 16. Loot bei voller Tasche + +Wenn ein Gegner Beute einer Loot Category fallen lässt, deren Tasche voll ist, darf die Kapazitätsgrenze nicht überschritten werden. + +V1-Regel: + +**Nicht tragbare Handelsbeute wird zurückgelassen.** + +Beispiel: + +```text +Aschenfell gefunden + +Fellbeutel: +5 / 5 + +Ergebnis: +Aschenfell kann nicht aufgenommen werden. +``` + +Der Spieler erhält eine klare Nachricht. + +Beispiel: + +> Dein Fellbeutel ist voll. + +Der Kampf selbst bleibt vollständig abgeschlossen. + +--- + +# 17. Equipment bei voller Beutetasche + +Equipment und andere normale Inventargegenstände sind unabhängig von Beutetaschen. + +Beispiel: + +Ein Wolf droppt: + +- Dämmerfell +- Schwarzmähnenzahn als Material +- seltene Waffe + +Ist der Fellbeutel voll: + +- Dämmerfell kann nicht aufgenommen werden. +- Trophy kann aufgenommen werden, wenn dort Kapazität vorhanden ist. +- Waffe wird regulär über das normale Inventar verarbeitet. + +Jeder Loot-Eintrag wird unabhängig behandelt. + +--- + +# 18. Keine automatische Umwandlung + +Beute darf bei voller Tasche nicht automatisch in: + +- Silber +- Ruf +- andere Währungen + +umgerechnet werden. + +Das würde den Sinn des Taschensystems umgehen. + +Der Spieler muss die Beute tatsächlich zu einer geeigneten Stelle bringen. + +--- + +# 19. Beute wird nicht automatisch verkauft + +Das Töten eines Monsters erzeugt keinen unmittelbaren wirtschaftlichen Gewinn. + +Der Server gewährt zunächst ausschließlich die tatsächlich erhaltene Beute. + +Beispiel: + +```text +Wolf besiegt + +Erhalten: +Dämmerfell ×1 +Wolfsklaue ×1 +``` + +Erst eine spätere Händlerinteraktion kann daraus beispielsweise: + +```text +Silber +Regionalruf +Weltruhm +``` + +erzeugen. + +Das Händler- und Rufsystem wird in einer separaten Spezifikation definiert. + +--- + +# 20. Taschenprogression + +Taschen stellen eine eigene Form von Charakterfortschritt dar. + +Typische Progression: + +| Stufe | Kapazität | +|---|---:| +| provisorisch | 1 | +| einfach | 5 | +| verstärkt | 10 | +| hochwertig | 15 | +| erfahren | 25 | + +Dies sind Richtwerte und keine endgültigen Balancingwerte. + +--- + +# 21. Quellen größerer Taschen + +Bessere Taschen können langfristig über verschiedene Systeme erreichbar sein. + +Geeignete Quellen: + +- Händler +- Rufstufen +- Quests +- seltene Drops +- Gebietshändler +- Crafting +- Berufe +- besondere Erfolge + +V1 benötigt nicht alle diese Quellen. + +Taschen sollten jedoch reguläre Progressionsgegenstände sein und keine Premiummechanik. + +--- + +# 22. Taschen und Regionprogression + +Taschen dürfen passend zu Regionen oder Gegnertypen eingeführt werden. + +Beispiel: + +## Aschenfelder + +relevant: + +- HIDE +- HUMANOID_SPOILS +- COIN + +## Dämmerwald + +zusätzlich stärker relevant: + +- TROPHY + +## Vergessene Ruinen + +neu: + +- RELIC + +Dadurch kann jedes Gebiet zusätzlich einen kleinen wirtschaftlichen Systemfortschritt einführen. + +--- + +# 23. Keine Tasche pro Gegner + +Es wird ausdrücklich kein separates Behältnis für jeden Gegnertyp erstellt. + +Nicht: + +- Rattenbeutel +- Wolfsbeutel +- Hirschbeutel +- Spinnenbeutel + +Stattdessen: + +- Fellbeutel +- Jagdtasche +- Plünderbeutel +- Münztasche +- Reliquienbehälter + +Grundsatz: + +> Taschen bilden breite wirtschaftliche Beutekategorien ab, keine einzelnen Monster. + +--- + +# 24. UI-Darstellung + +Die aktuelle Taschenkapazität muss leicht sichtbar sein. + +Mindestens im Inventar oder in einem eigenen Taschenbereich. + +Beispiel: + +```text +Fellbeutel +████████░░ +8 / 10 +``` + +Oder: + +```text +Felle +8 / 10 +``` + +Eine fast volle Tasche darf visuell hervorgehoben werden. + +Eine volle Tasche benötigt einen klaren Zustand. + +--- + +# 25. Loot-UI + +Bei Kampfergebnissen sollte sichtbar sein, in welche Tasche ein Gegenstand aufgenommen wurde. + +Beispiel: + +```text +Aschenfell ×1 +→ Fellbeutel 4 / 5 +``` + +Wenn keine Kapazität vorhanden ist: + +```text +Aschenfell ×1 +Nicht aufgenommen – Fellbeutel voll +``` + +Der Spieler soll niemals rätseln müssen, warum ein Gegenstand fehlt. + +--- + +# 26. Taschenübersicht + +Das Inventar sollte langfristig einen separaten Abschnitt besitzen: + +**Taschen** + +Beispiel: + +```text +Fellbeutel 8 / 10 +Jagdtasche 3 / 5 +Plünderbeutel 5 / 10 +Münztasche 12 / 20 +Reliquienbehälter nicht vorhanden +``` + +Eine noch nicht vorhandene Tasche darf angezeigt werden, wenn dies für den Spieler hilfreich ist. + +--- + +# 27. Fehlt eine Tasche vollständig + +Besitzt der Spieler keine passende Tasche, gilt für V1: + +**Kapazität = 0** + +Alternativ können einzelne Tutorialsysteme eine implizite Kapazität von 1 vergeben. + +Die fachliche Regel sollte jedoch eindeutig sein. + +Empfehlung: + +```text +Keine Tasche +→ 0 Kapazität +``` + +Ein provisorischer Beutel mit Kapazität 1 wird als echtes Item vergeben, wenn Gameplay eine minimale Transportkapazität benötigt. + +Dadurch gibt es keine versteckten Sonderregeln. + +--- + +# 28. Serverautorität + +Das Taschensystem ist vollständig serverautoritativ. + +Der Client darf nicht bestimmen: + +- ob ein Gegenstand aufgenommen werden kann +- welche Tasche verwendet wird +- wie viel Kapazität verfügbar ist +- ob eine Tasche ausgerüstet ist +- wie viele Gegenstände ein Spieler trägt + +Der Server prüft jeden Loot Grant. + +--- + +# 29. Loot-Grant-Ablauf + +Für einen kategorisierten Gegenstand: + +```text +Loot Roll erfolgreich +→ ItemDefinition laden +→ Loot Category bestimmen +→ aktive kompatible Tasche bestimmen +→ aktuelle Nutzung berechnen +→ freie Kapazität prüfen +→ Item gewähren oder zurücklassen +→ Ergebnis an Client senden +``` + +Für Equipment: + +```text +Loot Roll erfolgreich +→ normales Inventory-System +``` + +--- + +# 30. Kapazitätsprüfung + +Konzeptionell: + +```ts +canStoreLoot( + characterId: string, + itemDefinitionId: string, + quantity: number, +): BagCapacityResult +``` + +Mögliches Ergebnis: + +```ts +interface BagCapacityResult { + canStoreAll: boolean; + acceptedQuantity: number; + rejectedQuantity: number; + bagKey?: string; + usedCapacity: number; + maxCapacity: number; +} +``` + +--- + +# 31. Teilweise Aufnahme + +Wenn mehrere Einheiten gleichzeitig gedroppt werden, darf eine teilweise Aufnahme erfolgen. + +Beispiel: + +```text +Fellbeutel: +4 / 5 + +Drop: +Aschenfell ×3 +``` + +Ergebnis: + +```text +aufgenommen: 1 +zurückgelassen: 2 +``` + +Danach: + +```text +5 / 5 +``` + +Dies muss im Loot-Ergebnis klar dargestellt werden. + +--- + +# 32. Relevante technische Enums + +Konzeptionell: + +```ts +export enum MonsterCategory { + BEAST = 'BEAST', + HUMANOID = 'HUMANOID', + UNDEAD = 'UNDEAD', + INSECT = 'INSECT', + ABERRATION = 'ABERRATION', +} +``` + +```ts +export enum LootCategory { + HIDE = 'HIDE', + TROPHY = 'TROPHY', + HUMANOID_SPOILS = 'HUMANOID_SPOILS', + COIN = 'COIN', + RELIC = 'RELIC', +} +``` + +Diese Enums eignen sich für das bestehende `game-content`- beziehungsweise Shared-Content-Modell. + +--- + +# 33. Erforderliche Content-Erweiterungen + +Alle bereits vorhandenen Monster müssen um eine Monster Category ergänzt werden. + +Beispielhafte Zuordnung des bestehenden Contents: + +## Aschenfelder + +| Gegner | Monster Category | +|---|---| +| Aschenratte | BEAST | +| Verwilderter Straßenhund | BEAST | +| Straßenräuber | HUMANOID | +| Plünderer-Späher | HUMANOID | +| Plünderer-Veteran | HUMANOID | +| Verkohlter Plünderer | HUMANOID | +| Aschenwühler | ABERRATION | +| Verbrannter Jagdhund | BEAST | +| Hauptmann der Aschenbande | HUMANOID | + +## Dämmerwald + +| Gegner | Monster Category | +|---|---| +| Dämmerwolf | BEAST | +| Moorkrabbler | ABERRATION | +| Waldplünderer | HUMANOID | +| Giftspinne | INSECT | +| Verdorbener Hirsch | BEAST | +| Schwarzmähnenwolf | BEAST | +| Dornenbestie | ABERRATION | +| Giftweberin | INSECT | +| Graufang | BEAST | +| Schattenalpha | BEAST | + +## Vergessene Ruinen + +| Gegner | Monster Category | +|---|---| +| Verlorener Soldat | UNDEAD | +| Grabräuber | HUMANOID | +| Ruinenwächter | UNDEAD | +| Knochenrufer | UNDEAD | +| Gefallener Ritter | UNDEAD | +| Grabkriecher | ABERRATION | +| Namenloser Bannerträger | UNDEAD | +| Aschenpriester | HUMANOID | +| Grabwächter | UNDEAD | +| Verdorbener Akolyth | HUMANOID | +| Knochenritter | UNDEAD | +| Sir Varos | UNDEAD | +| Knochenfürst | UNDEAD | + +Diese Zuordnung ist eine erste fachliche Einordnung und kann beim Content-Review noch angepasst werden. + +--- + +# 34. Bestehende Materialien müssen kategorisiert werden + +Beispiele aus dem aktuellen Loot-Design: + +| Item | Loot Category | +|---|---| +| Aschenfell / Tierrest | HIDE | +| Zähes Fell | HIDE | +| Verkohlte Chitinplatte | TROPHY | +| Dämmerfell | HIDE | +| Moorkarapax | TROPHY | +| Giftdrüse | TROPHY | +| Bannerfragment der letzten Wacht | QUEST_ITEM / keine Bag Category | + +Das Bannerfragment bleibt wegen seiner Quest-/Lore-Funktion außerhalb des normalen Taschensystems. + +--- + +# 35. Neue humanoide Handelsbeute + +Da humanoide Gegner bisher häufig direkt Silber liefern, sollte das neue Ruf- und Taschensystem diese Drops langfristig durch physische Handelsware ersetzen. + +Beispiele: + +- Plündererabzeichen +- gestohlene Münzen +- Räuberplakette +- Veteraneninsignie +- verbranntes Bandenzeichen + +Diese Gegenstände können Kategorien wie: + +- HUMANOID_SPOILS +- COIN + +verwenden. + +Die konkreten Händlerwerte werden separat gebalanced. + +--- + +# 36. Neue Untotenbeute + +Für die Vergessenen Ruinen sollten physische Handelswaren eingeführt werden. + +Beispiele: + +- verwittertes Siegel +- Knochenfragment +- Grabmünze +- Reliquienrest +- Zeichen der letzten Wacht + +Diese verwenden hauptsächlich: + +`RELIC` + +Einzelne Grabmünzen können alternativ: + +`COIN` + +verwenden. + +--- + +# 37. Nicht jeder Gegner muss jede Kategorie droppen + +Monster Categories definieren keine verpflichtende Anzahl von Drops. + +Beispiel: + +Ein Wolf kann ausschließlich Fell droppen. + +Ein stärkerer Wolf kann Fell und Trophy droppen. + +Ein Boss kann zusätzlich Equipment droppen. + +Loot Tables bleiben bewusst individuell. + +--- + +# 38. Taschen dürfen Loot-Identität verstärken + +Taschen sollen sichtbar machen, welche Art von Beute eine Region prägt. + +Beispiele: + +Aschenfelder: + +> Felle und Plünderergut + +Dämmerwald: + +> Felle und Jagdtrophäen + +Vergessene Ruinen: + +> Reliquien und Grabbeute + +Damit werden Regionen wirtschaftlich unterscheidbarer. + +--- + +# 39. Keine Taschenpflicht für Equipment-Progression + +Ein Spieler darf niemals daran gehindert werden, ein relevantes Equipment-Upgrade zu erhalten, nur weil eine Beutetasche voll ist. + +Equipment verwendet weiterhin das normale Inventarsystem. + +Das Taschensystem begrenzt primär: + +- Handelsware +- Materialien +- spätere Crafting-Ressourcen + +nicht den Kern-Loot der Charakterprogression. + +--- + +# 40. Verhältnis zum Rufsystem + +Das Taschensystem bildet die Transportebene des neuen Ruf- und Wirtschaftskreislaufs. + +Grundloop: + +```text +Monster +→ physische Beute +→ passende Tasche +→ Händler / NPC +→ Verwertung +→ Silber / Regionalruf / Weltruhm +``` + +Der genaue Umrechnungsschritt wird nicht in dieser Spezifikation definiert. + +--- + +# 41. Verhältnis zu Crafting + +Crafting ist weiterhin nicht Bestandteil des ersten Vertical Slice. + +Materialien sollten trotzdem so modelliert werden, dass sie später weiterverwendet werden können. + +Beispiel: + +Heute: + +```text +Dämmerfell +→ Handelsware +``` + +Später: + +```text +Dämmerfell +→ Handelsware +oder +→ Lederverarbeitung +``` + +Die Loot Category muss dafür nicht verändert werden. + +--- + +# 42. Verhältnis zum normalen Inventar + +Die bisherige Regel eines einfachen slotbasierten Inventars bleibt bestehen. + +Beutetaschen ersetzen dieses System nicht. + +Trennung: + +```text +Equipment +→ Inventar + +normale Items +→ Inventar + +Kampfverbrauchsitems +→ Inventar + Kampftasche + +Questitems +→ Questinventar + +kategorisierte Handelsbeute +→ Beutetaschen +``` + +--- + +# 43. Keine unnötige Mikromanagement-Komplexität + +Für V1 ausdrücklich nicht enthalten: + +- Gewichtsberechnung +- unterschiedliche Itemgrößen +- Tetris-Inventar +- Taschen in Taschen +- manuelles Verschieben einzelner Materialien zwischen Taschen +- mehrere Taschen derselben Kategorie gleichzeitig +- Verschleiß von Taschen +- zufällige Taschenattribute +- Bag Enchantments +- Qualitätsboni auf Verkaufspreise +- automatisches Sortieren durch den Spieler konfigurieren + +--- + +# 44. Datengetriebene Architektur + +Das System soll ohne monsterspezifische Sonderprogrammierung funktionieren. + +Neue Inhalte sollen hauptsächlich durch Daten entstehen: + +```text +MonsterDefinition +→ MonsterCategory + +LootTable +→ ItemDefinition + +ItemDefinition +→ LootCategory + +BagDefinition +→ supportedLootCategories +→ capacity +``` + +Dadurch kann ein neuer Gegner ohne Änderung des Kerncodes neue kategorisierte Beute erhalten. + +--- + +# 45. Content-Validierung + +Beim Seed oder später im CMS sollten Plausibilitätsprüfungen möglich sein. + +Beispiele: + +Ein `TRADE_GOOD` sollte normalerweise eine Loot Category besitzen. + +Ein Equipment-Item sollte normalerweise keine Loot Category besitzen. + +Eine BagDefinition muss mindestens eine unterstützte Loot Category besitzen. + +Kapazität muss größer als 0 sein. + +Loot Categories müssen als gültige Enumwerte existieren. + +--- + +# 46. API-Ausgabe + +Inventar- oder Character-State sollte die aktiven Taschen mitsenden können. + +Beispiel: + +```json +{ + "bags": [ + { + "key": "simple-hide-pouch", + "name": "Einfacher Fellbeutel", + "lootCategories": ["HIDE"], + "capacity": 5, + "used": 3 + } + ] +} +``` + +Der Client berechnet verfügbare Kapazität nicht selbst. + +--- + +# 47. Loot-Ergebnis + +Das Ergebnis eines Kampfes sollte unterscheiden zwischen: + +```text +erhalten +nicht erhalten +``` + +Beispiel: + +```json +{ + "loot": [ + { + "itemKey": "ash-hide", + "rolledQuantity": 2, + "acceptedQuantity": 1, + "rejectedQuantity": 1, + "reason": "BAG_FULL" + } + ] +} +``` + +Dadurch kann die UI korrekt erklären, was passiert ist. + +--- + +# 48. Erste Implementierungsreihenfolge + +Empfohlene Reihenfolge: + +1. `MonsterCategory` einführen. +2. Bestehende Monster kategorisieren. +3. `LootCategory` einführen. +4. Materialien und Handelswaren kategorisieren. +5. `BagDefinition` erstellen. +6. `CharacterBag` erstellen. +7. Kapazitätsservice implementieren. +8. LootService um Bag-Prüfung erweitern. +9. Loot-API um akzeptierte und zurückgelassene Beute erweitern. +10. Taschenübersicht im Inventar anzeigen. +11. Bestehende direkten Monster-Silberdrops später auf physische Handelsware umstellen. +12. Händler- und Rufsystem daran anbinden. + +--- + +# 49. Definition of Done + +Das Taschensystem ist für V1 vollständig implementiert, wenn: + +- jedes relevante Monster eine Monster Category besitzt +- Handelsmaterialien eine Loot Category besitzen +- der Spieler passende Taschen besitzen kann +- Taschen eine feste Kapazität besitzen +- der Server Kapazität vor Lootvergabe prüft +- teilweise Lootaufnahme unterstützt wird +- volle Taschen keine Equipment-Drops blockieren +- Questitems keine Taschenkapazität verbrauchen +- die UI aktuelle und maximale Kapazität zeigt +- Loot-Ergebnisse zurückgelassene Beute eindeutig anzeigen +- das System datengetrieben um neue Kategorien erweitert werden kann + +--- + +# 50. Kurzfassung + +Ashen Realms verwendet spezialisierte Beutetaschen für kategorisierte Materialien und Handelswaren. + +Monster besitzen eine: + +**Monster Category** + +Gegenstände besitzen optional eine: + +**Loot Category** + +Taschen unterstützen bestimmte Loot Categories und besitzen eine feste Kapazität. + +Beispiel: + +```text +Dämmerwolf +Monster Category: BEAST + +Drops: +Dämmerfell → HIDE +Wolfsklaue → TROPHY + +Fellbeutel: +8 / 10 + +Jagdtasche: +3 / 5 +``` + +Das System erzeugt dadurch einen natürlichen Rhythmus aus: + +**Jagen → Taschen füllen → zurückkehren → Beute verwerten → Taschen verbessern → längere Expeditionen durchführen** + +Der wichtigste Grundsatz lautet: + +> Taschen sollen Expeditionen strukturieren und Fortschritt sichtbar machen, ohne das Inventar in ein kompliziertes Verwaltungs- oder Gewichtssystem zu verwandeln. \ No newline at end of file diff --git a/tools/extract-item-icons.ps1 b/tools/extract-item-icons.ps1 new file mode 100644 index 0000000..3a836d3 --- /dev/null +++ b/tools/extract-item-icons.ps1 @@ -0,0 +1,65 @@ +# 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() +}