Merge branch 'worktree-slice-0.4-first-loot'

This commit is contained in:
Bastian Wagner
2026-08-20 10:33:39 +02:00
68 changed files with 6837 additions and 30 deletions

View File

@@ -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<Character>;
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),

View File

@@ -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,

View File

@@ -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;

View File

@@ -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())

View File

@@ -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],

View File

@@ -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> = {}): 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);
});
});
});

View File

@@ -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<CombatDto | null> {
@@ -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<CombatDto> {
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,
};
}
}

View File

@@ -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())

View File

@@ -0,0 +1,174 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateLootAndRewards1788600000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Existing characters keep their progression; silver simply starts at 0.
await queryRunner.query(
'ALTER TABLE "characters" ADD COLUMN "silver" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
"CREATE TYPE \"item_type_enum\" AS ENUM ('WEAPON', 'ARMOR', 'MATERIAL', 'CONSUMABLE')",
);
await queryRunner.query(
"CREATE TYPE \"equipment_slot_enum\" AS ENUM ('WEAPON', 'HEAD', 'CHEST', 'HANDS', 'LEGS', 'FEET', 'AMULET')",
);
await queryRunner.query(
"CREATE TYPE \"item_rarity_enum\" AS ENUM ('COMMON', 'RARE', 'EPIC')",
);
await queryRunner.query(`CREATE TABLE "item_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"description" text NOT NULL,
"type" "item_type_enum" NOT NULL,
"equipment_slot" "equipment_slot_enum",
"rarity" "item_rarity_enum" NOT NULL,
"tier" integer NOT NULL,
"required_level" integer NOT NULL,
"weapon_damage" integer NOT NULL DEFAULT 0,
"bonus_hp" integer NOT NULL DEFAULT 0,
"bonus_attack" integer NOT NULL DEFAULT 0,
"bonus_armor" integer NOT NULL DEFAULT 0,
"sell_price" integer NOT NULL DEFAULT 0,
"icon_path" character varying(255) NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_item_definitions" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_item_definitions_key" ON "item_definitions" ("key")',
);
await queryRunner.query(`CREATE TABLE "loot_tables" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_loot_tables" PRIMARY KEY ("id")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_tables_key" ON "loot_tables" ("key")',
);
await queryRunner.query(`CREATE TABLE "loot_table_entries" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"loot_table_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"position" integer NOT NULL,
"drop_chance" numeric(5,4) NOT NULL,
"min_quantity" integer NOT NULL DEFAULT 1,
"max_quantity" integer NOT NULL DEFAULT 1,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_loot_table_entries" PRIMARY KEY ("id"),
CONSTRAINT "CHK_loot_table_entries_drop_chance" CHECK ("drop_chance" >= 0 AND "drop_chance" <= 1),
CONSTRAINT "CHK_loot_table_entries_quantity" CHECK ("min_quantity" >= 1 AND "max_quantity" >= "min_quantity"),
CONSTRAINT "FK_loot_table_entries_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_loot_table_entries_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_position" ON "loot_table_entries" ("loot_table_id", "position")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_table_entries_table_item" ON "loot_table_entries" ("loot_table_id", "item_definition_id")',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id" uuid',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD CONSTRAINT "FK_monster_definitions_loot_table" FOREIGN KEY ("loot_table_id") REFERENCES "loot_tables"("id") ON DELETE RESTRICT ON UPDATE NO ACTION',
);
await queryRunner.query(
'CREATE INDEX "IDX_monster_definitions_loot_table" ON "monster_definitions" ("loot_table_id")',
);
await queryRunner.query(`CREATE TABLE "character_items" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"quantity" integer NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_items" PRIMARY KEY ("id"),
CONSTRAINT "CHK_character_items_quantity" CHECK ("quantity" >= 1),
CONSTRAINT "FK_character_items_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_items_character_item" ON "character_items" ("character_id", "item_definition_id")',
);
await queryRunner.query(`CREATE TABLE "combat_rewards" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"combat_id" uuid NOT NULL,
"character_id" uuid NOT NULL,
"experience_granted" integer NOT NULL,
"silver_granted" integer NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_combat_rewards" PRIMARY KEY ("id"),
CONSTRAINT "FK_combat_rewards_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_combat_rewards_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
// The database half of the "one reward per combat" invariant (spec §7).
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combat_rewards_combat" ON "combat_rewards" ("combat_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_combat_rewards_character" ON "combat_rewards" ("character_id")',
);
await queryRunner.query(`CREATE TABLE "combat_reward_items" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"combat_reward_id" uuid NOT NULL,
"character_item_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"quantity" integer NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_combat_reward_items" PRIMARY KEY ("id"),
CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1),
CONSTRAINT "FK_combat_reward_items_reward" FOREIGN KEY ("combat_reward_id") REFERENCES "combat_rewards"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_combat_reward_items_character_item" FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_combat_reward_items_item_definition" FOREIGN KEY ("item_definition_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_combat_reward_items_reward" ON "combat_reward_items" ("combat_reward_id")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item" ON "combat_reward_items" ("combat_reward_id", "item_definition_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward_item"');
await queryRunner.query('DROP INDEX "IDX_combat_reward_items_reward"');
await queryRunner.query('DROP TABLE "combat_reward_items"');
await queryRunner.query('DROP INDEX "IDX_combat_rewards_character"');
await queryRunner.query('DROP INDEX "IDX_combat_rewards_combat"');
await queryRunner.query('DROP TABLE "combat_rewards"');
await queryRunner.query('DROP INDEX "IDX_character_items_character_item"');
await queryRunner.query('DROP TABLE "character_items"');
await queryRunner.query('DROP INDEX "IDX_monster_definitions_loot_table"');
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP CONSTRAINT "FK_monster_definitions_loot_table"',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"',
);
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"');
await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"');
await queryRunner.query('DROP TABLE "loot_table_entries"');
await queryRunner.query('DROP INDEX "IDX_loot_tables_key"');
await queryRunner.query('DROP TABLE "loot_tables"');
await queryRunner.query('DROP INDEX "IDX_item_definitions_key"');
await queryRunner.query('DROP TABLE "item_definitions"');
await queryRunner.query('DROP TYPE "item_rarity_enum"');
await queryRunner.query('DROP TYPE "equipment_slot_enum"');
await queryRunner.query('DROP TYPE "item_type_enum"');
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "silver"');
}
}

