Merge branch 'master' into worktree-encounter-status

This commit is contained in:
Bastian Wagner
2026-08-20 10:34:52 +02:00
82 changed files with 12212 additions and 37 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

@@ -6,6 +6,7 @@ import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
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';
@@ -174,6 +175,7 @@ function character(overrides: Partial<Character> = {}): Character {
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
@@ -248,6 +250,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 } = {},
) {
@@ -261,6 +277,7 @@ function createService(
travelService,
combatEngine,
characterCombatStats,
fakeRewardService(),
);
return { dataSource, service, travelService };
}
@@ -599,7 +616,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(
@@ -730,4 +750,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

@@ -7,6 +7,8 @@ import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum';
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';
@@ -58,6 +60,7 @@ export interface CombatDto {
player: CombatPlayerDto;
monster: CombatMonsterDto;
events: CombatEventDto[];
rewards: CombatRewardDto | null;
}
@Injectable()
@@ -67,6 +70,7 @@ export class CombatService {
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterCombatStats: CharacterCombatStatsService,
private readonly combatRewards: CombatRewardService,
) {}
async startCombat(
@@ -147,7 +151,7 @@ export class CombatService {
encounter.status = HuntEncounterStatus.IN_PROGRESS;
await encounters.save(encounter);
return this.toCombatDto(combat, character.name, monster, []);
return this.toCombatDto(combat, character.name, monster, [], null);
});
}
@@ -160,13 +164,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> {
@@ -184,7 +189,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(
@@ -193,9 +198,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' },
@@ -242,6 +256,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,
@@ -254,7 +276,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);
});
}
@@ -354,6 +376,7 @@ export class CombatService {
playerName: string,
monster: MonsterDefinition,
events: CombatEvent[],
rewards: CombatRewardDto | null,
): CombatDto {
return {
id: combat.id,
@@ -380,6 +403,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

@@ -13,7 +13,7 @@ import { HuntEncounterStatus } from './hunt-encounter-status.enum';
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';
@@ -235,6 +235,7 @@ function character(currentLocation: LocationDefinition): Character {
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,

View File

@@ -17,8 +17,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,

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 404 KiB

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;
@@ -108,4 +109,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

@@ -1,6 +1,6 @@
<section class="combat" aria-label="Kampf">
@if (combat(); as combat) {
<div class="combat__stage" [class.combat__stage--shaken]="phase() === 'hit'">
<div class="combat__stage" [class.combat__stage--shaken]="stageShake()">
<header class="combat__status">
<div class="fighter fighter--player">
<img class="fighter__icon" [src]="playerIcon" alt="" />
@@ -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

@@ -20,7 +20,12 @@
container-type: inline-size;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4);
min-block-size: 26rem;
// The sprites are sized as a share of the stage, so the stage has to carry a
// height of its own. With only a min-block-size the tallest cut-out -- the
// road bandit -- sized the battlefield row from its intrinsic height and
// pushed the page into a scrollbar. The subtracted 12.5rem is the shell
// chrome around the main column: top bar, footer and its own padding.
block-size: max(24rem, calc(100dvh - 12.5rem));
padding: var(--ar-space-4);
overflow: hidden;
border: 1px solid var(--ar-border);
@@ -182,7 +187,11 @@
position: relative;
z-index: 1;
display: grid;
// The single row is spelled out rather than left implicit: only then is it a
// definite height, and only then do the sprites' percentage heights resolve
// against the battlefield instead of against their own artwork.
grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr);
align-items: end;
min-block-size: 0;
}
@@ -448,6 +457,10 @@
text-transform: uppercase;
}
.outcome--won {
inline-size: min(32rem, 90%);
}
.outcome--won .outcome__title {
color: var(--ar-gold);
}
@@ -602,7 +615,7 @@
}
.combat__stage {
min-block-size: clamp(24rem, 55vh, 34rem);
block-size: clamp(20rem, 55dvh, 34rem);
}
.combat__log {
@@ -641,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' }) } },
@@ -162,7 +167,8 @@ describe('CombatPageComponent', () => {
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(true);
// The stage jolt is wired up but no longer fires on an ordinary hit.
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
expect(element.textContent).toContain('90 / 100');
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(2);
@@ -208,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);
@@ -257,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 {
@@ -28,14 +30,18 @@ const SWING_MS = 540;
const RECOIL_MS = 540;
// Beat between the player's blow landing and the monster striking back.
const RIPOSTE_DELAY_MS = 260;
// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet.
const STAGE_SHAKE_MS = 200;
@Component({
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);
@@ -47,9 +53,15 @@ export class CombatPageComponent implements OnInit {
private readonly displayed = signal<Combat | null>(null);
private readonly replaying = signal(false);
// The stage jolt stays wired up but is no longer fired by an ordinary hit --
// it was too much for every single round. Call `shakeStage()` to bring it
// back for a specific ability.
private readonly stageShaking = signal(false);
protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle');
protected readonly monsterPhase = signal<MonsterPhase>('idle');
protected readonly stageShake = this.stageShaking.asReadonly();
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON;
@@ -87,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',
@@ -125,6 +143,16 @@ export class CombatPageComponent implements OnInit {
}
}
/** Jolts the whole stage once. Reserved for abilities; no attack triggers it. */
protected shakeStage(): void {
this.stageShaking.set(true);
setTimeout(() => {
if (!this.destroyed) {
this.stageShaking.set(false);
}
}, STAGE_SHAKE_MS);
}
protected retry(): void {
void this.loadFromRoute();
}

View File

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

View File

@@ -82,6 +82,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

@@ -1,12 +1,14 @@
<div class="app-shell">
<app-top-bar [character]="worldStore.character()" />
<div class="app-shell__content">
<div class="app-shell__content" [class.app-shell__content--no-context]="inCombat()">
<app-side-navigation />
<main class="app-shell__main" aria-label="Spielinhalt">
<router-outlet />
</main>
@if (!inCombat()) {
<app-context-panel />
}
</div>
<app-game-footer />

View File

@@ -16,6 +16,12 @@
min-block-size: 0;
}
// Without the context rail the main column takes its place. The narrow layouts
// below re-declare the template, so they keep working either way.
.app-shell__content--no-context {
grid-template-columns: minmax(11rem, 13rem) minmax(0, 1fr);
}
.app-shell__main {
min-inline-size: 0;
min-block-size: 0;

View File

@@ -1,5 +1,5 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Router, RouterOutlet, isActive } from '@angular/router';
import { WorldStore } from '../../features/world/world.store';
import { ContextPanelComponent } from '../context-panel/context-panel.component';
import { GameFooterComponent } from '../game-footer/game-footer.component';
@@ -20,4 +20,8 @@ import { TopBarComponent } from '../top-bar/top-bar.component';
})
export class AppShellComponent {
protected readonly worldStore = inject(WorldStore);
// The fight has its own log rail and wants the width, and the area info
// belongs to the world view anyway, so the rail is dropped during combat.
protected readonly inCombat = isActive('/combat', inject(Router));
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,921 @@
# Ashen Realms Local Location View Design Specification V1
## Zweck
Dieses Dokument definiert die dauerhaften Design- und Content-Regeln für die **lokale Ortsansicht** von Ashen Realms.
Die Ortsansicht bildet die fehlende Weltebene zwischen der globalen Karte und spezialisierten Aktivitäten wie Jagd, Kampf, Händler, Quests, Dungeons oder Bossen.
Sie beantwortet dem Spieler unmittelbar vier Fragen:
1. **Wo bin ich?**
2. **Wie sieht dieser Ort aus und wie fühlt er sich an?**
3. **Wer oder was befindet sich hier?**
4. **Was kann ich hier tun?**
Diese Spezifikation soll auch für zukünftige Gebiete und Orte gültig bleiben. Einzelne Orte dürfen sich visuell und spielerisch deutlich unterscheiden, verwenden aber dieselbe grundlegende Struktur und dasselbe Interaktionsmodell.
---
# 1. Rolle im Kernloop
Die lokale Ortsansicht wird zum normalen Ankunfts- und Ausgangsscreen, solange sich der Charakter physisch an einem Ort befindet.
Empfohlener Ablauf:
```text
Karte
→ Reise
→ Ortsansicht
→ Aktivität
→ Jagd
→ NPC
→ Untersuchung
→ Händler
→ Quest
→ Boss / Dungeon
→ zurück zur Ortsansicht
```
Die Karte beantwortet:
**Wohin kann ich reisen?**
Die Ortsansicht beantwortet:
**Was gibt es hier?**
Die Jagd beantwortet:
**Was kann ich hier bekämpfen?**
Der Kampf beantwortet:
**Wie besiege ich diesen Gegner?**
Diese Verantwortlichkeiten sollen getrennt bleiben.
---
# 2. Position in der Navigation
Die persistente linke Navigation erhält einen eigenen Eintrag:
```text
Ort
Karte
Jagd
Quests
Inventar
Charakter
Shop
```
`Ort` ist aktiv hervorgehoben, solange der Spieler die lokale Ortsansicht betrachtet.
Der Spieler soll grundsätzlich jederzeit zu dieser Ansicht zurückkehren können, sofern kein blockierender Gameplay-Zustand aktiv ist, beispielsweise ein noch nicht abgeschlossener Kampf.
---
# 3. Ziel des Spielerlebnisses
Ein Ort muss sich wie ein **Ort** anfühlen und nicht wie ein Datensatz oder Menüpunkt.
Der Screen soll beim Spieler das Gefühl erzeugen:
> Ich befinde mich gerade hier. Das ist meine Umgebung. Diese Personen und interessanten Punkte befinden sich hier. Von hier aus entscheide ich, was ich als Nächstes tue.
Die Ortsansicht ist deshalb kein Dashboard aus vielen kleinen Karten.
Das zentrale Artwork ist die wichtigste Präsentationsebene.
UI liegt darum herum oder gezielt darüber.
---
# 4. Grundaufbau des Screens
Jede Ortsansicht verwendet den bestehenden Ashen-Realms-App-Shell:
- persistente Topbar
- persistente linke Navigation
- große zentrale Artwork-Fläche
- kontextuelle rechte Sidebar
- persistenter Footer / Weltstatus
Der eigentliche Ortsinhalt besteht aus fünf funktionalen Ebenen.
## 4.1 Ortskopf
Zeigt:
- Ortsname
- Gebiet / Region als Breadcrumb
- kurze atmosphärische Beschreibung
Beispiel:
```text
Verbrannte Straße
Gebiet 1 > Aschenfelder
Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.
```
Die Beschreibung sollte meist in 13 kurzen Zeilen auskommen.
Längere Lore gehört in Dialoge, Quests, Bücher oder eigene Lore-Inhalte.
---
## 4.2 Großes Orts-Artwork
Das Orts-Artwork ist das visuelle Zentrum des Screens.
Es soll vermitteln:
- Biom
- lokale Gefahr
- Wetter / Atmosphäre
- Architektur
- Spuren vergangener Ereignisse
- wichtige Landmarken
Beispiele:
### Aschenfelder
- verbrannte Straßen
- Asche
- Glut
- tote Bäume
- zerstörte Wagen
- Rauch
### Dämmerwald
- dichte Vegetation
- Nebel
- dunkles Blätterdach
- überwucherte Ruinen
- Spuren verdorbener Tiere
### Vergessene Ruinen
- alter Stein
- Gräber
- eingestürzte Mauern
- Banner
- Krypteneingänge
- kaltes magisches Licht
Interaktionsbeschriftungen dürfen **nicht fest in das Artwork eingebrannt** sein.
Marker und Labels werden durch die UI gerendert.
---
# 5. Points of Interest und Hotspots
Ein Ort kann direkt auf dem Artwork interaktive **Points of Interest (POIs)** besitzen.
Ein POI besteht aus:
- Icon
- Titel
- optionalem kurzen Aktionslabel
- normalisierter X-/Y-Position im Artwork
- Interaktionstyp
- optionalem Verfügbarkeitszustand
Beispiel:
```text
[Truhen-Icon]
Verlassener Wagen
Durchsuchen
```
POIs sollen sich optisch in die Szene integrieren.
Der Screen darf dadurch nicht zu einem Hidden-Object-Spiel werden.
Empfohlene sichtbare Anzahl:
- einfacher Übergangsort: 13
- normaler Ort: 25
- großer Hub: 37
Wenn deutlich mehr Aktionen notwendig sind, müssen sie gruppiert oder in eigene Panels/Screens ausgelagert werden.
---
# 6. Wiederverwendbare Interaktionstypen
Die erste gemeinsame Interaktionssprache lautet:
```text
HUNT
INVESTIGATE
SEARCH
NPC
MAP
TRAVEL
SHOP
QUEST
BOSS
DUNGEON
```
Nicht jeder Typ muss bereits im ersten Slice vollständig umgesetzt sein.
Wichtig ist, dass neue Orte aus wiederverwendbaren Interaktionstypen aufgebaut werden und nicht jeweils eigene Angular-Spezialkomponenten erhalten.
## 6.1 HUNT
Zweck:
Startet die normale Jagd-/Encounter-Schleife des aktuellen Ortes.
Ablauf:
```text
Ortsansicht
→ Jagd
```
Die Ortsansicht würfelt oder rendert selbst keine Encounter-Auswahl.
---
## 6.2 INVESTIGATE
Zweck:
Untersucht Spuren, Ruinen, Symbole, Leichen, Zeichen oder andere Hinweise in der Umgebung.
Mögliche Ergebnisse:
- atmosphärischer Text
- Lore-Hinweis
- Hinweis auf einen Ort
- später Questfortschritt
- später versteckte Verbindung
Für die erste Umsetzung reicht ein serverseitig geliefertes Ergebnis-Panel.
---
## 6.3 SEARCH
Zweck:
Durchsucht ein konkretes Objekt oder einen kleinen Bereich.
Beispiele:
- verlassener Wagen
- alte Vorratskiste
- Lagerreste
- Kryptennische
Später mögliche Ergebnisse:
- Item
- Währung
- Verbrauchsgegenstand
- Hinweis
- nichts
Sobald eine Interaktion echte Belohnungen vergibt, muss sie serverautoritativ sein.
Die erste Version darf reine Text-/Informationsresultate verwenden.
---
## 6.4 NPC
Zweck:
Interaktion mit einem sichtbar am Ort vorhandenen NPC.
Für V1 reicht ein einfaches Dialogpanel.
Spätere Erweiterungen können sein:
- Quests
- Händler
- Ruf-/Tauschsystem
- Dienstleistungen
- Fraktionsinteraktionen
NPCs sollen möglichst im Kontext der Szene sichtbar sein und nicht ausschließlich als Textliste in einem Panel erscheinen.
---
## 6.5 MAP
Zweck:
Öffnet die bestehende Karten-/Reiseansicht.
```text
Ortsansicht
→ Karte
```
---
## 6.6 TRAVEL
Reisen bleibt Bestandteil des bestehenden Karten-/Reisesystems.
Die Ortsansicht darf visuell Ausgänge oder Ziele zeigen, soll aber im Normalfall in den vorhandenen Reiseflow weiterleiten statt eine zweite Reiselogik zu bauen.
---
## 6.7 SHOP, QUEST, BOSS, DUNGEON
Diese Typen sind Erweiterungspunkte für späteren Content.
Sie verwenden dieselben POI- und Aktionsregeln, öffnen aber jeweils einen eigenen Screen, ein Panel oder einen spezialisierten Flow.
---
# 7. Primäre Aktionsleiste
Die wichtigsten lokalen Aktionen werden zusätzlich in einer großen Aktionsleiste unterhalb des Artworks dargestellt.
Diese Dopplung ist beabsichtigt.
POIs erzeugen Atmosphäre und räumlichen Kontext.
Die Aktionsleiste sorgt für klare Bedienbarkeit.
Empfohlenes Maximum:
**4 primäre Ortsaktionen**
Beispiel:
```text
[Jagd beginnen]
[Spuren untersuchen]
[Umgebung durchsuchen]
[Zur Karte]
```
Sekundäre Aktionen dürfen ausschließlich als POI oder in der rechten Sidebar erscheinen.
---
# 8. Rechte Kontext-Sidebar
Die rechte Sidebar fasst die spielerische Bedeutung des aktuellen Ortes zusammen.
Empfohlene Bereiche:
## Ortsidentität
- Gebiet / Region
- Ortsname
- Ortstyp
## Progressionskontext
- empfohlener Fortschrittsbereich oder Empfehlung
- relative Gefahr
Diese Information darf keine harte Zugangssperre sein.
Der bestehende Ashen-Realms-Grundsatz bleibt:
> Die Welt kommuniziert Gefahr, ohne Experimente künstlich zu verbieten.
## Mögliche Begegnungen
Zeigt repräsentative Gegner oder Encounter-Kategorien.
Das ist nur Vorschauinformation.
Der tatsächliche Encounter-Wurf erfolgt weiterhin im Jagdsystem.
## Verfügbare Interaktionen
Beispiele:
- Jagd
- Untersuchen
- Durchsuchen
- Sprechen
## Mögliche Belohnungen
Optionale Vorschau-Icons für:
- Ausrüstung
- Materialien
- Verbrauchsgegenstände
- Handelswaren
- regionsspezifische Belohnungen
Es dürfen keine exakten Belohnungen versprochen werden, wenn der zugrunde liegende Content diese nicht garantiert.
---
# 9. Ortstypen
Orte können über wiederverwendbare Typen ihre grundlegende Rolle ausdrücken.
Empfohlene Startwerte:
```text
SAFE_HUB
TRANSITION
HUNTING_GROUND
QUEST_LOCATION
OUTPOST
ELITE_ZONE
BOSS_LOCATION
DUNGEON_ENTRANCE
```
Der Typ beeinflusst Präsentation und verfügbare Aktionen, benötigt aber keine eigene Page-Implementierung.
Beispiele:
### Südtor von Graufurt
```text
TRANSITION
```
Fokus:
- Wachposten
- Reise
- Gebietsinformation
- wenig oder keine reguläre Jagd
### Verbrannte Straße
```text
HUNTING_GROUND
```
Fokus:
- Jagd
- Umgebungsinformationen
- Spuren
- erste NPC-Begegnung
### Verlassener Wachtposten
```text
OUTPOST / QUEST_LOCATION
```
Fokus:
- NPC
- stärkere Jagd
- Untersuchung
- Storyfortschritt
### Aschengrube
```text
ELITE_ZONE / BOSS_LOCATION
```
Fokus:
- Gefahr
- Elite-/Bosszugang
- kaum zivile Interaktionen
---
# 10. Wiederverwendbares Content-Modell
Die Ortsansicht soll datengetrieben sein.
Ein konzeptionelles View-Model sollte mindestens enthalten:
```ts
interface LocalLocationView {
locationId: string;
locationKey: string;
name: string;
regionName: string;
description: string;
locationType: LocationType;
artworkPath: string;
dangerRating: DangerRating;
recommendationLabel?: string;
huntingEnabled: boolean;
pointsOfInterest: LocationPointOfInterest[];
primaryActions: LocationAction[];
encounterPreview: EncounterPreview[];
rewardPreview: RewardPreview[];
}
```
Konzeptionelles POI-Modell:
```ts
interface LocationPointOfInterest {
key: string;
title: string;
actionLabel?: string;
type: LocationInteractionType;
iconKey: string;
xPercent: number;
yPercent: number;
enabled: boolean;
}
```
Koordinaten werden als Prozentwerte gespeichert, damit Hotspots beim Skalieren des Artworks an derselben Stelle bleiben.
Beispiel:
```text
xPercent: 42.5
yPercent: 66.0
```
---
# 11. Interaktionsergebnisse
Lokale Interaktionen sollen ein vorhersehbares Ergebnisformat zurückgeben.
Konzeptionell:
```ts
interface LocationInteractionResult {
interactionKey: string;
title: string;
text: string;
nextAction?: {
type: string;
target?: string;
};
}
```
Die erste Version bleibt bewusst klein.
Nicht nur für diesen Screen bauen:
- generische Visual-Novel-Engine
- verzweigte Dialogengine
- Event-Skriptsprache
- komplexen Questgraph
---
# 12. Navigationsregeln
## Nach abgeschlossener Reise
Der Spieler landet in der lokalen Ortsansicht des neuen aktuellen Ortes.
```text
Reise abgeschlossen
→ current location aktualisiert
→ Ortsansicht
```
## Nach normalem Kampf
Empfohlener Standard:
```text
Kampfergebnis / Loot
→ Ortsansicht
```
Von dort kann der Spieler erneut jagen.
## Beim Verlassen der Jagd
`Zurück` führt zur Ortsansicht.
## Karte
`Zur Karte` öffnet die Karte, verändert aber nicht den aktuellen Ort.
---
# 13. Screen-Zustände
Die Ortsansicht muss mindestens folgende Zustände definieren.
## Loading
- App-Shell bleibt sichtbar
- zurückhaltender Ladezustand im Hauptbereich
## Loaded
- Artwork
- POIs
- Aktionen
- Kontext-Sidebar
## Interaktion geöffnet
- Ort bleibt im Hintergrund sichtbar
- kurze Untersuchungen und einfache NPC-Dialoge öffnen kein komplett neues Seitenlayout
## Aktion nicht verfügbar
- sichtbar deaktiviert
- optional kurze Erklärung / Tooltip
## API-Fehler
- Navigation bleibt benutzbar
- kompakter Retry-Zustand
## Aktiver Kampf
Wenn der Server einen noch nicht abgeschlossenen Kampf meldet, darf die Ortsansicht nicht zum Umgehen dieses Kampfes genutzt werden.
Der bestehende Combat-Resume-Flow hat Vorrang.
---
# 14. Visuelle Regeln
Die Ortsansicht folgt vollständig der bestehenden Ashen-Realms-UI-Spezifikation.
Wichtig:
- Artwork zuerst
- dunkles Metall / Stein / Leder
- dezente Goldverzierungen
- keine weißen SaaS-Cards
- kein Glassmorphism
- keine Neon-Dashboard-Optik
- Fantasy-Serif für große Überschriften
- sehr gut lesbare UI-Schrift für normalen Text
- zurückhaltender Glow
- eindeutige Interaktionszustände
- konsistente Icon-Sprache
POI-Marker müssen erkennbar sein, dürfen aber das Artwork nicht dominieren.
Bevorzugt werden kleine runde oder schildartige Fantasy-Marker statt moderner Map-Pins.
---
# 15. Responsive Verhalten
Desktop bleibt das primäre Ziel.
## Desktop
- volle linke Navigation
- Artwork mit positionierten POIs
- rechte Sidebar sichtbar
- primäre Aktionsleiste sichtbar
## Tablet
- rechte Sidebar darf einklappbar werden
- Hauptaktionen bleiben sichtbar
- POIs bleiben durch Prozentkoordinaten korrekt verankert
## Mobile später
Nicht V1-Priorität.
Mögliche spätere Anpassung:
- Artwork oben
- POI-Liste unterhalb des Artworks
- rechte Sidebar wird einklappbare Ortsinfo
- Aktionen als zweispaltiges Grid
Das Desktop-Design soll jetzt nicht zugunsten von Mobile kompromittiert werden.
---
# 16. Content-Regeln für zukünftige Orte
Bei jedem neuen Ort werden in dieser Reihenfolge definiert:
1. **Zweck des Ortes**
2. **Visuelle Szene**
3. **Wichtigste Spielerentscheidung**
4. **25 sinnvolle POIs**
5. **Bis zu 4 primäre Aktionen**
6. **Encounter-Vorschau, falls relevant**
7. **Informationen für die rechte Sidebar**
8. **Rückkehr- und Weiterreise-Flow**
Für jeden relevanten Ort muss beantwortet werden:
> Warum soll sich der Spieler an diesen Ort erinnern und ihn nicht nur als weiteren Menüpunkt wahrnehmen?
Mindestens ein identitätsstiftendes Element sollte vorhanden sein:
- markanter NPC
- besondere Landmarke
- Untersuchung
- spezieller Service
- Elitezugang
- Bosszugang
- ungewöhnlicher Encounter-Pool
- Story-Hinweis
- visuelles Ereignis
---
# 17. Was ausdrücklich nicht gebaut werden soll
Die Ortsansicht ist kein:
- frei begehbarer Screen
- WASD-Bewegungssystem
- Point-and-Click-Adventure
- Hidden-Object-Spiel
- zweiter Kartenscreen
- Ersatz für die Jagd
- Ersatz für Quests
- universelles Modal für alle Spielsysteme
Vermeiden:
- dutzende Hotspots
- individuelle Komponenten pro Ort
- Kampflogik im Orts-Screen
- clientseitige Loot-Rolls
- clientseitiger Reiseabschluss
- duplizierte Jagdlogik
- lange Lore-Textwände direkt auf der Szene
---
# 18. V1-Referenzort Verbrannte Straße
Die erste Referenzimplementierung ist die **Verbrannte Straße** in den **Aschenfeldern**.
## Zweck
Beweisen, dass die Ortsansicht Weltgefühl, Jagd, einfache Exploration und NPC-Präsenz verbinden kann, ohne ein neues komplexes Subsystem zu werden.
## Visuelle Identität
- verbrannte Handelsstraße
- zerstörter Wagen
- aschebedeckter Boden
- glühende Risse / Glut
- tote Bäume
- Wachtstrukturen in der Ferne
- Rauch
- Spuren früherer Kämpfe
## POIs
### Jagdgebiet
Typ:
```text
HUNT
```
Aktion:
```text
Jagd beginnen
```
### Verdächtige Spuren
Typ:
```text
INVESTIGATE
```
Aktion:
```text
Untersuchen
```
Beispielresultat:
> Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.
### Verlassener Wagen
Typ:
```text
SEARCH
```
Aktion:
```text
Durchsuchen
```
Beispielresultat für die erste Umsetzung:
> Der Wagen wurde gründlich geplündert. Zwischen verbrannten Brettern findest du nur leere Kisten und Spuren eines hastigen Aufbruchs.
In der ersten Implementierung muss diese Aktion noch keine Belohnung vergeben.
### Verwundeter Kundschafter
Typ:
```text
NPC
```
Aktion:
```text
Sprechen
```
Beispieldialog:
> „Die Straße ist nicht mehr sicher. Die Plünderer kommen aus Richtung des alten Wachtpostens. Wenn du weitergehst, halte die Augen offen.“
Für diese erste NPC-Interaktion ist noch kein Quest-System notwendig.
## Primäre Aktionen
```text
Jagd beginnen
Spuren untersuchen
Umgebung durchsuchen
Zur Karte
```
## Encounter-Vorschau
Repräsentative Gegner:
- Aschenratte
- Verwilderter Straßenhund
- Straßenräuber
- Verkohlter Plünderer
Die Vorschau ist rein informativ.
Sie ersetzt nicht den gewichteten Encounter-Pool der Jagd.
---
# 19. Visuelle Referenz
Für dieses Konzept wurde folgender Referenzscreen erzeugt:
```text
verbrannte_strasse_in_den_aschenfeldern.png
```
Der Screenshot definiert Komposition und Zielgefühl, aber keine pixelgenauen Maße.
Zukünftige Ortsansichten sollen insbesondere beibehalten:
- denselben App-Shell
- dominantes Orts-Artwork
- dieselbe POI-Markersprache
- primäre Aktionsleiste unten
- kontextuelle rechte Sidebar
- klare Trennung zwischen Ort, Karte, Jagd und Kampf
---
# 20. Definition of Done für zukünftige Orte
Eine Ortsansicht ist fertig, wenn:
- aktueller Ort und Region sofort erkennbar sind
- das Artwork den Ort eindeutig vermittelt
- verfügbare Aktionen klar sind
- wichtige NPCs / POIs im Kontext sichtbar sind
- POIs beim Skalieren korrekt positioniert bleiben
- Jagdaktionen den bestehenden Jagdflow verwenden
- Reisen den bestehenden Karten-/Reiseflow verwenden
- lokale Interaktionen keine fremden Systeme duplizieren
- serverautoritative Aktionen serverautoritativ bleiben
- derselbe Komponentenaufbau den nächsten Ort nur über andere Daten rendern kann
- der Screen wie ein Bestandteil desselben Ashen-Realms-UI-Systems wirkt
---
# 21. Leitprinzip
> **Die Karte zeigt dem Spieler, wohin er gehen kann. Die Ortsansicht lässt ihn spüren, wo er gerade ist.**
Jede zukünftige Ortsansicht soll genau diese Trennung stärken.

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