Compare commits

..

25 Commits

Author SHA1 Message Date
Bastian Wagner
d31d064d36 images 2026-08-20 13:23:55 +02:00
Bastian Wagner
d174d46fbd fix(web): carry the combat rewards field into the resume-combat fixture
The merge brought in `CombatDto.rewards`, which the resume spec's fixture
predates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:41:40 +02:00
Bastian Wagner
a87a13fad2 Merge branch 'master' into worktree-encounter-status 2026-08-20 10:34:52 +02:00
Bastian Wagner
3c59603efb feat(hunting): show cleared encounters and resume interrupted fights
The hunt screen kept whatever roll was last in memory, so a player coming
back from a fight saw every encounter as fresh. Encounters now carry their
own status, which the combat module advances as fights start and end.

- hunt_encounters.status replaces consumed_at, which only recorded that a
  fight had begun and could not distinguish a win from a loss
- a lost fight hands the encounter back as AVAILABLE, so it can be retried;
  the unique index tying one combat to one encounter goes with it
- GET /hunts/active serves the resumable hunt, which the hunt page adopts on
  entry rather than trusting its in-memory roll
- defeated encounters are crossed out and lose their hover and attack action
- a fresh page load rejoins a combat the server still holds open

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:34:46 +02:00
Bastian Wagner
624c47e5a6 Merge branch 'worktree-slice-0.4-first-loot' 2026-08-20 10:33:39 +02:00
Bastian Wagner
048cf80e78 test(slice-0.4): assert migration SQL and top-bar silver/XP rendering
Adds a real-SQL-inspection test for CreateLootAndRewards1788600000000
(mirroring the visible-vertical-slice pattern) so the one-reward-per-combat
unique index and other DB-level invariants can't be silently deleted
without failing a test, and asserts the TopBar renders character.silver
and character.experience values already present in the app.spec.ts fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:25:26 +02:00
Bastian Wagner
b92aecb71a renaming 2026-08-20 10:16:51 +02:00
Bastian Wagner
1a7a766f2b docs(slice-0.4): record First Loot verification results
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:04:26 +02:00
Bastian Wagner
b0e769d0da combat 2026-08-20 09:47:24 +02:00
Bastian Wagner
21b377f70c feat(web): show authoritative silver and XP in the top bar after victory
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:40:20 +02:00
Bastian Wagner
6711d51bcd feat(combat): replace the victory placeholder with the reward summary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:34:21 +02:00
Bastian Wagner
cf576133ff feat(web): add reward models and the reusable item card
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:24:58 +02:00
Bastian Wagner
6448605a72 feat(characters): expose persisted silver on the character endpoint
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:13:04 +02:00
Bastian Wagner
40e830a321 fix(combat): lock character before combat row and harden reward-transaction test
Fix a lock-order inversion Task 8 introduced: performAction locked the
combat row first and, inside grantVictoryRewards, the character row
second -- the opposite order to startCombat (character, then combat),
creating a deadlock cycle for two concurrent requests on the same
character. performAction now locks the character first via the
existing lockCharacter helper, matching startCombat; the later re-lock
inside grantVictoryRewards is a no-op within the same transaction.

Also strengthen the test that guards the transaction contract for
grantVictoryRewards: expect.anything() would have passed even if the
data source were handed over instead of the transaction manager, since
CombatRewardService has no runtime guard against that substitution.
The test now asserts on the captured argument's identity. Verified
this is load-bearing by temporarily passing the data source in place
of the manager and confirming the test fails.

Finally, make the rollback test's unchanged-XP/silver assertions real:
the fake grantVictoryRewards now writes through the transaction
manager before throwing, so the assertions prove the rollback
discarded those writes instead of passing vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:06:50 +02:00
Bastian Wagner
2a8883d479 feat(combat): resolve victory rewards in the combat completion transaction
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:46:35 +02:00
Bastian Wagner
d9585b166a fix(rewards): resolve item definitions up front and stabilize reward item order
Resolve every rolled ItemDefinition before mutating the character or
creating the CombatReward row, so a missing definition can no longer
half-grant (XP/silver saved, reward row created, then throw). Also
make the immediate grant response and a later loadRewards replay
agree on item order by sorting both on itemDefinitionId instead of
roll/insertion order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:36:54 +02:00
Bastian Wagner
af665dc677 feat(rewards): add CombatRewardService with idempotent victory rewards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:13:37 +02:00
Bastian Wagner
07984110bf feat(loot): add LootService with deterministic independent rolls
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:05:59 +02:00
Bastian Wagner
c58a46b7d9 feat(loot): seed tier-1 items and the ash rat and road bandit loot tables
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:59:08 +02:00
Bastian Wagner
35559e3d2b feat(items): extract tier-1 item icons from the art sheet
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:47:42 +02:00
Bastian Wagner
67237f5ad8 effekte 2026-08-19 23:42:18 +02:00
Bastian Wagner
fe130c597e feat(loot): add loot and reward migration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:39:17 +02:00
Bastian Wagner
af9d422e8b feat(loot): add item, loot table, and combat reward entities
Declares every new TypeORM entity Slice 0.4 needs (ItemDefinition,
CharacterItem, LootTable, LootTableEntry, CombatReward, CombatRewardItem)
plus the ItemType/EquipmentSlot/ItemRarity enums, and adds the two columns
existing entities gain: Character.silver and MonsterDefinition.lootTableId.
No migration SQL or service logic yet - just schema declarations backed by
a metadata-driven schema spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:29:30 +02:00
Bastian Wagner
a5f7772a6d refactor(api): move RandomSource to shared and add rollInclusive
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:19:05 +02:00
Bastian Wagner
f008e53cc6 docs(slice-0.4): add the First Loot implementation plan
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:14:25 +02:00
111 changed files with 13261 additions and 106 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