View File

@@ -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"',
]),
);
});
});

View File

@@ -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<Pick<SeedItemDefinition, 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'>> = {},
): SeedItemDefinition {
return {
id: ITEM_IDS[key],
key,
name,
description,
type,
equipmentSlot,
rarity,
tier: 1,
requiredLevel: 1,
weaponDamage: stats.weaponDamage ?? 0,
bonusHp: stats.bonusHp ?? 0,
bonusAttack: stats.bonusAttack ?? 0,
bonusArmor: stats.bonusArmor ?? 0,
// Always 0: no merchants exist in Slice 0.4, and the balancing doc's
// Grenzmarken table lists purchase prices, not sell prices.
sellPrice: 0,
iconPath: `/images/items/${key}.png`,
};
}
// Stats from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §19.
export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
item(
'worn-short-sword',
'Abgenutztes Kurzschwert',
'Die Klinge eines Rekruten, öfter geschliffen als geführt.',
ItemType.WEAPON,
EquipmentSlot.WEAPON,
ItemRarity.COMMON,
{ weaponDamage: 8 },
),
item(
'bandit-blade',
'Räuberklinge',
'Eine grob gezahnte Klinge, geschmiedet für schnelle Überfälle.',
ItemType.WEAPON,
EquipmentSlot.WEAPON,
ItemRarity.COMMON,
{ weaponDamage: 11, bonusAttack: 1 },
),
item(
'ash-blade',
'Aschenklinge',
'In der Glut der Aschenfelder gehärtet; die Schneide glimmt noch.',
ItemType.WEAPON,
EquipmentSlot.WEAPON,
ItemRarity.RARE,
{ weaponDamage: 15, bonusAttack: 2 },
),
item(
'bandit-hood',
'Räuberhaube',
'Vernarbtes Leder, das Gesicht und Absicht des Trägers verbirgt.',
ItemType.ARMOR,
EquipmentSlot.HEAD,
ItemRarity.COMMON,
{ bonusArmor: 3, bonusHp: 5 },
),
item(
'reinforced-leather-jacket',
'Verstärkte Lederjacke',
'Mit Eisenplatten benähtes Leder, schwer und verlässlich.',
ItemType.ARMOR,
EquipmentSlot.CHEST,
ItemRarity.RARE,
{ bonusArmor: 7, bonusHp: 10 },
),
item(
'raider-gloves',
'Plündererhandschuhe',
'Beschlagene Handschuhe, abgegriffen von fremdem Gut.',
ItemType.ARMOR,
EquipmentSlot.HANDS,
ItemRarity.COMMON,
{ bonusArmor: 3, bonusAttack: 1 },
),
item(
'guardsman-legs',
'Wachmannsbeinkleid',
'Beinzeug der Grenzwacht, an den Knien geflickt.',
ItemType.ARMOR,
EquipmentSlot.LEGS,
ItemRarity.RARE,
{ bonusArmor: 5, bonusHp: 5 },
),
item(
'ash-boots',
'Aschenstiefel',
'Stiefel, die durch glimmende Felder getragen wurden und blieben.',
ItemType.ARMOR,
EquipmentSlot.FEET,
ItemRarity.RARE,
{ bonusArmor: 4, bonusHp: 5 },
),
item(
'borderwatch-sigil',
'Zeichen der Grenzwacht',
'Das Wappen eines Turms, den es nicht mehr gibt.',
ItemType.ARMOR,
EquipmentSlot.AMULET,
ItemRarity.RARE,
{ bonusAttack: 3, bonusHp: 10 },
),
item(
'burned-captain-pendant',
'Anhänger des verbrannten Hauptmanns',
'Ein Schädel aus Schlacke, in dem die Glut nie erlosch.',
ItemType.ARMOR,
EquipmentSlot.AMULET,
ItemRarity.EPIC,
{ bonusAttack: 3, bonusHp: 15, bonusArmor: 2 },
),
// Seeded as content only. Slice 0.4 implements no consumable use, and the
// Straßenräuber loot entry for it is deliberately deferred (spec §16).
item(
'small-healing-potion',
'Kleiner Heiltrank',
'Ein bitterer Sud, der Wunden für einen Atemzug vergessen lässt.',
ItemType.CONSUMABLE,
null,
ItemRarity.COMMON,
),
item(
'ash-pelt',
'Aschenfell',
'Versengtes Fell, zäh wie Leder und grau von Ascheflug.',
ItemType.MATERIAL,
null,
ItemRarity.COMMON,
),
];
export const LOOT_TABLES = [
{ id: ASH_RAT_LOOT_TABLE_ID, key: 'ash-rat-loot', name: 'Aschenratte Beute' },
{ id: ROAD_BANDIT_LOOT_TABLE_ID, key: 'road-bandit-loot', name: 'Straßenräuber Beute' },
];
export interface SeedLootTableEntry {
lootTableId: string;
itemDefinitionId: string;
position: number;
dropChance: string;
minQuantity: number;
maxQuantity: number;
enabled: boolean;
}
function entry(
lootTableId: string,
key: ItemKey,
position: number,
dropChance: string,
): SeedLootTableEntry {
return {
lootTableId,
itemDefinitionId: ITEM_IDS[key],
position,
dropChance,
minQuantity: 1,
maxQuantity: 1,
enabled: true,
};
}
/**
* Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §2728.
* 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'),
];

View File

@@ -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';

View File

@@ -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<string, unknown>;
@@ -67,24 +71,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');
}),
@@ -256,4 +256,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 }),
]),
);
});
});

View File

@@ -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 {
ASH_RAT_LOOT_TABLE_ID,
ROAD_BANDIT_LOOT_TABLE_ID,
} from './item.constants';
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
import {
ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID,
@@ -21,6 +29,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 = [
{
@@ -95,6 +106,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,
@@ -108,6 +128,7 @@ export async function seedVisibleVerticalSlice(
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID,
},
{
id: ROAD_BANDIT_MONSTER_ID,
@@ -121,6 +142,7 @@ export async function seedVisibleVerticalSlice(
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},
];
let ashRatId = ASH_RAT_MONSTER_ID;
@@ -176,6 +198,7 @@ export async function seedVisibleVerticalSlice(
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,

View File

@@ -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: [

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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',
}

View File

@@ -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',
}

View File

@@ -0,0 +1,6 @@
export enum ItemType {
WEAPON = 'WEAPON',
ARMOR = 'ARMOR',
MATERIAL = 'MATERIAL',
CONSUMABLE = 'CONSUMABLE',
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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>): 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: [] });
});
});

View File

@@ -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<LootRollResult> {
if (!lootTableId) {
return { items: [] };
}
const entries = await (
manager?.getRepository(LootTableEntry) ??
this.dataSource.getRepository(LootTableEntry)
).find({
where: { lootTableId, enabled: true },
order: { position: 'ASC' },
});
const items: LootRollItem[] = [];
for (const entry of entries) {
if (this.randomSource.next() >= Number(entry.dropChance)) {
continue;
}
items.push({
itemDefinitionId: entry.itemDefinitionId,
quantity: rollInclusive(
this.randomSource,
entry.minQuantity,
entry.maxQuantity,
),
});
}
return { items };
}
}

View File

@@ -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 })
@@ -43,9 +46,16 @@ export class MonsterDefinition {
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: 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;
}

View File

@@ -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<T extends { id: string }> {
constructor(
private readonly rows: T[],
private readonly prefix: string,
) {}
findOne(options: { where: Partial<T> }): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null);
}
find(options: {
where: Partial<T>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
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>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = `${this.prefix}-${this.rows.length + 1}`;
}
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
this.rows.push(entity);
} else {
this.rows[index] = entity;
}
return Promise.resolve(entity);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
}
function fakeManager(state: State): EntityManager {
return {
getRepository: <T extends { id: string }>(target: EntityTarget<T>) => {
if (target === Character) return new FakeRepository(state.characters, 'character') as never;
if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never;
if (target === ItemDefinition)
return new FakeRepository(state.itemDefinitions, 'definition') as never;
if (target === CharacterItem)
return new FakeRepository(state.characterItems, 'character-item') as never;
if (target === CombatReward)
return new FakeRepository(state.combatRewards, 'reward') as never;
if (target === CombatRewardItem)
return new FakeRepository(state.combatRewardItems, 'reward-item') as never;
throw new Error('Unsupported repository');
},
} as unknown as EntityManager;
}
function combat(overrides: Partial<Combat> = {}): Combat {
return {
id: COMBAT_ID,
characterId: CHARACTER_ID,
monsterDefinitionId: ASH_RAT_ID,
status: CombatStatus.WON,
round: 4,
...overrides,
} as Combat;
}
function createState(overrides: Partial<State> = {}): State {
return {
characters: [{ id: CHARACTER_ID, experience: 12, silver: 3 } as Character],
monsters: [
{
id: ASH_RAT_ID,
key: 'ash-rat',
experienceReward: 8,
silverMin: 4,
silverMax: 7,
lootTableId: ASH_RAT_TABLE,
} as MonsterDefinition,
{
id: ROAD_BANDIT_ID,
key: 'road-bandit',
experienceReward: 16,
silverMin: 9,
silverMax: 15,
lootTableId: ROAD_BANDIT_TABLE,
} as MonsterDefinition,
],
itemDefinitions: [
{
id: BANDIT_BLADE,
key: 'bandit-blade',
name: 'Räuberklinge',
type: ItemType.WEAPON,
rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png',
} as ItemDefinition,
],
characterItems: [],
combatRewards: [],
combatRewardItems: [],
...overrides,
};
}
function 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);
});
});
});

View File

@@ -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<DataSource, 'getRepository'>;
@Injectable()
export class CombatRewardService {
constructor(
private readonly dataSource: DataSource,
private readonly lootService: LootService,
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
) {}
/**
* Grants a won combat's rewards exactly once (spec §7, §21).
*
* Runs inside the caller's transaction — `CombatService.performAction`
* already holds a pessimistic write lock on the combat row — so either
* everything below commits or nothing does.
*
* Roll order is fixed: silver first, then the loot table in `position`
* order. Tests depend on it.
*/
async grantVictoryRewards(
manager: EntityManager,
combat: Combat,
): Promise<CombatRewardDto> {
if (combat.status !== CombatStatus.WON) {
throw combatNotWon();
}
const rewards = manager.getRepository(CombatReward);
const existing = await rewards.findOne({ where: { combatId: combat.id } });
if (existing) {
// Already rewarded: replay rather than roll again.
return this.toDto(manager, existing);
}
const monster = await manager
.getRepository(MonsterDefinition)
.findOneBy({ id: combat.monsterDefinitionId });
if (!monster) {
throw rewardStateInvalid();
}
const experience = monster.experienceReward;
const silver = rollInclusive(
this.randomSource,
monster.silverMin,
monster.silverMax,
);
const roll = await this.lootService.rollLoot(monster.lootTableId, manager);
// 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<string, ItemDefinition>();
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<CombatRewardDto | null> {
const scope: RepositoryScope = manager ?? this.dataSource;
const reward = await scope
.getRepository(CombatReward)
.findOne({ where: { combatId } });
return reward ? this.toDto(scope, reward) : null;
}
private async toDto(
scope: RepositoryScope,
reward: CombatReward,
): Promise<CombatRewardDto> {
// 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,
};
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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.',
);
}

View File

@@ -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 {}

View File

@@ -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);
});
});

View File

@@ -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)));
}

View File

@@ -179,6 +179,7 @@ function createState(): FakeState {
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -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', () => {

View File

@@ -9,6 +9,7 @@ export interface CharacterResponse {
name: string;
level: number;
experience: number;
silver: number;
currentHp: number;
maxHp: number;
attack: number;
@@ -105,4 +106,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[];
}

View File

@@ -86,7 +86,37 @@
<div class="outcome outcome--won" data-combat-result="WON">
<h2 class="outcome__title">Sieg</h2>
<p>{{ combat.monster.name }} wurde besiegt.</p>
<p class="outcome__hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
@if (combat.rewards; as rewards) {
<section class="rewards" data-combat-rewards aria-label="Belohnungen">
<h3 class="rewards__title">Belohnungen</h3>
<dl class="rewards__currencies">
<div class="rewards__currency" data-reward-experience>
<dt>Erfahrung</dt>
<dd>+{{ rewards.experience }} XP</dd>
</div>
<div class="rewards__currency" data-reward-silver>
<dt>Silber</dt>
<dd>+{{ rewards.silver }}</dd>
</div>
</dl>
@if (rewards.items.length) {
<h3 class="rewards__title">Beute</h3>
<ul class="rewards__loot">
@for (reward of rewards.items; track reward.characterItemId) {
<li>
<app-item-card [item]="reward.item" [quantity]="reward.quantity" />
</li>
}
</ul>
} @else {
<p class="outcome__hint" data-reward-empty>Keine besondere Beute gefunden.</p>
}
</section>
}
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
</button>

View File

@@ -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;
}