@@ -3,8 +3,10 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
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';
@@ -173,6 +175,7 @@ function character(overrides: Partial<Character> = {}): Character {
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
@@ -221,7 +224,7 @@ function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
huntId: HUNT_ID,
monsterDefinitionId: MONSTER_ID,
position: 0,
consumedAt: null,
status: HuntEncounterStatus.AVAILABLE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as HuntEncounter;
@@ -247,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 } = {},
) {
@@ -260,6 +277,7 @@ function createService(
travelService,
combatEngine,
characterCombatStats,
fakeRewardService(),
);
return { dataSource, service, travelService };
}
@@ -315,12 +333,14 @@ describe('CombatService', () => {
});
});
it('marks the encounter as consumed', async () => {
it('marks the encounter as IN_PROGRESS', async () => {
const { dataSource, service } = createService();
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull();
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('rejects an unknown encounter id', async () => {
@@ -332,10 +352,25 @@ describe('CombatService', () => {
);
});
it('rejects an already-consumed encounter, and does not create a second combat', async () => {
it('rejects an already-defeated encounter, and does not create a second combat', async () => {
const state = createState({
huntEncounters: [
huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') }),
huntEncounter({ status: HuntEncounterStatus.DEFEATED }),
],
});
const { dataSource, service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
);
expect(dataSource.state.combats).toHaveLength(0);
});
it('rejects an encounter whose fight is still IN_PROGRESS', async () => {
const state = createState({
huntEncounters: [
huntEncounter({ status: HuntEncounterStatus.IN_PROGRESS }),
],
});
const { dataSource, service } = createService({ state });
@@ -518,6 +553,55 @@ describe('CombatService', () => {
);
});
it('marks the encounter DEFEATED when the fight is won', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.DEFEATED,
);
});
it('frees the encounter for another attempt when the fight is lost', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.AVAILABLE,
);
});
it('leaves the encounter IN_PROGRESS while the fight continues', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('lets a lost encounter be fought again as a fresh combat', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(retry.id).not.toBe(combatId);
expect(retry.status).toBe('ACTIVE');
expect(retry.round).toBe(1);
expect(retry.player.currentHp).toBe(retry.player.maxHp);
expect(dataSource.state.combats).toHaveLength(2);
expect(dataSource.state.huntEncounters[0].status).toBe(
HuntEncounterStatus.IN_PROGRESS,
);
});
it('rejects actions on an unknown combat id', async () => {
const { service } = createService();
@@ -532,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(
@@ -663,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

@@ -4,8 +4,11 @@ import { CharacterCombatStatsService } from '../characters/character-combat-stat
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
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';
@@ -57,6 +60,7 @@ export interface CombatDto {
player: CombatPlayerDto;
monster: CombatMonsterDto;
events: CombatEventDto[];
rewards: CombatRewardDto | null;
}
@Injectable()
@@ -66,6 +70,7 @@ export class CombatService {
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterCombatStats: CharacterCombatStatsService,
private readonly combatRewards: CombatRewardService,
) {}
async startCombat(
@@ -93,7 +98,7 @@ export class CombatService {
if (!encounter) {
throw huntEncounterNotFound();
}
if (encounter.consumedAt) {
if (encounter.status !== HuntEncounterStatus.AVAILABLE) {
throw huntEncounterAlreadyConsumed();
}
@@ -143,10 +148,10 @@ export class CombatService {
});
await combats.save(combat);
encounter.consumedAt = new Date();
encounter.status = HuntEncounterStatus.IN_PROGRESS;
await encounters.save(encounter);
return this.toCombatDto(combat, character.name, monster, []);
return this.toCombatDto(combat, character.name, monster, [], null);
});
}
@@ -159,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> {
@@ -183,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(
@@ -192,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' },
@@ -216,6 +231,11 @@ export class CombatService {
combat.monsterCurrentHp = result.state.monster.currentHp;
if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date();
await this.settleEncounter(
manager.getRepository(HuntEncounter),
combat.huntEncounterId,
combat.status,
);
}
await combats.save(combat);
@@ -236,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,
@@ -248,10 +276,32 @@ 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);
});
}
/**
* Records the fight's outcome on the encounter that spawned it. A win
* retires the encounter; a loss hands it back so the player can try again.
*/
private async settleEncounter(
encounters: Repository<HuntEncounter>,
encounterId: string,
outcome: CombatStatus,
): Promise<void> {
const encounter = await encounters.findOneBy({ id: encounterId });
if (!encounter) {
// combats.hunt_encounter_id is a RESTRICT FK; guaranteed to exist.
throw combatStateInvalid();
}
encounter.status =
outcome === CombatStatus.WON
? HuntEncounterStatus.DEFEATED
: HuntEncounterStatus.AVAILABLE;
await encounters.save(encounter);
}
private async lockCharacter(
characters: Repository<Character>,
characterId: string,
@@ -326,6 +376,7 @@ export class CombatService {
playerName: string,
monster: MonsterDefinition,
events: CombatEvent[],
rewards: CombatRewardDto | null,
): CombatDto {
return {
id: combat.id,
@@ -352,6 +403,7 @@ export class CombatService {
target: event.target,
amount: event.amount ?? undefined,
})),
rewards,
};
}
}

View File

@@ -23,7 +23,9 @@ export interface CombatPlayerState extends CombatCombatantState {
}
@Entity({ name: 'combats' })
@Index('IDX_combats_hunt_encounter', ['huntEncounterId'], { unique: true })
// Deliberately not unique: a lost fight frees the encounter to be retried,
// which creates a second combat row for the same encounter.
@Index('IDX_combats_hunt_encounter', ['huntEncounterId'])
export class Combat {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;

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,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddHuntEncounterStatus1788200000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"hunt_encounter_status_enum\" AS ENUM ('AVAILABLE', 'IN_PROGRESS', 'DEFEATED')",
);
await queryRunner.query(`ALTER TABLE "hunt_encounters"
ADD COLUMN "status" "hunt_encounter_status_enum" NOT NULL DEFAULT 'AVAILABLE'`);
// consumed_at only recorded that a fight had started, so the outcome has
// to be read off the combat it spawned. The unique index this migration
// drops guarantees at most one such combat per encounter.
await queryRunner.query(`UPDATE "hunt_encounters" AS "encounter"
SET "status" = CASE "combat"."status"
WHEN 'WON' THEN 'DEFEATED'::"hunt_encounter_status_enum"
WHEN 'ACTIVE' THEN 'IN_PROGRESS'::"hunt_encounter_status_enum"
ELSE 'AVAILABLE'::"hunt_encounter_status_enum"
END
FROM "combats" AS "combat"
WHERE "combat"."hunt_encounter_id" = "encounter"."id"`);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"',
);
// A retried encounter gets a second combat row, so the index that kept
// them one-to-one has to go; one ACTIVE combat per character is still
// enforced by IDX_active_combat_per_character.
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query(
'CREATE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE',
);
await queryRunner.query(`UPDATE "hunt_encounters"
SET "consumed_at" = now()
WHERE "status" <> 'AVAILABLE'`);
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "status"',
);
await queryRunner.query('DROP TYPE "hunt_encounter_status_enum"');
}
}

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