View File

@@ -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<typeof vi.fn>;
attack: ReturnType<typeof vi.fn>;
};
let worldStore: { refreshCharacter: ReturnType<typeof vi.fn> };
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<HTMLButtonElement>('[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<HTMLImageElement>('[data-item-icon]')?.getAttribute('src')).toBe(
'/images/items/bandit-blade.png',
);
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge');
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich');
expect(element.querySelector('[data-reward-empty]')).toBeNull();
});
it('treats a victory without loot as complete, not as a failure', async () => {
const fixture = await setup(wonWithRewards);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-reward-empty]')?.textContent).toContain(
'Keine besondere Beute gefunden.',
);
expect(element.querySelector('[role="alert"]')).toBeNull();
// XP and silver still carry the screen.
expect(element.querySelector('[data-reward-experience]')).toBeTruthy();
});
it('renders the persisted rewards of an already-won combat loaded from the server', async () => {
// Simulates a browser refresh: the page loads the combat by id and shows
// exactly what the server persisted, without rerolling anything.
const fixture = await setup({
...wonWithRewards,
rewards: { experience: 16, silver: 12, items: [] },
});
const element = fixture.nativeElement as HTMLElement;
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
expect(element.querySelector('[data-reward-experience]')?.textContent).toContain('16');
expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('12');
});
it('still shows a plain victory when the server reports no reward record', async () => {
const fixture = await setup({ ...wonWithRewards, rewards: null });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.querySelector('[data-combat-rewards]')).toBeNull();
});
});

View File

@@ -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',

View File

@@ -20,6 +20,7 @@ const startedCombat: Combat = {
artworkPath: '/images/monsters/ash-rat.png',
},
events: [],
rewards: null,
};
const afterAttack: Combat = {

View File

@@ -79,6 +79,7 @@ const startedCombat: Combat = {
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
rewards: null,
};
describe('HuntPageComponent', () => {

View File

@@ -15,6 +15,7 @@ const character: CharacterResponse = {
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
currentHp: 100,
maxHp: 100,
attack: 6,
@@ -331,4 +332,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();
});
});

View File

@@ -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<void> {
if (this.destroyed) {
return;
}
try {
const character = await firstValueFrom(this.api.getCharacter());
if (!this.destroyed) {
this.characterState.set(character);
}
} catch {
// Keep the previous character; the next load() will resync.
}
}
async startTravel(): Promise<void> {
if (this.destroyed) {
return;

View File

@@ -15,6 +15,16 @@
></span>
</span>
</div>
<dl class="top-bar__resources">
<div class="top-bar__resource" data-top-bar-silver>
<dt>Silber</dt>
<dd>{{ character.silver }}</dd>
</div>
<div class="top-bar__resource" data-top-bar-experience>
<dt>XP</dt>
<dd>{{ character.experience }}</dd>
</div>
</dl>
} @else {
<span class="top-bar__loading">Charakterdaten werden geladen</span>
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
<article class="item-card" [class]="rarityModifier()">
<div class="item-card__frame">
<img class="item-card__icon" data-item-icon [src]="item().iconPath" [alt]="item().name" />
@if (quantity() > 1) {
<span class="item-card__quantity" data-item-quantity>×{{ quantity() }}</span>
}
</div>
<p class="item-card__name" data-item-name>{{ item().name }}</p>
<p class="item-card__rarity" data-item-rarity>{{ rarityLabel() }}</p>
</article>

View File

@@ -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);
}