@@ -2,7 +2,6 @@ import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Combat } from '../../combat/entities/combat.entity';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
describe('combat system schema', () => {
it('maps Combat and CombatEvent relations with the documented onDelete behavior', () => {
@@ -28,17 +27,6 @@ describe('combat system schema', () => {
);
});
it('enforces one combat per hunt encounter via a unique index', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) => candidate.target === Combat && candidate.columns?.includes('huntEncounterId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('enforces ordered, unique event sequencing per combat', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
@@ -52,14 +40,4 @@ describe('combat system schema', () => {
const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean };
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true);
});
it('adds a nullable consumedAt column to hunt_encounters to prevent reuse', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) => candidate.target === HuntEncounter && candidate.propertyName === 'consumedAt',
);
expect(column).toBeDefined();
expect(column?.options.nullable).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Combat } from '../../combat/entities/combat.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { HuntEncounterStatus } from '../../hunting/hunt-encounter-status.enum';
describe('encounter status schema', () => {
it('stores the encounter status as a non-nullable enum on hunt_encounters', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === HuntEncounter &&
candidate.propertyName === 'status',
);
expect(column).toBeDefined();
expect(column?.options.type).toBe('enum');
expect(column?.options.enum).toBe(HuntEncounterStatus);
expect(column?.options.enumName).toBe('hunt_encounter_status_enum');
expect(column?.options.nullable).toBeFalsy();
});
it('drops consumedAt, whose gate the encounter status replaces', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) =>
candidate.target === HuntEncounter &&
candidate.propertyName === 'consumedAt',
);
expect(column).toBeUndefined();
});
it('allows repeated combats per encounter so a lost fight can be retried', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === Combat &&
candidate.columns?.includes('huntEncounterId'),
);
expect(index).toBeDefined();
const indexMetadata = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBeFalsy();
});
});

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