View File

@@ -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<HTMLImageElement>('[data-item-icon]');
expect(icon?.getAttribute('src')).toBe('/images/items/bandit-blade.png');
expect(icon?.getAttribute('alt')).toBe('Räuberklinge');
expect(element.querySelector('[data-item-name]')?.textContent).toContain('Räuberklinge');
expect(element.querySelector('[data-item-rarity]')?.textContent).toContain('Gewöhnlich');
});
it('labels the other rarities in German too', async () => {
const rare = await setup({ ...banditBlade, rarity: 'RARE' });
expect(
(rare.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent,
).toContain('Selten');
const epic = await setup({ ...banditBlade, rarity: 'EPIC' });
expect(
(epic.nativeElement as HTMLElement).querySelector('[data-item-rarity]')?.textContent,
).toContain('Episch');
});
it('hides the quantity for a single item and shows it for a stack', async () => {
const single = await setup(banditBlade, 1);
expect((single.nativeElement as HTMLElement).querySelector('[data-item-quantity]')).toBeNull();
const stack = await setup(banditBlade, 3);
expect(
(stack.nativeElement as HTMLElement).querySelector('[data-item-quantity]')?.textContent,
).toContain('3');
});
it('offers no equip or comparison affordance yet', async () => {
const fixture = await setup(banditBlade);
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('button')).toBeNull();
expect(element.textContent).not.toContain('Anlegen');
});
});

View File

@@ -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<Record<ItemRarity, string>> = {
COMMON: 'Gewöhnlich',
RARE: 'Selten',
EPIC: 'Episch',
};
/**
* Compact item presentation for loot (spec §33).
*
* Deliberately read-only: equipping, stat comparison, and slot replacement
* belong to Slice 0.5.
*/
@Component({
selector: 'app-item-card',
templateUrl: './item-card.component.html',
styleUrl: './item-card.component.scss',
})
export class ItemCardComponent {
readonly item = input.required<RewardItemSummary>();
readonly quantity = input(1);
protected readonly rarityLabel = computed(() => RARITY_LABELS[this.item().rarity]);
protected readonly rarityModifier = computed(() => `item-card--${this.item().rarity.toLowerCase()}`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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()
}