@@ -7,6 +7,7 @@ import {
PrimaryGeneratedColumn,
} from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { HuntEncounterStatus } from '../hunt-encounter-status.enum';
import { Hunt } from './hunt.entity';
@Entity({ name: 'hunt_encounters' })
@@ -23,10 +24,16 @@ export class HuntEncounter {
@Column({ name: 'position', type: 'integer' })
position!: number;
// Set when a Combat is successfully created from this encounter. Prevents
// one HuntEncounter from spawning more than one Combat (spec §7).
@Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
consumedAt!: Date | null;
// Owned by the combat module, which advances it as fights start and end.
// DEFEATED and IN_PROGRESS both bar a new fight; a lost fight resets the
// encounter to AVAILABLE so the player can try again.
@Column({
name: 'status',
type: 'enum',
enum: HuntEncounterStatus,
enumName: 'hunt_encounter_status_enum',
})
status!: HuntEncounterStatus;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;

View File

@@ -0,0 +1,5 @@
export enum HuntEncounterStatus {
AVAILABLE = 'AVAILABLE',
IN_PROGRESS = 'IN_PROGRESS',
DEFEATED = 'DEFEATED',
}

View File

@@ -10,15 +10,17 @@ import { HuntingService } from './hunting.service';
describe('HuntingController', () => {
let app: INestApplication<App>;
const startHunt = jest.fn();
const getActiveHunt = jest.fn();
beforeEach(async () => {
startHunt.mockReset();
getActiveHunt.mockReset();
const module = await Test.createTestingModule({
controllers: [HuntingController],
providers: [
{
provide: HuntingService,
useValue: { startHunt },
useValue: { startHunt, getActiveHunt },
},
],
}).compile();
@@ -47,4 +49,42 @@ describe('HuntingController', () => {
expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
it('serves the resumable hunt with its encounter statuses', async () => {
const huntResult = {
id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/monsters/ash-rat.png',
},
dangerRating: 'WEAK',
status: 'DEFEATED',
},
],
};
getActiveHunt.mockResolvedValue(huntResult);
const response = await request(app.getHttpServer())
.get('/api/hunts/active')
.expect(200);
expect(getActiveHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
it('serves an empty body when there is no resumable hunt', async () => {
getActiveHunt.mockResolvedValue(null);
const response = await request(app.getHttpServer())
.get('/api/hunts/active')
.expect(200);
expect(response.body).toEqual({});
});
});

View File

@@ -1,4 +1,4 @@
import { Controller, Post } from '@nestjs/common';
import { Controller, Get, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { HuntResultDto, HuntingService } from './hunting.service';
@@ -10,4 +10,9 @@ export class HuntingController {
startHunt(): Promise<HuntResultDto> {
return this.huntingService.startHunt(DEMO_CHARACTER_ID);
}
@Get('active')
getActiveHunt(): Promise<HuntResultDto | null> {
return this.huntingService.getActiveHunt(DEMO_CHARACTER_ID);
}
}

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

@@ -9,10 +9,11 @@ import { LocationDefinition } from '../world/entities/location-definition.entity
import { DangerRating } from './danger-rating';
import { Hunt } from './entities/hunt.entity';
import { HuntEncounter } from './entities/hunt-encounter.entity';
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';
@@ -29,6 +30,15 @@ interface FakeState {
huntEncounters: HuntEncounter[];
}
// `find` in the fake ignores `relations`, so fixtures attach the joined
// monster the way TypeORM would have hydrated it.
function withMonster(
encounter: HuntEncounter,
monster: MonsterDefinition,
): HuntEncounter {
return { ...encounter, monster } as HuntEncounter;
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
@@ -56,10 +66,24 @@ class FakeRepository<T extends { id: string }> {
);
}
find(options: { where: Partial<T> }): Promise<T[]> {
return Promise.resolve(
this.rows().filter((row) => this.matches(row, options.where)),
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),
);
const orderKey = options.order
? (Object.keys(options.order)[0] as keyof T)
: undefined;
if (orderKey) {
const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
matched.sort((a, b) => {
if (a[orderKey] === b[orderKey]) return 0;
return a[orderKey] > b[orderKey] ? direction : -direction;
});
}
return Promise.resolve(matched);
}
create(values: Partial<T>): T {
@@ -211,6 +235,7 @@ function character(currentLocation: LocationDefinition): Character {
name: 'Aric Duskwalker',
level: 1,
experience: 0,
silver: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
@@ -567,4 +592,135 @@ describe('HuntingService', () => {
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
}
});
it('marks every freshly rolled encounter as AVAILABLE', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterA,
100,
),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters.map((encounter) => encounter.status)).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
]);
expect(
dataSource.state.huntEncounters.map((encounter) => encounter.status),
).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.AVAILABLE,
]);
});
describe('getActiveHunt', () => {
function activeHuntState(
statuses: HuntEncounterStatus[],
overrides: { huntStatus?: HuntStatus; huntLocationId?: string } = {},
) {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.hunts = [
{
id: 'hunt-1',
characterId: CHARACTER_ID,
locationId: overrides.huntLocationId ?? HUNTING_LOCATION_ID,
status: overrides.huntStatus ?? HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
} as Hunt,
];
state.huntEncounters = statuses.map((status, position) =>
withMonster(
{
id: `encounter-${position}`,
huntId: 'hunt-1',
monsterDefinitionId: MONSTER_A_ID,
position,
status,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
} as HuntEncounter,
monsterA,
),
);
return state;
}
it('returns null when the character has no active hunt', async () => {
const { service } = createService();
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
it('returns null when the only hunt has been superseded', async () => {
const state = activeHuntState([HuntEncounterStatus.AVAILABLE], {
huntStatus: HuntStatus.SUPERSEDED,
});
const { service } = createService({ state });
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
it('returns the active hunt with the persisted status of each encounter', async () => {
const state = activeHuntState([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.DEFEATED,
HuntEncounterStatus.IN_PROGRESS,
]);
const { service } = createService({ state });
const result = await service.getActiveHunt(CHARACTER_ID);
expect(result?.id).toBe('hunt-1');
expect(result?.location).toEqual({
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
});
expect(result?.encounters.map((encounter) => encounter.status)).toEqual([
HuntEncounterStatus.AVAILABLE,
HuntEncounterStatus.DEFEATED,
HuntEncounterStatus.IN_PROGRESS,
]);
expect(result?.encounters.map((encounter) => encounter.id)).toEqual([
'encounter-0',
'encounter-1',
'encounter-2',
]);
expect(result?.encounters[0].monster.key).toBe('aschenratte');
expect(result?.encounters[0].dangerRating).toBeDefined();
});
it('returns null once the character has left the hunt location', async () => {
const state = activeHuntState([HuntEncounterStatus.AVAILABLE], {
huntLocationId: SAFE_LOCATION_ID,
});
const { service } = createService({ state });
await expect(service.getActiveHunt(CHARACTER_ID)).resolves.toBeNull();
});
});
});

View File

@@ -9,6 +9,7 @@ import { TravelStatus } from '../travel/travel-status.enum';
import { calculateDangerRating, DangerRating } from './danger-rating';
import { Hunt } from './entities/hunt.entity';
import { HuntEncounter } from './entities/hunt-encounter.entity';
import { HuntEncounterStatus } from './hunt-encounter-status.enum';
import { HuntStatus } from './hunt-status.enum';
import {
characterNotFound,
@@ -16,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;
@@ -30,6 +31,7 @@ export interface HuntEncounterDto {
id: string;
monster: MonsterSummary;
dangerRating: DangerRating;
status: HuntEncounterStatus;
}
export interface HuntResultDto {
@@ -110,28 +112,11 @@ export class HuntingService {
huntId: hunt.id,
monsterDefinitionId: monster.id,
position,
status: HuntEncounterStatus.AVAILABLE,
});
await txEncounters.save(encounter);
const dangerRating = calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{
attack: monster.attack,
armor: monster.armor,
hp: monster.maxHp,
},
);
encounterDtos.push({
id: encounter.id,
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
artworkPath: monster.artworkPath,
},
dangerRating,
});
encounterDtos.push(this.toEncounterDto(encounter, monster, character));
}
return {
@@ -142,6 +127,70 @@ export class HuntingService {
});
}
/**
* The hunt the player can still act on, or null if there is none. A hunt
* is only resumable where it was rolled, so travelling away retires it and
* the player has to search the new area instead.
*/
async getActiveHunt(characterId: string): Promise<HuntResultDto | null> {
const characters = this.dataSource.getRepository(Character);
const character = await characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
throw characterNotFound();
}
const hunts = this.dataSource.getRepository(Hunt);
const hunt = await hunts.findOne({
where: {
characterId,
status: HuntStatus.ACTIVE,
locationId: character.currentLocationId,
},
});
if (!hunt) {
return null;
}
const huntEncounters = this.dataSource.getRepository(HuntEncounter);
const encounters = await huntEncounters.find({
where: { huntId: hunt.id },
relations: { monster: true },
order: { position: 'ASC' },
});
return {
id: hunt.id,
location: this.toLocationSummary(character.currentLocation),
encounters: encounters.map((encounter) =>
this.toEncounterDto(encounter, encounter.monster, character),
),
};
}
private toEncounterDto(
encounter: HuntEncounter,
monster: MonsterDefinition,
character: Character,
): HuntEncounterDto {
return {
id: encounter.id,
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
artworkPath: monster.artworkPath,
},
dangerRating: calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{ attack: monster.attack, armor: monster.armor, hp: monster.maxHp },
),
status: encounter.status,
};
}
/**
* Rolls `count` independent weighted picks from `pool`. Each slot walks
* the pool in the order it was supplied, accumulating weight, and picks

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: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

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

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Component, OnInit, inject } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { CombatStore } from './features/combat/combat.store';
@Component({
selector: 'app-root',
@@ -7,4 +8,21 @@ import { RouterOutlet } from '@angular/router';
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {}
export class App implements OnInit {
private readonly combatStore = inject(CombatStore);
private readonly router = inject(Router);
// A fight the server still holds open outlives the browser session, and
// leaving it is not something the player can do from anywhere else, so a
// fresh load resumes it rather than stranding them on the world map.
ngOnInit(): void {
void this.resumeRunningCombat();
}
private async resumeRunningCombat(): Promise<void> {
const combat = await this.combatStore.loadActiveCombat();
if (combat) {
void this.router.navigate(['/combat', combat.id]);
}
}
}

View File

@@ -9,6 +9,7 @@ export interface CharacterResponse {
name: string;
level: number;
experience: number;
silver: number;
currentHp: number;
maxHp: number;
attack: number;
@@ -57,10 +58,13 @@ export interface MonsterSummary {
artworkPath: string;
}
export type HuntEncounterStatus = 'AVAILABLE' | 'IN_PROGRESS' | 'DEFEATED';
export interface HuntEncounter {
id: string;
monster: MonsterSummary;
dangerRating: DangerRating;
status: HuntEncounterStatus;
}
export interface HuntResult {
@@ -105,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

@@ -34,6 +34,10 @@ export class GameApiService {
return this.http.post<HuntResult>('/api/hunts', {});
}
getActiveHunt(): Observable<HuntResult | null> {
return this.http.get<HuntResult | null>('/api/hunts/active');
}
startCombat(encounterId: string): Observable<Combat> {
return this.http.post<Combat>(`/api/hunt-encounters/${encounterId}/attack`, {});
}

View File

@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat } from './api/game-api.models';
import { App } from '../app';
import { CombatStore } from '../features/combat/combat.store';
const runningCombat: Combat = {
id: 'combat-running',
status: 'ACTIVE',
round: 4,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
maxHp: 75,
currentHp: 30,
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
rewards: null,
};
describe('resuming an interrupted combat', () => {
let combatStore: { loadActiveCombat: ReturnType<typeof vi.fn> };
let router: Router;
async function bootstrap() {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter([
{ path: 'world', children: [] },
{ path: 'combat/:combatId', children: [] },
]),
{ provide: CombatStore, useValue: combatStore },
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
await fixture.whenStable();
return fixture;
}
it('drops the player straight back into the fight they left running', async () => {
combatStore = { loadActiveCombat: vi.fn(() => Promise.resolve(runningCombat)) };
await bootstrap();
expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce();
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-running']);
});
it('leaves navigation alone when no fight is running', async () => {
combatStore = { loadActiveCombat: vi.fn(() => Promise.resolve(null)) };
await bootstrap();
expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce();
expect(router.navigate).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
<section class="combat" aria-label="Kampf">
@if (combat(); as combat) {
<div class="combat__stage">
<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="" />
@@ -58,6 +58,8 @@
></div>
<img
class="sprite sprite--monster"
[class.sprite--flinch]="monsterPhase() === 'flinch'"
[class.sprite--lunge]="monsterPhase() === 'lunge'"
[style.--sprite-scale]="monsterSpriteScale(combat.monster.key)"
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
[alt]="combat.monster.name"
@@ -84,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;
}
@@ -208,7 +217,7 @@
background-size: 600% 100%;
}
.sprite--hit {
.sprite--player.sprite--hit {
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
}
@@ -226,6 +235,126 @@
.sprite--monster {
justify-self: end;
block-size: calc(var(--sprite-scale, 0.6) * 100%);
// Every beat below scales and rotates the cut-out. Pivoting at the feet keeps
// it standing on the same spot instead of sinking behind the action bar.
transform-origin: bottom center;
}
/* The monster is a single cut-out, so every beat below is transform-only. All
of them animate translate/rotate/scale rather than `transform`, and any step
that touches `filter` has to repeat the base drop-shadow from `.sprite`,
because a keyframe filter replaces the whole list. */
/* Getting hit: a short recoil to the right, away from the player, with a red
glow pulse. 240ms so it finishes inside the 260ms riposte pause. */
@keyframes monster-flinch {
0% {
translate: 0 0;
rotate: 0deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7)) drop-shadow(0 0 0 rgb(199 75 66 / 0));
}
20% {
translate: 0.45rem -0.12rem;
rotate: 1.1deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7))
drop-shadow(0 0 0.65rem rgb(199 75 66 / 0.6));
}
45% {
translate: -0.28rem 0;
rotate: -1deg;
}
70% {
translate: 0.18rem 0;
rotate: 0.5deg;
}
100% {
translate: 0 0;
rotate: 0deg;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7)) drop-shadow(0 0 0 rgb(199 75 66 / 0));
}
}
/* Striking back: a brief wind-up away from the player, then a lunge left. The
impact sits at 28% of 540ms so it reads against the warrior hit sheet. */
@keyframes monster-lunge {
0% {
translate: 0 0;
rotate: 0deg;
scale: 1;
}
12% {
translate: 0.55rem 0;
rotate: 1.4deg;
scale: 0.985;
}
28% {
translate: -2rem -0.45rem;
rotate: -2.4deg;
scale: 1.05;
}
55% {
translate: -0.8rem -0.1rem;
rotate: -0.8deg;
scale: 1.015;
}
100% {
translate: 0 0;
rotate: 0deg;
scale: 1;
}
}
/* Idle: deliberately tiny, just enough that the cut-out does not read as card-
board. The spec rules out permanent *strong* animation, not a slow breath. */
@keyframes monster-breathe {
from {
translate: 0 0;
scale: 1;
}
to {
translate: 0 -0.4%;
scale: 1.004;
}
}
/* Stage jolt when the player takes the blow. The slight scale gives the offset
somewhere to go: the stage clips its children, but its own border moves too,
so without it the shift would open a sliver at the frame. */
@keyframes stage-shake {
0%,
100% {
translate: 0 0;
scale: 1;
}
15% {
translate: -0.35% 0.08rem;
scale: 1.012;
}
35% {
translate: 0.3% -0.06rem;
scale: 1.012;
}
60% {
translate: -0.18% 0.04rem;
scale: 1.009;
}
80% {
translate: 0.1% 0;
scale: 1.004;
}
}
/* ---------- action bar ---------- */
@@ -328,6 +457,10 @@
text-transform: uppercase;
}
.outcome--won {
inline-size: min(32rem, 90%);
}
.outcome--won .outcome__title {
color: var(--ar-gold);
}
@@ -439,11 +572,29 @@
}
@media (prefers-reduced-motion: no-preference) {
.sprite--attacking,
.sprite--hit {
.sprite--player.sprite--attacking,
.sprite--player.sprite--hit {
animation: warrior-frames 540ms steps(6) 1;
}
/* Order matters: the state classes below share translate/rotate/scale with
the breathing loop, so their `animation` shorthand has to come last. */
.sprite--monster {
animation: monster-breathe 5.5s ease-in-out infinite alternate;
}
.sprite--flinch {
animation: monster-flinch 240ms ease-out 1;
}
.sprite--lunge {
animation: monster-lunge 540ms cubic-bezier(0.22, 0.9, 0.3, 1) 1;
}
.combat__stage--shaken {
animation: stage-shake 200ms ease-out 1;
}
.bar__fill {
transition: inline-size var(--ar-motion-base);
}
@@ -464,7 +615,7 @@
}
.combat__stage {
min-block-size: clamp(24rem, 55vh, 34rem);
block-size: clamp(20rem, 55dvh, 34rem);
}
.combat__log {
@@ -503,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' }) } },
@@ -134,17 +139,23 @@ describe('CombatPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
const sprite = element.querySelector('.sprite--player');
const monster = element.querySelector('.sprite--monster');
const stage = element.querySelector('.combat__stage');
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(true);
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
expect(element.textContent).toContain('31 / 45');
expect(element.textContent).toContain('95 / 100');
// Swing lands: the monster loses HP, the player's own loss is held back.
// Swing lands: the monster loses HP and flinches, its own loss is held back.
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(false);
expect(monster?.classList.contains('sprite--flinch')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
expect(element.textContent).toContain('17 / 45');
expect(element.textContent).toContain('95 / 100');
// Only round 1's identical line is logged so far, not round 2's.
@@ -154,12 +165,18 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(260);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(true);
expect(monster?.classList.contains('sprite--flinch')).toBe(false);
// 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);
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(false);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(stage?.classList.contains('combat__stage--shaken')).toBe(false);
});
it('skips the recoil when the round ends without the monster striking back', async () => {
@@ -184,9 +201,42 @@ describe('CombatPageComponent', () => {
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
const monster = element.querySelector('.sprite--monster');
expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false);
// The killing blow still registers on the monster, it just never lunges back.
expect(monster?.classList.contains('sprite--flinch')).toBe(true);
expect(monster?.classList.contains('sprite--lunge')).toBe(false);
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('0 / 45');
await vi.advanceTimersByTimeAsync(800);
fixture.detectChanges();
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 () => {
@@ -238,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 {
@@ -15,6 +17,10 @@ interface CombatLogRound {
}
type CombatPhase = 'idle' | 'attacking' | 'hit';
// The monster is a single cut-out with no sheets, so its beats are pure
// CSS transforms and run offset from the player's: it flinches when the
// player's blow lands and lunges while the player is recoiling.
type MonsterPhase = 'idle' | 'flinch' | 'lunge';
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
@@ -24,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);
@@ -43,8 +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;
@@ -67,6 +84,7 @@ export class CombatPageComponent implements OnInit {
this.replaying.set(true);
try {
this.phase.set('attacking');
this.monsterPhase.set('idle');
const swing = this.wait(SWING_MS);
await this.combatStore.attack();
await swing;
@@ -74,12 +92,19 @@ export class CombatPageComponent implements OnInit {
return;
}
this.phase.set('idle');
this.monsterPhase.set('flinch');
const after = this.combatStore.combat();
if (!after) {
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',
@@ -103,12 +128,14 @@ export class CombatPageComponent implements OnInit {
}
this.phase.set('hit');
this.monsterPhase.set('lunge');
this.displayed.set(after);
await this.wait(RECOIL_MS);
if (this.destroyed) {
return;
}
this.phase.set('idle');
this.monsterPhase.set('idle');
} finally {
if (!this.destroyed) {
this.replaying.set(false);
@@ -116,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();
}
@@ -192,6 +229,8 @@ export class CombatPageComponent implements OnInit {
await this.combatStore.loadCombat(combatId);
if (!this.destroyed) {
this.phase.set('idle');
this.monsterPhase.set('idle');
this.displayed.set(this.combatStore.combat());
}
}

View File

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

View File

@@ -1,4 +1,4 @@
<article class="encounter-card">
<article class="encounter-card" [class.encounter-card--settled]="settled">
<div class="encounter-card__crest">
@if (iconPath(); as icon) {
<img class="encounter-card__crest-icon" [src]="icon" alt="" loading="lazy" decoding="async" />
@@ -13,6 +13,16 @@
loading="lazy"
decoding="async"
/>
@if (defeated) {
<img
class="encounter-card__defeated-mark"
[src]="defeatedMark"
alt="Besiegt"
loading="lazy"
decoding="async"
/>
}
</div>
<h3 class="encounter-card__name">{{ encounter.monster.name }}</h3>
@@ -22,5 +32,12 @@
<app-danger-badge class="encounter-card__danger" [rating]="encounter.dangerRating" />
</div>
<button type="button" class="encounter-card__attack" (click)="onAttack()">Angreifen</button>
<button
type="button"
class="encounter-card__attack"
[disabled]="settled"
(click)="onAttack()"
>
{{ actionLabel }}
</button>
</article>

View File

@@ -21,6 +21,16 @@
background-size: 100% 100%;
}
/* A settled encounter is out of play, so the card recedes: the artwork loses
its colour and the whole frame dims. */
.encounter-card--settled .encounter-card__artwork {
filter: grayscale(0.85) brightness(0.6);
}
.encounter-card--settled {
opacity: 0.78;
}
/* ---------- crest ---------- */
.encounter-card__crest {
@@ -54,6 +64,17 @@
place-items: end center;
}
/* Sits over the artwork panel rather than the whole card, so the name and
the danger badge stay readable under it. */
.encounter-card__defeated-mark {
position: absolute;
inset: 6%;
inline-size: 88%;
block-size: 88%;
object-fit: contain;
pointer-events: none;
}
.encounter-card__artwork {
inline-size: 100%;
block-size: 100%;
@@ -126,15 +147,20 @@
text-shadow: 0 0.1rem 0.35rem rgb(0 0 0 / 0.85);
}
.encounter-card__attack:hover {
.encounter-card__attack:enabled:hover {
color: #f4ecda;
box-shadow: inset 0 0 1.1rem rgb(150 200 245 / 0.5);
}
.encounter-card__attack:active {
.encounter-card__attack:enabled:active {
box-shadow: inset 0 0.15rem 0.7rem rgb(0 0 0 / 0.6);
}
.encounter-card__attack:disabled {
color: rgb(214 206 190 / 0.45);
cursor: default;
}
.encounter-card__attack:focus-visible {
outline: 2px solid var(--ar-blue);
outline-offset: -3px;
@@ -145,7 +171,7 @@
transition: filter var(--ar-motion-base);
}
.encounter-card:hover {
.encounter-card:not(.encounter-card--settled):hover {
filter: drop-shadow(0 0 0.9rem rgb(214 178 107 / 0.3));
}

View File

@@ -12,6 +12,7 @@ const dawnwolfEncounter: HuntEncounter = {
artworkPath: '/images/enemies/Dawnwolf.png',
},
dangerRating: 'MATCH',
status: 'AVAILABLE',
};
const ashRatEncounter: HuntEncounter = {
@@ -23,6 +24,7 @@ const ashRatEncounter: HuntEncounter = {
artworkPath: '/images/monsters/ash-rat.png',
},
dangerRating: 'WEAK',
status: 'AVAILABLE',
};
function render(encounter: HuntEncounter): HTMLElement {
@@ -89,4 +91,62 @@ describe('EncounterCardComponent', () => {
expect(emitted).not.toHaveBeenCalledWith(dawnwolfEncounter.monster.key);
expect(dawnwolfEncounter.id).not.toBe(dawnwolfEncounter.monster.key);
});
it('leaves an available encounter unmarked and interactive', () => {
const element = render(dawnwolfEncounter);
expect(element.querySelector('.encounter-card__defeated-mark')).toBeNull();
expect(element.querySelector('.encounter-card')?.classList).not.toContain(
'encounter-card--settled',
);
expect(
element.querySelector<HTMLButtonElement>('.encounter-card__attack')?.disabled,
).toBe(false);
});
it('crosses out a defeated encounter and disables its attack', () => {
const element = render({ ...dawnwolfEncounter, status: 'DEFEATED' });
const mark = element.querySelector('.encounter-card__defeated-mark');
expect(mark).not.toBeNull();
expect(mark?.getAttribute('alt')).toBe('Besiegt');
expect(
element.querySelector<HTMLButtonElement>('.encounter-card__attack')?.disabled,
).toBe(true);
});
it('drops the hover treatment once an encounter is settled', () => {
const defeated = render({ ...dawnwolfEncounter, status: 'DEFEATED' });
const inProgress = render({ ...dawnwolfEncounter, status: 'IN_PROGRESS' });
expect(defeated.querySelector('.encounter-card')?.classList).toContain(
'encounter-card--settled',
);
expect(inProgress.querySelector('.encounter-card')?.classList).toContain(
'encounter-card--settled',
);
});
it('locks an in-progress encounter without crossing it out', () => {
const element = render({ ...dawnwolfEncounter, status: 'IN_PROGRESS' });
expect(element.querySelector('.encounter-card__defeated-mark')).toBeNull();
expect(
element.querySelector<HTMLButtonElement>('.encounter-card__attack')?.disabled,
).toBe(true);
});
it('does not emit an attack for a defeated encounter', () => {
const fixture = TestBed.createComponent(EncounterCardComponent);
fixture.componentRef.setInput('encounter', { ...dawnwolfEncounter, status: 'DEFEATED' });
fixture.detectChanges();
const emitted = vi.fn();
fixture.componentInstance.attack.subscribe(emitted);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('.encounter-card__attack')?.click();
expect(emitted).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,8 @@ import { HuntEncounter } from '../../../core/api/game-api.models';
import { monsterCutoutPath, monsterIconPath } from '../../../shared/monster-artwork';
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
const DEFEATED_MARK = '/images/hud/runtime/defeated-mark-256.png';
@Component({
selector: 'app-encounter-card',
imports: [DangerBadgeComponent],
@@ -13,7 +15,31 @@ export class EncounterCardComponent {
@Input({ required: true }) encounter!: HuntEncounter;
@Output() readonly attack = new EventEmitter<string>();
protected readonly defeatedMark = DEFEATED_MARK;
protected get defeated(): boolean {
return this.encounter.status === 'DEFEATED';
}
// A cleared or already-running encounter cannot be attacked, so the card
// drops its hover invitation as well as the button.
protected get settled(): boolean {
return this.encounter.status !== 'AVAILABLE';
}
protected get actionLabel(): string {
if (this.defeated) {
return 'Besiegt';
}
return this.encounter.status === 'IN_PROGRESS' ? 'Im Kampf' : 'Angreifen';
}
protected onAttack(): void {
if (this.settled) {
return;
}
this.attack.emit(this.encounter.id);
}

View File

@@ -46,6 +46,7 @@ const threeEncounterHunt: HuntResult = {
id: 'encounter-1',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
status: 'AVAILABLE',
},
{
id: 'encounter-2',
@@ -56,11 +57,13 @@ const threeEncounterHunt: HuntResult = {
artworkPath: '/images/enemies/RoadBandit.png',
},
dangerRating: 'MATCH',
status: 'AVAILABLE',
},
{
id: 'encounter-3',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
status: 'AVAILABLE',
},
],
};
@@ -79,6 +82,7 @@ const startedCombat: Combat = {
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
rewards: null,
};
describe('HuntPageComponent', () => {
@@ -93,6 +97,7 @@ describe('HuntPageComponent', () => {
encounters: () => HuntResult['encounters'];
startHunt: ReturnType<typeof vi.fn>;
refreshHunt: ReturnType<typeof vi.fn>;
loadActiveHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let combatStore: {
@@ -115,6 +120,7 @@ describe('HuntPageComponent', () => {
encounters: () => currentHunt()?.encounters ?? [],
startHunt: vi.fn(() => Promise.resolve()),
refreshHunt: vi.fn(() => Promise.resolve()),
loadActiveHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(),
};
combatStore = {
@@ -301,6 +307,75 @@ describe('HuntPageComponent', () => {
expect(huntingStore.startHunt).not.toHaveBeenCalled();
});
it('adopts the resumable hunt on page entry so cleared encounters stay marked', async () => {
await setup(burnedRoad);
expect(huntingStore.loadActiveHunt).toHaveBeenCalledOnce();
expect(huntingStore.startHunt).not.toHaveBeenCalled();
});
it('marks a defeated encounter and takes away its attack action', async () => {
const clearedHunt: HuntResult = {
...threeEncounterHunt,
encounters: [
{ ...threeEncounterHunt.encounters[0], status: 'DEFEATED' },
threeEncounterHunt.encounters[1],
threeEncounterHunt.encounters[2],
],
};
const fixture = await setup(burnedRoad, clearedHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards[0].querySelector('.encounter-card__defeated-mark')).not.toBeNull();
expect(cards[1].querySelector('.encounter-card__defeated-mark')).toBeNull();
const attackButtons = Array.from(
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack'),
);
expect(attackButtons[0].disabled).toBe(true);
expect(attackButtons[1].disabled).toBe(false);
});
it('keeps an in-progress encounter unmarked but locked', async () => {
const fightingHunt: HuntResult = {
...threeEncounterHunt,
encounters: [
{ ...threeEncounterHunt.encounters[0], status: 'IN_PROGRESS' },
threeEncounterHunt.encounters[1],
threeEncounterHunt.encounters[2],
],
};
const fixture = await setup(burnedRoad, fightingHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards[0].querySelector('.encounter-card__defeated-mark')).toBeNull();
const attackButtons = Array.from(
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack'),
);
expect(attackButtons[0].disabled).toBe(true);
});
it('does not start a combat from a defeated encounter card', async () => {
const clearedHunt: HuntResult = {
...threeEncounterHunt,
encounters: [
{ ...threeEncounterHunt.encounters[0], status: 'DEFEATED' },
threeEncounterHunt.encounters[1],
threeEncounterHunt.encounters[2],
],
};
const fixture = await setup(burnedRoad, clearedHunt);
const element = fixture.nativeElement as HTMLElement;
element.querySelectorAll<HTMLButtonElement>('.encounter-card__attack')[0].click();
await Promise.resolve();
expect(combatStore.startCombat).not.toHaveBeenCalled();
});
it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => {
await setup(null);

View File

@@ -21,6 +21,11 @@ export class HuntPageComponent implements OnInit {
if (this.worldStore.currentLocation() === null) {
void this.worldStore.load();
}
// The server owns which encounters are still open, so entering the page --
// including on the way back from a fight -- takes its word over whatever
// roll is still in memory.
void this.huntingStore.loadActiveHunt();
}
protected startHunt(): void {

View File

@@ -14,11 +14,13 @@ const huntResult: HuntResult = {
id: 'encounter-1',
monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' },
dangerRating: 'MATCH',
status: 'AVAILABLE',
},
{
id: 'encounter-2',
monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' },
dangerRating: 'STRONG',
status: 'DEFEATED',
},
],
};
@@ -31,6 +33,7 @@ const refreshedHuntResult: HuntResult = {
id: 'encounter-3',
monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' },
dangerRating: 'WEAK',
status: 'AVAILABLE',
},
],
};
@@ -38,12 +41,14 @@ const refreshedHuntResult: HuntResult = {
describe('HuntingStore', () => {
let api: {
startHunt: ReturnType<typeof vi.fn>;
getActiveHunt: ReturnType<typeof vi.fn>;
};
let store: HuntingStore;
beforeEach(() => {
api = {
startHunt: vi.fn(() => of(huntResult)),
getActiveHunt: vi.fn(() => of(huntResult)),
};
TestBed.configureTestingModule({
@@ -206,4 +211,44 @@ describe('HuntingStore', () => {
expect(store.error()).toBe('Netzwerkfehler');
});
describe('loadActiveHunt', () => {
it('adopts the resumable hunt so returning players see the encounter statuses', async () => {
await store.loadActiveHunt();
expect(api.getActiveHunt).toHaveBeenCalledOnce();
expect(api.startHunt).not.toHaveBeenCalled();
expect(store.currentHunt()).toEqual(huntResult);
expect(store.encounters()[1].status).toBe('DEFEATED');
expect(store.loading()).toBe(false);
expect(store.error()).toBeNull();
});
it('leaves the page on its start screen when there is no resumable hunt', async () => {
api.getActiveHunt.mockReturnValue(of(null));
await store.loadActiveHunt();
expect(store.currentHunt()).toBeNull();
expect(store.error()).toBeNull();
});
it('replaces a stale hunt with the current server state', async () => {
await store.startHunt();
api.getActiveHunt.mockReturnValue(of(refreshedHuntResult));
await store.loadActiveHunt();
expect(store.currentHunt()).toEqual(refreshedHuntResult);
});
it('surfaces a failed reload as an error and clears loading', async () => {
api.getActiveHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
await store.loadActiveHunt();
expect(store.error()).toBe('Netzwerkfehler');
expect(store.loading()).toBe(false);
});
});
});

View File

@@ -50,6 +50,26 @@ export class HuntingStore {
await this.startHunt();
}
/**
* Adopts the hunt the server still considers open, so a player coming back
* from a fight (or a fresh page load) sees which encounters they have
* already cleared instead of a stale in-memory roll.
*/
async loadActiveHunt(): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const hunt = await firstValueFrom(this.api.getActiveHunt());
this.currentHuntState.set(hunt);
this.selectedEncounterIdState.set(null);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
selectEncounter(encounterId: string): void {
this.selectedEncounterIdState.set(encounterId);
}

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>
<app-context-panel />
@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>

Some files were not shown because too many files have changed in this diff Show More