Compare commits

...

24 Commits

Author SHA1 Message Date
Bastian Wagner
76e8ef4320 Merge branch 'worktree-slice-0.3-first-combat' 2026-08-19 22:47:34 +02:00
Bastian Wagner
6c4b7d29d0 feat(combat): play the round a beat at a time with attack and hit frames
The server resolves a whole round in one call, so both blows used to land
at the same instant. The page now keeps its own view of the combat and
plays the round back: the swing animates, the monster's HP and log line
land, then after a beat the monster strikes and the player recoils.

Both animations are six-frame sprite sheets driven by steps(6), which is
why the phase durations mirror the stylesheet.

The status row reflows on the stage's own width via a container query —
the side rails can squeeze it narrow while the viewport is still wide,
which previously overlapped the round marker with the player's name. The
component-style budget moves to 12kB to fit this screen's stylesheet.
2026-08-19 22:46:17 +02:00
Bastian Wagner
54f9d59ef4 feat(combat): use the cut-out enemy art and scale sprites per monster
Swap the enemy body art for the background-free versions and trim every
sprite to its opaque bounds so the fighters share one ground baseline
instead of floating in a padded box.

Sprite height is now a share of the battlefield rather than a fixed
clamp, set per monster key, so a low-slung rat and a standing bandit keep
believable proportions against the player at any stage size.
2026-08-19 22:46:05 +02:00
Bastian Wagner
f33b0c0e4a feat(combat): rejoin the running combat instead of dead-ending
Attacking while a combat is already active returned COMBAT_ALREADY_ACTIVE
and left the hunt page showing an error the player could not act on, with
no way back into the fight they were already in.

Add GET /api/combats/active so the client can resolve that combat, and
have the hunt page navigate into it when an attack is rejected for this
reason. CombatStore now also exposes the error code so callers can tell
this case apart from a genuinely failed attack.
2026-08-19 22:45:53 +02:00
Bastian Wagner
e42957d91e Merge branch 'worktree-slice-0.3-first-combat' 2026-08-19 22:40:32 +02:00
Bastian Wagner
c390a61594 sprites 2026-08-19 22:16:17 +02:00
Bastian Wagner
92b9f1cd35 feat(combat): rebuild the combat screen around the reference layout
Move both health bars to a status row at the top of the scene with a
circular portrait beside each, and place full-body sprites for the
player and the monster standing on the location background instead of
square portraits. The Angriff action now uses the ornate HUD frame art,
with the keybind in the frame's own tab.

Sprite and icon derivatives are keyed by monster key so both seeded
monsters resolve; the Aschenratte body art still carries its original
backdrop until a cut-out version replaces it at the same path.
2026-08-19 22:11:07 +02:00
Bastian Wagner
dcb248bd15 statusbar 2026-08-19 22:07:19 +02:00
Bastian Wagner
6affb9eddc Merge branch 'master' into worktree-slice-0.3-first-combat 2026-08-19 21:53:29 +02:00
Bastian Wagner
18f30a1ba1 test(combat): cover CombatService LOST persistence
CombatService only had engine-level coverage of the LOST transition.
Add service-level tests that force a loss (character.baseHp: 1) and
assert the persisted status, completedAt, further-action rejection,
and getCombat refresh behavior for a LOST combat.
2026-08-19 21:42:01 +02:00
Bastian Wagner
aa8c374db0 feat(combat): start real combats from the hunt page and navigate to /combat/:combatId 2026-08-19 17:03:35 +02:00
Bastian Wagner
1fd62cddde feat(combat): add CombatPageComponent and replace the combat/new placeholder route
Wires up the Slice 0.3 combat screen (player/monster HP bars, round
display, Angriff action, grouped German combat log, victory/defeat
panels) and replaces the Slice 0.2 combat/new placeholder route with
combat/:combatId loading CombatPageComponent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:53:59 +02:00
Bastian Wagner
4b1e7f034c feat(combat): add CombatStore
Signal-based store wrapping GameApiService.startCombat/getCombat/
performCombatAction with loading/error/actionPending state, following
the HuntingStore/WorldStore pattern. startCombat failures clear any
previously-loaded combat; loadCombat/attack failures preserve the
last-known-good combat. attack() guards against re-entrancy and
no-loaded-combat calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:46:36 +02:00
Bastian Wagner
8cf15afaee refactor(web): extract shared runtime monster-artwork lookup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:41:28 +02:00
Bastian Wagner
a6450817de feat(combat): add frontend Combat models and API methods
Adds Combat domain models (Combat, CombatPlayer, CombatMonster, CombatEvent)
and three GameApiService methods to interact with backend combat endpoints:
- startCombat(encounterId): POST /api/hunt-encounters/:id/attack
- getCombat(combatId): GET /api/combats/:id
- performCombatAction(combatId, action): POST /api/combats/:id/actions

Includes test coverage for all three methods.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:33:37 +02:00
Bastian Wagner
bb42195ef5 feat(combat): wire CombatModule into the application
Provide and export CharacterCombatStatsService from CharactersModule,
create CombatModule (registering the combat entities, controllers,
CombatService and CombatEngineService, and importing TravelModule and
CharactersModule), and register CombatModule in AppModule so the three
combat endpoints (attack, get combat, post action) are reachable from
the running app.
2026-08-19 16:19:06 +02:00
Bastian Wagner
4831dc20b0 feat(combat): add HTTP controllers for starting, reading, and acting on combats
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:09:08 +02:00
Bastian Wagner
498349b5eb feat(combat): add CombatService orchestration and persistence
Wires the pure combat engine, character combat stats, and domain errors
into a transactional service that validates the HuntEncounter boundary,
snapshots stats into a new Combat row, and persists engine results.
2026-08-19 16:01:17 +02:00
Bastian Wagner
90f6a8cd78 feat(combat): add combat domain errors 2026-08-19 15:50:52 +02:00
Bastian Wagner
bd8a00227f feat(characters): add temporary combat-stats stand-in for equipment 2026-08-19 15:48:11 +02:00
Bastian Wagner
edae1af39a feat(combat): add deterministic combat engine for ATTACK resolution 2026-08-19 15:43:24 +02:00
Bastian Wagner
6cb4d02613 feat(combat): add deterministic damage formula 2026-08-19 15:39:17 +02:00
Bastian Wagner
47931ff717 feat(combat): add CreateCombatSystem migration 2026-08-19 15:34:46 +02:00
Bastian Wagner
68edc04ed6 feat(combat): add combat domain enums, entities, and encounter consumption field 2026-08-19 15:27:05 +02:00
71 changed files with 3668 additions and 114 deletions

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
@@ -14,6 +15,7 @@ import { WorldModule } from './world/world.module';
TravelModule,
WorldModule,
HuntingModule,
CombatModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,16 @@
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { Character } from './entities/character.entity';
describe('CharacterCombatStatsService', () => {
it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => {
const service = new CharacterCombatStatsService();
const character = { baseHp: 100, baseAttack: 6 } as Character;
expect(service.getStats(character)).toEqual({
maxHp: 100,
attack: 6,
weaponDamage: 8,
armor: 6,
});
});
});

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Character } from './entities/character.entity';
export interface CharacterCombatStats {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants
// stand in for the starting weapon/armor until Slice 0.5 introduces real
// equipment. Replacing them there must not change this method's signature
// or the combat API it feeds (spec §10).
const TEMPORARY_WEAPON_DAMAGE = 8;
const TEMPORARY_ARMOR = 6;
@Injectable()
export class CharacterCombatStatsService {
getStats(character: Character): CharacterCombatStats {
return {
maxHp: character.baseHp,
attack: character.baseAttack,
weaponDamage: TEMPORARY_WEAPON_DAMAGE,
armor: TEMPORARY_ARMOR,
};
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity';
@@ -7,6 +8,7 @@ import { Character } from './entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Character])],
controllers: [CharactersController],
providers: [CharactersService],
providers: [CharactersService, CharacterCombatStatsService],
exports: [CharacterCombatStatsService],
})
export class CharactersModule {}

View File

@@ -0,0 +1,6 @@
// Only ATTACK is implemented in Slice 0.3. Future slices add HEAVY_STRIKE,
// SHIELD_BASH, DEFEND, POTION, FLEE as real members with their own
// CombatEngineService cases — do not add them here until their behavior ships.
export enum CombatAction {
ATTACK = 'ATTACK',
}

View File

@@ -0,0 +1,17 @@
import { calculateDamage } from './combat-damage';
describe('calculateDamage', () => {
it('applies the established armor mitigation formula', () => {
// raw = 12 + 15 = 27; 27 * 60 / (60 + 20) = 20.25 -> rounds to 20
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20)).toBe(20);
});
it('never returns less than 1 damage, even against extreme armor', () => {
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000)).toBe(1);
});
it('treats an attacker with no weaponDamage as having attack alone as its raw damage', () => {
// raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8
expect(calculateDamage({ attack: 9 }, 5)).toBe(8);
});
});

View File

@@ -0,0 +1,13 @@
export interface DamageAttacker {
attack: number;
weaponDamage?: number;
}
const ARMOR_MITIGATION_CONSTANT = 60;
export function calculateDamage(attacker: DamageAttacker, targetArmor: number): number {
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
const mitigatedDamage =
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor);
return Math.max(1, Math.round(mitigatedDamage));
}

View File

@@ -0,0 +1,107 @@
import { CombatAction } from './combat-action.enum';
import { CombatEngineService, UnsupportedCombatActionError } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
import { Combatant } from './combatant.enum';
function baseState(overrides: Partial<CombatEngineState> = {}): CombatEngineState {
return {
status: CombatStatus.ACTIVE,
round: 1,
player: {
currentHp: 100,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6 },
},
monster: {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0 },
},
...overrides,
};
}
describe('CombatEngineService', () => {
let engine: CombatEngineService;
beforeEach(() => {
engine = new CombatEngineService();
});
it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });
// raw = 6 + 8 = 14; armor 0 -> 14 mitigated
expect(result.state.monster.currentHp).toBe(45 - 14);
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 14,
});
});
it('lets the monster retaliate when it survives the player attack, and advances the round', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });
// raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5
expect(result.state.player.currentHp).toBe(100 - 5);
expect(result.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
});
expect(result.state.status).toBe(CombatStatus.ACTIVE);
expect(result.state.round).toBe(2);
});
it('does not let the monster attack once it is reduced to 0 HP, and ends the combat as WON', () => {
const state = baseState({
monster: { currentHp: 10, maxHp: 45, stats: { attack: 5, armor: 0 } },
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.monster.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.round).toBe(1);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
]);
});
it('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => {
const state = baseState({
player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } },
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.player.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.LOST);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5 },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST },
]);
});
it('produces the exact same result for the same state and action (determinism)', () => {
const state = baseState();
const first = engine.resolveAction(state, { action: CombatAction.ATTACK });
const second = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(first).toEqual(second);
});
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
expect(() =>
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
).toThrow(UnsupportedCombatActionError);
});
});

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { CombatAction } from './combat-action.enum';
import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum';
import {
CombatActionInput,
CombatEngineEvent,
CombatEngineResult,
CombatEngineState,
} from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
export class UnsupportedCombatActionError extends Error {
constructor(action: string) {
super(`Unsupported combat action: ${action}`);
}
}
@Injectable()
export class CombatEngineService {
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
switch (input.action) {
case CombatAction.ATTACK:
return this.resolveAttack(state);
default:
throw new UnsupportedCombatActionError(input.action);
}
}
private resolveAttack(state: CombatEngineState): CombatEngineResult {
const events: CombatEngineEvent[] = [];
const player = { ...state.player };
const monster = { ...state.monster };
const playerDamage = calculateDamage(player.stats, monster.stats.armor);
monster.currentHp = Math.max(0, monster.currentHp - playerDamage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: playerDamage,
});
if (monster.currentHp <= 0) {
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
});
return {
state: { ...state, player, monster, status: CombatStatus.WON },
events,
};
}
const monsterDamage = calculateDamage(monster.stats, player.stats.armor);
player.currentHp = Math.max(0, player.currentHp - monsterDamage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: monsterDamage,
});
if (player.currentHp <= 0) {
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
});
return {
state: { ...state, player, monster, status: CombatStatus.LOST },
events,
};
}
return {
state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 },
events,
};
}
}

View File

@@ -0,0 +1,39 @@
import { Combatant } from './combatant.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
export interface CombatEngineCombatantStats {
attack: number;
weaponDamage?: number;
armor: number;
}
export interface CombatEngineCombatant {
currentHp: number;
maxHp: number;
stats: CombatEngineCombatantStats;
}
export interface CombatEngineState {
status: CombatStatus;
round: number;
player: CombatEngineCombatant;
monster: CombatEngineCombatant;
}
export interface CombatActionInput {
action: CombatAction;
}
export interface CombatEngineEvent {
source: Combatant;
target: Combatant;
type: CombatEventType;
amount?: number;
}
export interface CombatEngineResult {
state: CombatEngineState;
events: CombatEngineEvent[];
}

View File

@@ -0,0 +1,5 @@
export enum CombatEventType {
DAMAGE = 'DAMAGE',
COMBAT_WON = 'COMBAT_WON',
COMBAT_LOST = 'COMBAT_LOST',
}

View File

@@ -0,0 +1,5 @@
export enum CombatStatus {
ACTIVE = 'ACTIVE',
WON = 'WON',
LOST = 'LOST',
}

View File

@@ -0,0 +1,97 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatController } from './combat.controller';
import { CombatService } from './combat.service';
describe('CombatController', () => {
let app: INestApplication<App>;
const getCombat = jest.fn();
const getActiveCombat = jest.fn();
const performAction = jest.fn();
beforeEach(async () => {
getCombat.mockReset();
getActiveCombat.mockReset();
performAction.mockReset();
const module = await Test.createTestingModule({
controllers: [CombatController],
providers: [
{ provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } },
],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] };
getCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200);
expect(getCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1');
expect(response.body).toEqual(combat);
});
it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [] };
getActiveCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(getCombat).not.toHaveBeenCalled();
expect(response.body).toEqual(combat);
});
it('returns an empty body from GET /api/combats/active when no combat is running', async () => {
getActiveCombat.mockResolvedValue(null);
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual({});
expect(getCombat).not.toHaveBeenCalled();
});
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: [] };
performAction.mockResolvedValue(combat);
const response = await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'ATTACK' })
.expect(201);
expect(performAction).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1', 'ATTACK');
expect(response.body).toEqual(combat);
});
it('rejects an unknown action value', async () => {
await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'HEAVY_STRIKE' })
.expect(400);
expect(performAction).not.toHaveBeenCalled();
});
it('rejects server-owned combat fields the client must never send', async () => {
await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'ATTACK', damage: 999, playerHp: 1, monsterHp: 1, round: 99 })
.expect(400);
expect(performAction).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,25 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatActionDto } from './dto/combat-action.dto';
import { CombatService } from './combat.service';
@Controller('combats')
export class CombatController {
constructor(private readonly combatService: CombatService) {}
// Declared before ':combatId' so the literal segment wins the route match.
@Get('active')
getActiveCombat() {
return this.combatService.getActiveCombat(DEMO_CHARACTER_ID);
}
@Get(':combatId')
getCombat(@Param('combatId') combatId: string) {
return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId);
}
@Post(':combatId/actions')
performAction(@Param('combatId') combatId: string, @Body() dto: CombatActionDto) {
return this.combatService.performAction(DEMO_CHARACTER_ID, combatId, dto.action);
}
}

View File

@@ -0,0 +1,87 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type CombatErrorCode =
| 'HUNT_ENCOUNTER_NOT_FOUND'
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
| 'INVALID_HUNT_ENCOUNTER'
| 'CHARACTER_TRAVELLING'
| 'COMBAT_ALREADY_ACTIVE'
| 'COMBAT_NOT_FOUND'
| 'COMBAT_ALREADY_FINISHED'
| 'COMBAT_STATE_INVALID';
export class CombatDomainError extends HttpException {
constructor(
public readonly code: CombatErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function huntEncounterNotFound(): CombatDomainError {
return new CombatDomainError(
'HUNT_ENCOUNTER_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This encounter could not be found.',
);
}
export function huntEncounterAlreadyConsumed(): CombatDomainError {
return new CombatDomainError(
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
HttpStatus.CONFLICT,
'This encounter has already been used to start a combat.',
);
}
export function invalidHuntEncounter(): CombatDomainError {
return new CombatDomainError(
'INVALID_HUNT_ENCOUNTER',
HttpStatus.BAD_REQUEST,
'This encounter is not valid for the current character.',
);
}
export function characterTravelling(): CombatDomainError {
return new CombatDomainError(
'CHARACTER_TRAVELLING',
HttpStatus.CONFLICT,
'The character cannot fight while travelling.',
);
}
export function combatAlreadyActive(): CombatDomainError {
return new CombatDomainError(
'COMBAT_ALREADY_ACTIVE',
HttpStatus.CONFLICT,
'The character already has an active combat.',
);
}
export function combatNotFound(): CombatDomainError {
return new CombatDomainError(
'COMBAT_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This combat could not be found.',
);
}
export function combatAlreadyFinished(): CombatDomainError {
return new CombatDomainError(
'COMBAT_ALREADY_FINISHED',
HttpStatus.CONFLICT,
'This combat has already finished.',
);
}
export function combatStateInvalid(): CombatDomainError {
return new CombatDomainError(
'COMBAT_STATE_INVALID',
HttpStatus.INTERNAL_SERVER_ERROR,
'The persisted combat references unavailable data.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersModule } from '../characters/characters.module';
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 { TravelModule } from '../travel/travel.module';
import { CombatEngineService } from './combat-engine.service';
import { CombatController } from './combat.controller';
import { CombatService } from './combat.service';
import { Combat } from './entities/combat.entity';
import { CombatEvent } from './entities/combat-event.entity';
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';
@Module({
imports: [
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
TravelModule,
CharactersModule,
],
controllers: [CombatController, HuntEncounterAttackController],
providers: [CombatService, CombatEngineService],
})
export class CombatModule {}

View File

@@ -0,0 +1,666 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatDomainError } from './combat.errors';
import { CombatService } from './combat.service';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const ENCOUNTER_ID = '30000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
interface FakeState {
characters: Character[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
monsters: MonsterDefinition[];
combats: Combat[];
combatEvents: CombatEvent[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
private readonly target: EntityTarget<T>,
private readonly inTransaction: boolean,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
lock?: { mode: string };
}): Promise<T | null> {
if (options.lock) {
if (!this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
this.dataSource.locks.push({
target: this.target,
mode: options.lock.mode,
});
}
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),
);
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);
}
count(options: { where: Partial<T> }): Promise<number> {
return Promise.resolve(
this.rows().filter((row) => this.matches(row, options.where)).length,
);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === Hunt) return this.state.hunts as T[];
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
if (this.target === MonsterDefinition) return this.state.monsters as T[];
if (this.target === Combat) return this.state.combats as T[];
if (this.target === CombatEvent) return this.state.combatEvents as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
}
private targetName(): string {
return typeof this.target === 'function'
? this.target.name
: 'EntitySchema';
}
}
class FakeEntityManager {
constructor(
private readonly state: FakeState,
private readonly dataSource: FakeDataSource,
) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, true, this.dataSource);
}
}
class FakeDataSource {
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
private readonly idCounters = new Map<string, number>();
constructor(public state: FakeState) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, false, this);
}
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
const draft = structuredClone(this.state);
const result = await work(
new FakeEntityManager(draft, this) as unknown as EntityManager,
);
this.state = draft;
return result;
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: 'location-1',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Character;
}
function monster(
overrides: Partial<MonsterDefinition> = {},
): MonsterDefinition {
return {
id: MONSTER_ID,
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
};
}
function hunt(overrides: Partial<Hunt> = {}): Hunt {
return {
id: HUNT_ID,
characterId: CHARACTER_ID,
locationId: 'location-1',
status: HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Hunt;
}
function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
return {
id: ENCOUNTER_ID,
huntId: HUNT_ID,
monsterDefinitionId: MONSTER_ID,
position: 0,
consumedAt: null,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as HuntEncounter;
}
function createState(overrides: Partial<FakeState> = {}): FakeState {
return {
characters: [character()],
hunts: [hunt()],
huntEncounters: [huntEncounter()],
monsters: [monster()],
combats: [],
combatEvents: [],
...overrides,
};
}
function fakeTravelService(
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
): TravelService {
return {
completeTravelIfDue: jest.fn().mockResolvedValue({ status }),
} as unknown as TravelService;
}
function createService(
options: { state?: FakeState; travelService?: TravelService } = {},
) {
const state = options.state ?? createState();
const dataSource = new FakeDataSource(state);
const travelService = options.travelService ?? fakeTravelService();
const combatEngine = new CombatEngineService();
const characterCombatStats = new CharacterCombatStatsService();
const service = new CombatService(
dataSource as unknown as DataSource,
travelService,
combatEngine,
characterCombatStats,
);
return { dataSource, service, travelService };
}
async function expectCombatDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(CombatDomainError);
if (!(error instanceof CombatDomainError)) {
throw new Error('Expected CombatDomainError');
}
expect(error.code).toBe(code);
}
describe('CombatService', () => {
describe('startCombat', () => {
it('starts an ACTIVE combat with snapshotted stats and full HP', async () => {
const { dataSource, service } = createService();
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(combat.status).toBe('ACTIVE');
expect(combat.round).toBe(1);
expect(combat.player).toEqual({
name: 'Aric Duskwalker',
maxHp: 100,
currentHp: 100,
});
expect(combat.monster).toEqual({
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
});
expect(combat.events).toEqual([]);
expect(dataSource.state.combats).toHaveLength(1);
expect(dataSource.state.combats[0]).toMatchObject({
characterId: CHARACTER_ID,
huntEncounterId: ENCOUNTER_ID,
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
});
});
it('marks the encounter as consumed', async () => {
const { dataSource, service } = createService();
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull();
});
it('rejects an unknown encounter id', async () => {
const { service } = createService();
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, 'unknown-id'),
'HUNT_ENCOUNTER_NOT_FOUND',
);
});
it('rejects an already-consumed encounter, and does not create a second combat', async () => {
const state = createState({
huntEncounters: [
huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') }),
],
});
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 belonging to a different character', async () => {
const state = createState({
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
});
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(OTHER_CHARACTER_ID, ENCOUNTER_ID),
'INVALID_HUNT_ENCOUNTER',
);
});
it('rejects an encounter whose hunt is no longer ACTIVE', async () => {
const state = createState({
hunts: [hunt({ status: HuntStatus.SUPERSEDED })],
});
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'INVALID_HUNT_ENCOUNTER',
);
});
it('rejects starting combat while the character is travelling', async () => {
const { service } = createService({
travelService: fakeTravelService('TRAVELLING'),
});
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'CHARACTER_TRAVELLING',
);
});
it('rejects starting a second combat while one is already ACTIVE', async () => {
const state = createState({
combats: [
{
id: 'combat-existing',
characterId: CHARACTER_ID,
huntEncounterId: 'other-encounter',
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
round: 1,
playerMaxHp: 100,
playerCurrentHp: 100,
monsterMaxHp: 45,
monsterCurrentHp: 45,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
createdAt: new Date(),
updatedAt: new Date(),
completedAt: null,
} as Combat,
],
});
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'COMBAT_ALREADY_ACTIVE',
);
});
it('locks the character and the active-combat lookup', async () => {
const { dataSource, service } = createService();
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.locks).toEqual(
expect.arrayContaining([
{ target: Character, mode: 'pessimistic_write' },
{ target: Combat, mode: 'pessimistic_write' },
]),
);
});
});
describe('performAction', () => {
async function startedCombat(state = createState()) {
const context = createService({ state });
const combat = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
return { ...context, combatId: combat.id };
}
it('resolves ATTACK, persists HP/round changes, and returns them', async () => {
const { dataSource, service, combatId } = await startedCombat();
const result = await service.performAction(
CHARACTER_ID,
combatId,
CombatAction.ATTACK,
);
expect(result.status).toBe('ACTIVE');
expect(result.round).toBe(2);
expect(result.monster.currentHp).toBe(45 - 14);
expect(result.player.currentHp).toBe(100 - 5);
expect(dataSource.state.combats[0].round).toBe(2);
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(31);
expect(dataSource.state.combats[0].playerCurrentHp).toBe(95);
});
it('persists ordered, sequential CombatEvents across multiple rounds', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const events = dataSource.state.combatEvents
.filter((event) => event.combatId === combatId)
.sort((a, b) => a.sequence - b.sequence);
expect(events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
expect(events.map((event) => event.round)).toEqual([1, 1, 2, 2]);
expect(events[0]).toMatchObject({
source: 'PLAYER',
target: 'MONSTER',
type: 'DAMAGE',
amount: 14,
});
expect(events[1]).toMatchObject({
source: 'MONSTER',
target: 'PLAYER',
type: 'DAMAGE',
amount: 5,
});
});
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state);
const result = await service.performAction(
CHARACTER_ID,
combatId,
CombatAction.ATTACK,
);
expect(result.status).toBe('WON');
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
await expectCombatDomainError(
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
'COMBAT_ALREADY_FINISHED',
);
});
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({
characters: [character({ baseHp: 1 })],
});
const { dataSource, service, combatId } = await startedCombat(state);
const result = await service.performAction(
CHARACTER_ID,
combatId,
CombatAction.ATTACK,
);
expect(result.status).toBe('LOST');
expect(dataSource.state.combats[0].status).toBe(CombatStatus.LOST);
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
await expectCombatDomainError(
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
'COMBAT_ALREADY_FINISHED',
);
});
it('rejects actions on an unknown combat id', async () => {
const { service } = createService();
await expectCombatDomainError(
service.performAction(
CHARACTER_ID,
'unknown-combat',
CombatAction.ATTACK,
),
'COMBAT_NOT_FOUND',
);
});
it('rejects actions from a character who does not own the combat', async () => {
const { service, combatId } = await startedCombat();
await expectCombatDomainError(
service.performAction(
OTHER_CHARACTER_ID,
combatId,
CombatAction.ATTACK,
),
'COMBAT_NOT_FOUND',
);
});
it('locks the combat row for the duration of the action', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.locks).toEqual(
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
);
});
});
describe('getCombat', () => {
it('returns the persisted state and ordered events after a refresh', async () => {
const context = createService();
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
const reloaded = await context.service.getCombat(
CHARACTER_ID,
started.id,
);
expect(reloaded.round).toBe(2);
expect(reloaded.monster.currentHp).toBe(31);
expect(reloaded.events.map((event) => event.sequence)).toEqual([1, 2]);
});
it('rejects an unknown combat id', async () => {
const { service } = createService();
await expectCombatDomainError(
service.getCombat(CHARACTER_ID, 'unknown'),
'COMBAT_NOT_FOUND',
);
});
it('resolves the character ACTIVE combat so the hunt page can rejoin it', async () => {
const context = createService();
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
const active = await context.service.getActiveCombat(CHARACTER_ID);
expect(active?.id).toBe(started.id);
expect(active?.status).toBe('ACTIVE');
expect(active?.round).toBe(2);
});
it('resolves null when the character has no ACTIVE combat', async () => {
const { service } = createService();
await expect(service.getActiveCombat(CHARACTER_ID)).resolves.toBeNull();
});
it('resolves null once the only combat has finished', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const context = createService({ state });
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
await expect(
context.service.getActiveCombat(CHARACTER_ID),
).resolves.toBeNull();
});
it('does not resolve another character ACTIVE combat', async () => {
const context = createService();
await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
await expect(
context.service.getActiveCombat(OTHER_CHARACTER_ID),
).resolves.toBeNull();
});
it('keeps returning LOST after the combat has ended', async () => {
const state = createState({
characters: [character({ baseHp: 1 })],
});
const context = createService({ state });
const started = await context.service.startCombat(
CHARACTER_ID,
ENCOUNTER_ID,
);
await context.service.performAction(
CHARACTER_ID,
started.id,
CombatAction.ATTACK,
);
const reloaded = await context.service.getCombat(
CHARACTER_ID,
started.id,
);
expect(reloaded.status).toBe('LOST');
expect(reloaded.player.currentHp).toBe(0);
});
});
});

View File

@@ -0,0 +1,357 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
import {
characterNotFound,
characterTravelling,
combatAlreadyActive,
combatAlreadyFinished,
combatNotFound,
combatStateInvalid,
huntEncounterAlreadyConsumed,
huntEncounterNotFound,
invalidHuntEncounter,
} from './combat.errors';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
export interface CombatPlayerDto {
name: string;
maxHp: number;
currentHp: number;
}
export interface CombatMonsterDto {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
}
export interface CombatEventDto {
round: number;
sequence: number;
type: string;
source: string;
target: string;
amount?: number;
}
export interface CombatDto {
id: string;
status: CombatStatus;
round: number;
player: CombatPlayerDto;
monster: CombatMonsterDto;
events: CombatEventDto[];
}
@Injectable()
export class CombatService {
constructor(
private readonly dataSource: DataSource,
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterCombatStats: CharacterCombatStatsService,
) {}
async startCombat(
characterId: string,
encounterId: string,
): Promise<CombatDto> {
const travel = await this.travelService.completeTravelIfDue(characterId);
if (travel.status === TravelStatus.TRAVELLING) {
throw characterTravelling();
}
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const encounters = manager.getRepository(HuntEncounter);
const hunts = manager.getRepository(Hunt);
const monsters = manager.getRepository(MonsterDefinition);
const combats = manager.getRepository(Combat);
const character = await this.lockCharacter(characters, characterId);
const encounter = await encounters.findOne({
where: { id: encounterId },
lock: { mode: 'pessimistic_write' },
});
if (!encounter) {
throw huntEncounterNotFound();
}
if (encounter.consumedAt) {
throw huntEncounterAlreadyConsumed();
}
const hunt = await hunts.findOneBy({ id: encounter.huntId });
if (
!hunt ||
hunt.characterId !== characterId ||
hunt.status !== HuntStatus.ACTIVE
) {
throw invalidHuntEncounter();
}
const existingActiveCombat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
lock: { mode: 'pessimistic_write' },
});
if (existingActiveCombat) {
throw combatAlreadyActive();
}
const monster = await monsters.findOneBy({
id: encounter.monsterDefinitionId,
});
if (!monster) {
throw invalidHuntEncounter();
}
const playerStats = this.characterCombatStats.getStats(character);
const combat = combats.create({
characterId,
huntEncounterId: encounter.id,
monsterDefinitionId: monster.id,
status: CombatStatus.ACTIVE,
round: 1,
playerMaxHp: playerStats.maxHp,
playerCurrentHp: playerStats.maxHp,
monsterMaxHp: monster.maxHp,
monsterCurrentHp: monster.maxHp,
playerState: {
attack: playerStats.attack,
weaponDamage: playerStats.weaponDamage,
armor: playerStats.armor,
},
monsterState: { attack: monster.attack, armor: monster.armor },
completedAt: null,
});
await combats.save(combat);
encounter.consumedAt = new Date();
await encounters.save(encounter);
return this.toCombatDto(combat, character.name, monster, []);
});
}
async getCombat(characterId: string, combatId: string): Promise<CombatDto> {
const combats = this.dataSource.getRepository(Combat);
const combat = await combats.findOne({
where: { id: combatId, characterId },
});
if (!combat) {
throw combatNotFound();
}
const [character, monster, events] = await Promise.all([
this.loadCharacter(combat.characterId),
this.loadMonster(combat.monsterDefinitionId),
this.loadEvents(combat.id),
]);
return this.toCombatDto(combat, character.name, monster, events);
}
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
const combats = this.dataSource.getRepository(Combat);
const combat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
});
if (!combat) {
return null;
}
const [character, monster, events] = await Promise.all([
this.loadCharacter(combat.characterId),
this.loadMonster(combat.monsterDefinitionId),
this.loadEvents(combat.id),
]);
return this.toCombatDto(combat, character.name, monster, events);
}
async performAction(
characterId: string,
combatId: string,
action: CombatAction,
): Promise<CombatDto> {
return this.dataSource.transaction(async (manager) => {
const combats = manager.getRepository(Combat);
const combatEvents = manager.getRepository(CombatEvent);
const combat = await combats.findOne({
where: { id: combatId, characterId },
lock: { mode: 'pessimistic_write' },
});
if (!combat) {
throw combatNotFound();
}
if (combat.status !== CombatStatus.ACTIVE) {
throw combatAlreadyFinished();
}
const actionRound = combat.round;
const engineState = this.toEngineState(combat);
const result = this.combatEngine.resolveAction(engineState, { action });
combat.round = result.state.round;
combat.status = result.state.status;
combat.playerCurrentHp = result.state.player.currentHp;
combat.monsterCurrentHp = result.state.monster.currentHp;
if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date();
}
await combats.save(combat);
const startingSequence = await combatEvents.count({
where: { combatId: combat.id },
});
for (let index = 0; index < result.events.length; index += 1) {
const event = result.events[index];
const entity = combatEvents.create({
combatId: combat.id,
round: actionRound,
sequence: startingSequence + index + 1,
type: event.type,
source: event.source,
target: event.target,
amount: event.amount ?? null,
});
await combatEvents.save(entity);
}
const [character, monster, events] = await Promise.all([
this.loadCharacter(
combat.characterId,
manager.getRepository(Character),
),
this.loadMonster(
combat.monsterDefinitionId,
manager.getRepository(MonsterDefinition),
),
this.loadEvents(combat.id, combatEvents),
]);
return this.toCombatDto(combat, character.name, monster, events);
});
}
private async lockCharacter(
characters: Repository<Character>,
characterId: string,
): Promise<Character> {
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
return character;
}
private async loadCharacter(
characterId: string,
repo?: Repository<Character>,
): Promise<Character> {
const characters = repo ?? this.dataSource.getRepository(Character);
const character = await characters.findOneBy({ id: characterId });
if (!character) {
// combats.character_id is a RESTRICT FK; a persisted combat's
// character is guaranteed to exist.
throw combatStateInvalid();
}
return character;
}
private async loadMonster(
monsterId: string,
repo?: Repository<MonsterDefinition>,
): Promise<MonsterDefinition> {
const monsters = repo ?? this.dataSource.getRepository(MonsterDefinition);
const monster = await monsters.findOneBy({ id: monsterId });
if (!monster) {
// combats.monster_definition_id is a RESTRICT FK; guaranteed to exist.
throw combatStateInvalid();
}
return monster;
}
private loadEvents(
combatId: string,
repo?: Repository<CombatEvent>,
): Promise<CombatEvent[]> {
const combatEvents = repo ?? this.dataSource.getRepository(CombatEvent);
return combatEvents.find({
where: { combatId },
order: { sequence: 'ASC' },
});
}
private toEngineState(combat: Combat): CombatEngineState {
return {
status: combat.status,
round: combat.round,
player: {
currentHp: combat.playerCurrentHp,
maxHp: combat.playerMaxHp,
stats: combat.playerState,
},
monster: {
currentHp: combat.monsterCurrentHp,
maxHp: combat.monsterMaxHp,
stats: combat.monsterState,
},
};
}
private toCombatDto(
combat: Combat,
playerName: string,
monster: MonsterDefinition,
events: CombatEvent[],
): CombatDto {
return {
id: combat.id,
status: combat.status,
round: combat.round,
player: {
name: playerName,
maxHp: combat.playerMaxHp,
currentHp: combat.playerCurrentHp,
},
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
maxHp: combat.monsterMaxHp,
currentHp: combat.monsterCurrentHp,
artworkPath: monster.artworkPath,
},
events: events.map((event) => ({
round: event.round,
sequence: event.sequence,
type: event.type,
source: event.source,
target: event.target,
amount: event.amount ?? undefined,
})),
};
}
}

View File

@@ -0,0 +1,4 @@
export enum Combatant {
PLAYER = 'PLAYER',
MONSTER = 'MONSTER',
}

View File

@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { CombatAction } from '../combat-action.enum';
export class CombatActionDto {
@IsEnum(CombatAction)
action!: CombatAction;
}

View File

@@ -0,0 +1,62 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Combatant } from '../combatant.enum';
import { CombatEventType } from '../combat-event-type.enum';
import { Combat } from './combat.entity';
@Entity({ name: 'combat_events' })
@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true })
export class CombatEvent {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'combat_id', type: 'uuid' })
combatId!: string;
@Column({ name: 'round', type: 'integer' })
round!: number;
@Column({ name: 'sequence', type: 'integer' })
sequence!: number;
@Column({
name: 'type',
type: 'enum',
enum: CombatEventType,
enumName: 'combat_event_type_enum',
})
type!: CombatEventType;
@Column({
name: 'source',
type: 'enum',
enum: Combatant,
enumName: 'combatant_enum',
})
source!: Combatant;
@Column({
name: 'target',
type: 'enum',
enum: Combatant,
enumName: 'combatant_enum',
})
target!: Combatant;
@Column({ name: 'amount', type: 'integer', nullable: true })
amount!: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'combat_id' })
combat!: Combat;
}

View File

@@ -0,0 +1,89 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { CombatStatus } from '../combat-status.enum';
export interface CombatCombatantState {
attack: number;
armor: number;
}
export interface CombatPlayerState extends CombatCombatantState {
weaponDamage: number;
}
@Entity({ name: 'combats' })
@Index('IDX_combats_hunt_encounter', ['huntEncounterId'], { unique: true })
export class Combat {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'hunt_encounter_id', type: 'uuid' })
huntEncounterId!: string;
@Column({ name: 'monster_definition_id', type: 'uuid' })
monsterDefinitionId!: string;
@Column({
name: 'status',
type: 'enum',
enum: CombatStatus,
enumName: 'combat_status_enum',
})
status!: CombatStatus;
@Column({ name: 'round', type: 'integer' })
round!: number;
@Column({ name: 'player_max_hp', type: 'integer' })
playerMaxHp!: number;
@Column({ name: 'player_current_hp', type: 'integer' })
playerCurrentHp!: number;
@Column({ name: 'monster_max_hp', type: 'integer' })
monsterMaxHp!: number;
@Column({ name: 'monster_current_hp', type: 'integer' })
monsterCurrentHp!: number;
@Column({ name: 'player_state', type: 'jsonb' })
playerState!: CombatPlayerState;
@Column({ name: 'monster_state', type: 'jsonb' })
monsterState!: CombatCombatantState;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt!: Date | null;
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => HuntEncounter, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'hunt_encounter_id' })
huntEncounter!: HuntEncounter;
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'monster_definition_id' })
monster!: MonsterDefinition;
}

View File

@@ -0,0 +1,41 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatService } from './combat.service';
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';
describe('HuntEncounterAttackController', () => {
let app: INestApplication<App>;
const startCombat = jest.fn();
beforeEach(async () => {
startCombat.mockReset();
const module = await Test.createTestingModule({
controllers: [HuntEncounterAttackController],
providers: [{ provide: CombatService, useValue: { startCombat } }],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
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: [] };
startCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer())
.post('/api/hunt-encounters/encounter-1/attack')
.expect(201);
expect(startCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'encounter-1');
expect(response.body).toEqual(combat);
});
});

View File

@@ -0,0 +1,13 @@
import { Controller, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatService } from './combat.service';
@Controller('hunt-encounters')
export class HuntEncounterAttackController {
constructor(private readonly combatService: CombatService) {}
@Post(':encounterId/attack')
attack(@Param('encounterId') encounterId: string) {
return this.combatService.startCombat(DEMO_CHARACTER_ID, encounterId);
}
}

View File

@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCombatSystem1788100000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE',
);
await queryRunner.query(
"CREATE TYPE \"combat_status_enum\" AS ENUM ('ACTIVE', 'WON', 'LOST')",
);
await queryRunner.query(
"CREATE TYPE \"combat_event_type_enum\" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')",
);
await queryRunner.query(
"CREATE TYPE \"combatant_enum\" AS ENUM ('PLAYER', 'MONSTER')",
);
await queryRunner.query(`CREATE TABLE "combats" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"hunt_encounter_id" uuid NOT NULL,
"monster_definition_id" uuid NOT NULL,
"status" "combat_status_enum" NOT NULL,
"round" integer NOT NULL,
"player_max_hp" integer NOT NULL,
"player_current_hp" integer NOT NULL,
"monster_max_hp" integer NOT NULL,
"monster_current_hp" integer NOT NULL,
"player_state" jsonb NOT NULL,
"monster_state" jsonb NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"completed_at" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_combats" PRIMARY KEY ("id"),
CONSTRAINT "FK_combats_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_combats_hunt_encounter" FOREIGN KEY ("hunt_encounter_id") REFERENCES "hunt_encounters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_combats_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_combats_character" ON "combats" ("character_id")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_combats_monster_definition" ON "combats" ("monster_definition_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_combat_per_character"
ON "combats" ("character_id")
WHERE "status" = 'ACTIVE'`);
await queryRunner.query(`CREATE TABLE "combat_events" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"combat_id" uuid NOT NULL,
"round" integer NOT NULL,
"sequence" integer NOT NULL,
"type" "combat_event_type_enum" NOT NULL,
"source" "combatant_enum" NOT NULL,
"target" "combatant_enum" NOT NULL,
"amount" integer,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_combat_events" PRIMARY KEY ("id"),
CONSTRAINT "FK_combat_events_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_combat_events_combat" ON "combat_events" ("combat_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_combat_events_combat_sequence"
ON "combat_events" ("combat_id", "sequence")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_combat_events_combat_sequence"');
await queryRunner.query('DROP INDEX "IDX_combat_events_combat"');
await queryRunner.query('DROP TABLE "combat_events"');
await queryRunner.query('DROP INDEX "IDX_active_combat_per_character"');
await queryRunner.query('DROP INDEX "IDX_combats_monster_definition"');
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query('DROP INDEX "IDX_combats_character"');
await queryRunner.query('DROP TABLE "combats"');
await queryRunner.query('DROP TYPE "combatant_enum"');
await queryRunner.query('DROP TYPE "combat_event_type_enum"');
await queryRunner.query('DROP TYPE "combat_status_enum"');
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"',
);
}
}

View File

@@ -0,0 +1,65 @@
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', () => {
const metadata = getMetadataArgsStorage();
const relations = metadata.relations.filter(
(relation) => relation.target === Combat || relation.target === CombatEvent,
);
expect(
relations.map((relation) => ({
onDelete: relation.options.onDelete,
propertyName: relation.propertyName,
target: relation.target,
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: Combat }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'huntEncounter', target: Combat }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'monster', target: Combat }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatEvent }),
]),
);
});
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(
(candidate) =>
candidate.target === CombatEvent &&
candidate.columns?.includes('combatId') &&
candidate.columns?.includes('sequence'),
);
expect(index).toBeDefined();
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

@@ -23,6 +23,11 @@ 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;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;

View File

@@ -45,7 +45,7 @@
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumError": "12kB"
}
],
"outputHashing": "all"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -22,10 +22,10 @@ export const routes: Routes = [
),
},
{
path: 'combat/new',
path: 'combat/:combatId',
loadComponent: () =>
import('./features/combat/combat-placeholder-page.component').then(
(module) => module.CombatPlaceholderPageComponent,
import('./features/combat/combat-page/combat-page.component').then(
(module) => module.CombatPageComponent,
),
},
],

View File

@@ -68,3 +68,41 @@ export interface HuntResult {
location: LocationSummary;
encounters: HuntEncounter[];
}
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
export type CombatEventType = 'DAMAGE' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = 'ATTACK';
export interface CombatEvent {
round: number;
sequence: number;
type: CombatEventType;
source: CombatSide;
target: CombatSide;
amount?: number;
}
export interface CombatPlayer {
name: string;
maxHp: number;
currentHp: number;
}
export interface CombatMonster {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
}
export interface Combat {
id: string;
status: CombatStatus;
round: number;
player: CombatPlayer;
monster: CombatMonster;
events: CombatEvent[];
}

View File

@@ -47,4 +47,38 @@ describe('GameApiService', () => {
expect(request.request.body).toEqual({ targetLocationId: 'target-uuid' });
request.flush({ status: 'IDLE' });
});
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
service.startCombat('encounter-uuid').subscribe();
const request = http.expectOne('/api/hunt-encounters/encounter-uuid/attack');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush({});
});
it('gets a combat by id', () => {
service.getCombat('combat-uuid').subscribe();
const request = http.expectOne('/api/combats/combat-uuid');
expect(request.request.method).toBe('GET');
request.flush({});
});
it('gets the running combat from the literal active route', () => {
service.getActiveCombat().subscribe();
const request = http.expectOne('/api/combats/active');
expect(request.request.method).toBe('GET');
request.flush({});
});
it('posts only the action enum when performing a combat action', () => {
service.performCombatAction('combat-uuid', 'ATTACK').subscribe();
const request = http.expectOne('/api/combats/combat-uuid/actions');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({ action: 'ATTACK' });
request.flush({});
});
});

View File

@@ -1,7 +1,14 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { CharacterResponse, CurrentLocationResponse, CurrentTravel, HuntResult } from './game-api.models';
import {
CharacterResponse,
Combat,
CombatAction,
CurrentLocationResponse,
CurrentTravel,
HuntResult,
} from './game-api.models';
@Injectable({ providedIn: 'root' })
export class GameApiService {
@@ -26,4 +33,20 @@ export class GameApiService {
startHunt(): Observable<HuntResult> {
return this.http.post<HuntResult>('/api/hunts', {});
}
startCombat(encounterId: string): Observable<Combat> {
return this.http.post<Combat>(`/api/hunt-encounters/${encounterId}/attack`, {});
}
getCombat(combatId: string): Observable<Combat> {
return this.http.get<Combat>(`/api/combats/${combatId}`);
}
getActiveCombat(): Observable<Combat | null> {
return this.http.get<Combat | null>('/api/combats/active');
}
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
}
}

View File

@@ -0,0 +1,126 @@
<section class="combat" aria-label="Kampf">
@if (combat(); as combat) {
<div class="combat__stage">
<header class="combat__status">
<div class="fighter fighter--player">
<img class="fighter__icon" [src]="playerIcon" alt="" />
<div class="fighter__meter">
<p class="fighter__name">{{ combat.player.name }}</p>
<div
class="bar bar--player"
role="progressbar"
[attr.aria-label]="'Lebenspunkte ' + combat.player.name"
[attr.aria-valuenow]="combat.player.currentHp"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="combat.player.maxHp"
>
<span class="bar__fill" [style.inline-size.%]="playerHpPercent()"></span>
<span class="bar__text">{{ combat.player.currentHp }} / {{ combat.player.maxHp }}</span>
</div>
</div>
</div>
<p class="combat__round" data-combat-round>Runde {{ combat.round }}</p>
<div class="fighter fighter--monster">
<div class="fighter__meter">
<p class="fighter__name">
{{ combat.monster.name }}
<span class="fighter__level">Stufe {{ combat.monster.level }}</span>
</p>
<div
class="bar bar--monster"
role="progressbar"
[attr.aria-label]="'Lebenspunkte ' + combat.monster.name"
[attr.aria-valuenow]="combat.monster.currentHp"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="combat.monster.maxHp"
>
<span class="bar__fill" [style.inline-size.%]="monsterHpPercent()"></span>
<span class="bar__text">{{ combat.monster.currentHp }} / {{ combat.monster.maxHp }}</span>
</div>
</div>
<img
class="fighter__icon"
[src]="monsterIcon(combat.monster.key, combat.monster.artworkPath)"
alt=""
/>
</div>
</header>
<div class="combat__field">
<div
class="sprite sprite--player"
[class.sprite--attacking]="phase() === 'attacking'"
[class.sprite--hit]="phase() === 'hit'"
role="img"
[attr.aria-label]="combat.player.name"
></div>
<img
class="sprite sprite--monster"
[style.--sprite-scale]="monsterSpriteScale(combat.monster.key)"
[src]="monsterSprite(combat.monster.key, combat.monster.artworkPath)"
[alt]="combat.monster.name"
/>
</div>
<footer class="combat__actions">
@if (combat.status === 'ACTIVE') {
<button
type="button"
class="action"
data-combat-attack
[disabled]="busy()"
(click)="attack()"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Angriff</span>
<span class="action__key">1</span>
</button>
}
</footer>
@if (combat.status === 'WON') {
<div class="outcome outcome--won" data-combat-result="WON">
<h2 class="outcome__title">Sieg</h2>
<p>{{ combat.monster.name }} wurde besiegt.</p>
<p class="outcome__hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
</button>
</div>
} @else if (combat.status === 'LOST') {
<div class="outcome outcome--lost" data-combat-result="LOST">
<h2 class="outcome__title">Niederlage</h2>
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
</button>
</div>
}
</div>
<aside class="combat__log" aria-label="Kampfprotokoll">
<h2 class="combat__log-title">Kampflog</h2>
<div class="combat__log-body">
@for (round of logRounds(); track round.round) {
<p class="combat__log-round">Runde {{ round.round }}</p>
@for (event of round.events; track event.sequence) {
<p class="combat__log-entry" [class.combat__log-entry--player]="event.source === 'PLAYER'">
{{ formatEvent(event) }}
</p>
}
}
</div>
</aside>
} @else if (combatStore.loading()) {
<p class="combat__notice" role="status">Kampf wird geladen…</p>
}
@if (combatStore.error(); as error) {
<section class="combat__notice combat__notice--error" role="alert">
<p>{{ error }}</p>
<button type="button" data-combat-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>

View File

@@ -0,0 +1,505 @@
:host {
display: block;
block-size: 100%;
}
.combat {
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 17rem);
gap: var(--ar-space-4);
block-size: 100%;
}
/* ---------- stage ---------- */
// The stage is a container so the status row reflows on its own width: the
// surrounding rails can squeeze it narrow while the viewport is still wide.
.combat__stage {
position: relative;
display: grid;
container-type: inline-size;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-4);
min-block-size: 26rem;
padding: var(--ar-space-4);
overflow: hidden;
border: 1px solid var(--ar-border);
background-color: var(--ar-bg);
background-image: url('/images/backgrounds/Aschestrasse.png');
background-position: center;
background-repeat: no-repeat;
background-size: cover;
box-shadow: var(--ar-shadow-raised);
}
@supports (
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
)
) {
.combat__stage {
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
);
}
}
.combat__stage::before {
position: absolute;
z-index: 0;
inset: 0;
content: '';
background:
linear-gradient(180deg, rgb(4 6 8 / 0.72) 0%, rgb(4 6 8 / 0.1) 26%, rgb(4 6 8 / 0.66) 100%);
pointer-events: none;
}
/* ---------- combatant status ---------- */
.combat__status {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: var(--ar-space-4);
}
.fighter {
display: flex;
align-items: center;
gap: var(--ar-space-3);
min-inline-size: 0;
}
.fighter--monster {
justify-content: flex-end;
}
.fighter__icon {
flex: 0 0 auto;
inline-size: clamp(2.75rem, 5vw, 3.75rem);
block-size: clamp(2.75rem, 5vw, 3.75rem);
object-fit: cover;
border-radius: 50%;
box-shadow:
0 0 0 1px var(--ar-border-highlight),
0 0.3rem 0.9rem rgb(0 0 0 / 0.8);
}
.fighter__meter {
display: grid;
gap: 0.3rem;
min-inline-size: 0;
flex: 1 1 auto;
max-inline-size: 22rem;
}
.fighter--monster .fighter__meter {
justify-items: end;
text-align: end;
}
.fighter__name {
margin: 0;
overflow: hidden;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.95rem, 1.5vw, 1.15rem);
letter-spacing: 0.02em;
text-overflow: ellipsis;
text-shadow: 0 0.1rem 0.5rem rgb(0 0 0 / 0.95);
white-space: nowrap;
}
.fighter__level {
color: var(--ar-gold);
font-family: system-ui, sans-serif;
font-size: var(--ar-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ---------- hp bars ---------- */
.bar {
position: relative;
display: block;
inline-size: 100%;
block-size: 1.25rem;
overflow: hidden;
border: 1px solid var(--ar-border);
background: rgb(0 0 0 / 0.72);
box-shadow: inset 0 0 0.6rem rgb(0 0 0 / 0.9);
}
.bar__fill {
position: absolute;
z-index: 0;
inset-block: 0;
inset-inline-start: 0;
display: block;
background: linear-gradient(180deg, #d0564a, #8e2b23);
}
.bar--monster .bar__fill {
inset-inline-start: auto;
inset-inline-end: 0;
}
.bar__text {
position: relative;
z-index: 1;
display: grid;
block-size: 100%;
place-items: center;
color: var(--ar-text);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
text-shadow: 0 0.05rem 0.25rem rgb(0 0 0 / 1);
}
/* ---------- round marker ---------- */
.combat__round {
margin: 0;
align-self: center;
padding: var(--ar-space-1) var(--ar-space-5);
border-block: 1px solid var(--ar-border-highlight);
color: var(--ar-gold);
background: linear-gradient(90deg, transparent, rgb(9 11 13 / 0.9) 18%, rgb(9 11 13 / 0.9) 82%, transparent);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.95rem, 1.4vw, 1.1rem);
letter-spacing: 0.14em;
text-transform: uppercase;
white-space: nowrap;
}
/* ---------- battlefield ---------- */
.combat__field {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: end;
min-block-size: 0;
}
.sprite {
display: block;
max-inline-size: 100%;
inline-size: auto;
object-fit: contain;
object-position: bottom;
filter: drop-shadow(0 0.5rem 0.9rem rgb(0 0 0 / 0.7));
}
/* Six 384px frames laid out horizontally; frame 0 is the resting stance. */
.sprite--player {
justify-self: start;
block-size: 80%;
aspect-ratio: 1;
transform: scaleX(-1);
background-image: url('/images/combat/sprites/warrior-attack-sheet-384.png');
background-repeat: no-repeat;
background-position: 0% 0;
background-size: 600% 100%;
}
.sprite--hit {
background-image: url('/images/combat/sprites/warrior-hit-sheet-384.png');
}
/* 6 frames across a 600%-wide sheet land on 0/20/40/60/80/100%. */
@keyframes warrior-frames {
from {
background-position: 0% 0;
}
to {
background-position: 120% 0;
}
}
.sprite--monster {
justify-self: end;
block-size: calc(var(--sprite-scale, 0.6) * 100%);
}
/* ---------- action bar ---------- */
.combat__actions {
position: relative;
z-index: 2;
display: flex;
gap: var(--ar-space-3);
min-block-size: clamp(6.5rem, 11vw, 8.5rem);
}
.action {
position: relative;
inline-size: clamp(6.5rem, 11vw, 8.5rem);
aspect-ratio: 1;
padding: 0;
border: 0;
background: transparent url('/images/hud/runtime/fight-action-frame-360.png') center / 100% 100%
no-repeat;
cursor: pointer;
}
.action__icon,
.action__label,
.action__key {
position: absolute;
inset-inline-start: 50%;
translate: -50% -50%;
}
.action__icon {
inset-block-start: 32%;
inline-size: 34%;
border-radius: 50%;
opacity: 0.92;
}
.action__label {
inset-block-start: 62%;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.85rem, 1.2vw, 1rem);
letter-spacing: 0.03em;
white-space: nowrap;
}
.action__key {
inset-block-start: 90%;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
}
.action:hover:not(:disabled) .action__label,
.action:focus-visible .action__label {
color: var(--ar-gold);
}
.action:focus-visible {
outline: 2px solid var(--ar-gold);
outline-offset: 3px;
}
.action:disabled {
cursor: progress;
opacity: 0.55;
}
/* ---------- outcome overlay ---------- */
.outcome {
position: absolute;
z-index: 3;
inset-block-start: 50%;
inset-inline-start: 50%;
display: grid;
gap: var(--ar-space-2);
justify-items: center;
inline-size: min(26rem, 80%);
padding: var(--ar-space-5);
translate: -50% -50%;
border: 1px solid var(--ar-border-highlight);
background: rgb(9 11 13 / 0.94);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.outcome p {
margin: 0;
color: var(--ar-text-muted);
}
.outcome__title {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.outcome--won .outcome__title {
color: var(--ar-gold);
}
.outcome--lost .outcome__title {
color: var(--ar-danger);
}
.outcome__hint {
font-size: var(--ar-font-sm);
font-style: italic;
}
.outcome__button,
.combat__notice--error button {
padding: var(--ar-space-2) var(--ar-space-5);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #23282c, #14181b);
cursor: pointer;
font-family: Georgia, 'Times New Roman', serif;
letter-spacing: 0.04em;
}
.outcome__button {
margin-block-start: var(--ar-space-2);
}
.outcome__button:hover,
.combat__notice--error button:hover {
border-color: var(--ar-gold);
color: var(--ar-gold);
}
/* ---------- combat log ---------- */
.combat__log {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-block-size: 0;
border: 1px solid var(--ar-border);
background: var(--ar-panel-muted);
box-shadow: var(--ar-shadow-raised);
}
.combat__log-title {
margin: 0;
padding: var(--ar-space-3) var(--ar-space-4);
border-block-end: 1px solid var(--ar-border);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.combat__log-body {
padding: var(--ar-space-3) var(--ar-space-4) var(--ar-space-4);
overflow-y: auto;
}
.combat__log-round {
margin: var(--ar-space-4) 0 var(--ar-space-2);
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.combat__log-round:first-child {
margin-block-start: 0;
}
.combat__log-entry {
margin: 0 0 var(--ar-space-1);
padding-inline-start: var(--ar-space-3);
border-inline-start: 2px solid var(--ar-danger);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
line-height: 1.45;
}
.combat__log-entry--player {
border-inline-start-color: var(--ar-border-highlight);
}
/* ---------- notices ---------- */
.combat__notice {
grid-column: 1 / -1;
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.combat__notice--error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ar-space-4);
border-color: var(--ar-danger);
}
.combat__notice--error button {
flex: 0 0 auto;
}
@media (prefers-reduced-motion: no-preference) {
.sprite--attacking,
.sprite--hit {
animation: warrior-frames 540ms steps(6) 1;
}
.bar__fill {
transition: inline-size var(--ar-motion-base);
}
.action__label {
transition: color var(--ar-motion-fast);
}
}
@media (width < 60rem) {
:host,
.combat {
block-size: auto;
}
.combat {
grid-template-columns: minmax(0, 1fr);
}
.combat__stage {
min-block-size: clamp(24rem, 55vh, 34rem);
}
.combat__log {
max-block-size: 18rem;
}
}
// Below this the three-column status row cannot hold two names and two bars
// side by side, so the fighters stack under a centred round marker.
@container (width < 38rem) {
.combat__status {
grid-template-columns: minmax(0, 1fr);
gap: var(--ar-space-2);
}
.combat__round {
order: -1;
justify-self: center;
}
.fighter--monster {
justify-content: flex-start;
}
.fighter--monster .fighter__meter {
justify-items: start;
text-align: start;
}
.bar--monster .bar__fill {
inset-inline-start: 0;
inset-inline-end: auto;
}
.fighter__meter {
max-inline-size: none;
}
}

View File

@@ -0,0 +1,241 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat } from '../../../core/api/game-api.models';
import { CombatStore } from '../combat.store';
import { CombatPageComponent } from './combat-page.component';
const activeCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 2,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png',
},
events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
const monsterHitLine = 'Aschenratte trifft Aric Duskwalker für 5 Schaden.';
function countOccurrences(haystack: string | null, needle: string): number {
return haystack ? haystack.split(needle).length - 1 : 0;
}
describe('CombatPageComponent', () => {
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
loading: ReturnType<typeof signal<boolean>>;
actionPending: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
loadCombat: ReturnType<typeof vi.fn>;
attack: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(combat: Combat | null) {
combatStore = {
combat: signal(combat),
loading: signal(false),
actionPending: signal(false),
error: signal<string | null>(null),
loadCombat: vi.fn(() => Promise.resolve()),
attack: vi.fn(() => Promise.resolve()),
};
await TestBed.configureTestingModule({
imports: [CombatPageComponent],
providers: [
provideRouter([]),
{ provide: CombatStore, useValue: combatStore },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
},
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(CombatPageComponent);
fixture.detectChanges();
// The route load resolves on the microtask queue before the combat renders.
await fixture.whenStable();
fixture.detectChanges();
return fixture;
}
afterEach(() => {
vi.useRealTimers();
});
it('loads the combat from the route param on init', async () => {
await setup(activeCombat);
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
});
it('shows the player, monster, HP bars, round, and the Angriff action', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker');
expect(element.textContent).toContain('95 / 100');
expect(element.textContent).toContain('Aschenratte');
expect(element.textContent).toContain('31 / 45');
expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Runde 2');
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
});
it('renders the structured events as readable German combat-log entries', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker trifft Aschenratte für 14 Schaden.');
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
});
it('calls combatStore.attack() when Angriff is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
expect(combatStore.attack).toHaveBeenCalledOnce();
});
it('plays the swing, reveals the monster damage, then the recoil a beat later', async () => {
const fixture = await setup(activeCombat);
const resolvedRound: Combat = {
...activeCombat,
round: 3,
player: { ...activeCombat.player, currentHp: 90 },
monster: { ...activeCombat.monster, currentHp: 17 },
events: [
...activeCombat.events,
{ round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
{ round: 2, sequence: 4, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
combatStore.attack.mockImplementation(async () => {
combatStore.combat.set(resolvedRound);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
const sprite = element.querySelector('.sprite--player');
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).toBe(true);
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.
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--attacking')).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.
expect(countOccurrences(element.textContent, monsterHitLine)).toBe(1);
// The monster strikes back after the beat.
await vi.advanceTimersByTimeAsync(260);
fixture.detectChanges();
expect(sprite?.classList.contains('sprite--hit')).toBe(true);
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);
});
it('skips the recoil when the round ends without the monster striking back', async () => {
const fixture = await setup(activeCombat);
const won: Combat = {
...activeCombat,
status: 'WON',
monster: { ...activeCombat.monster, currentHp: 0 },
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' },
],
};
combatStore.attack.mockImplementation(async () => {
combatStore.combat.set(won);
});
vi.useFakeTimers();
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
await vi.advanceTimersByTimeAsync(540);
fixture.detectChanges();
expect(element.querySelector('.sprite--player')?.classList.contains('sprite--hit')).toBe(false);
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('0 / 45');
});
it('disables Angriff while an action is pending', async () => {
const fixture = await setup(activeCombat);
combatStore.actionPending.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(true);
});
it('shows the victory state and hides Angriff when the combat is WON', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('Sieg');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('shows the defeat state and hides Angriff when the combat is LOST', async () => {
const fixture = await setup({ ...activeCombat, status: 'LOST' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="LOST"]')).toBeTruthy();
expect(element.textContent).toContain('Niederlage');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('navigates to /hunt from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-hunt]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
});
it('shows an error and retries loading the combat', async () => {
const fixture = await setup(null);
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Dieser Kampf wurde nicht gefunden.',
);
element.querySelector<HTMLButtonElement>('[data-combat-retry]')?.click();
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,198 @@
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { Combat, CombatEvent } from '../../../core/api/game-api.models';
import {
combatMonsterIconPath,
combatMonsterSpritePath,
combatMonsterSpriteScale,
runtimeMonsterArtworkPath,
} from '../../../shared/monster-artwork';
import { CombatStore } from '../combat.store';
interface CombatLogRound {
round: number;
events: CombatEvent[];
}
type CombatPhase = 'idle' | 'attacking' | 'hit';
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
// Must stay in step with the sprite-sheet animations in the stylesheet: the
// swing and the recoil each run six frames over these durations.
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;
@Component({
selector: 'app-combat-page',
templateUrl: './combat-page.component.html',
styleUrl: './combat-page.component.scss',
})
export class CombatPageComponent implements OnInit {
protected readonly combatStore = inject(CombatStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private destroyed = false;
// The server resolves a whole round at once. `combat` is what the screen is
// currently showing, so the round can be played back a beat at a time
// instead of both blows landing together.
private readonly displayed = signal<Combat | null>(null);
private readonly replaying = signal(false);
protected readonly combat = this.displayed.asReadonly();
protected readonly phase = signal<CombatPhase>('idle');
protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending());
protected readonly playerIcon = PLAYER_ICON;
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
}
ngOnInit(): void {
void this.loadFromRoute();
}
protected async attack(): Promise<void> {
const before = this.displayed();
if (!before || this.busy()) {
return;
}
this.replaying.set(true);
try {
this.phase.set('attacking');
const swing = this.wait(SWING_MS);
await this.combatStore.attack();
await swing;
if (this.destroyed) {
return;
}
this.phase.set('idle');
const after = this.combatStore.combat();
if (!after) {
return;
}
const riposte = after.events.find(
(event) =>
event.round === before.round && event.type === 'DAMAGE' && event.source === 'MONSTER',
);
if (!riposte) {
this.displayed.set(after);
return;
}
// Show the blow the player just landed, holding back the monster's reply.
this.displayed.set({
...after,
player: before.player,
events: after.events.filter((event) => event.sequence < riposte.sequence),
});
await this.wait(RIPOSTE_DELAY_MS);
if (this.destroyed) {
return;
}
this.phase.set('hit');
this.displayed.set(after);
await this.wait(RECOIL_MS);
if (this.destroyed) {
return;
}
this.phase.set('idle');
} finally {
if (!this.destroyed) {
this.replaying.set(false);
}
}
}
protected retry(): void {
void this.loadFromRoute();
}
protected goToHunt(): void {
void this.router.navigate(['/hunt']);
}
protected monsterSprite(monsterKey: string, artworkPath: string): string {
return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
protected monsterSpriteScale(monsterKey: string): number {
return combatMonsterSpriteScale(monsterKey);
}
protected monsterIcon(monsterKey: string, artworkPath: string): string {
return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
protected playerHpPercent(): number {
const combat = this.displayed();
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
}
protected monsterHpPercent(): number {
const combat = this.displayed();
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
}
protected logRounds(): CombatLogRound[] {
const combat = this.displayed();
if (!combat) {
return [];
}
const rounds = new Map<number, CombatEvent[]>();
for (const event of combat.events) {
const events = rounds.get(event.round) ?? [];
events.push(event);
rounds.set(event.round, events);
}
return [...rounds.entries()].sort(([a], [b]) => a - b).map(([round, events]) => ({ round, events }));
}
protected formatEvent(event: CombatEvent): string {
const combat = this.displayed();
const playerName = combat?.player.name ?? 'Du';
const monsterName = combat?.monster.name ?? 'Der Gegner';
if (event.type === 'DAMAGE') {
const attacker = event.source === 'PLAYER' ? playerName : monsterName;
const defender = event.target === 'PLAYER' ? playerName : monsterName;
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
}
if (event.type === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`;
}
return `${playerName} wurde im Kampf besiegt.`;
}
private wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async loadFromRoute(): Promise<void> {
const combatId = this.route.snapshot.paramMap.get('combatId');
if (!combatId) {
return;
}
await this.combatStore.loadCombat(combatId);
if (!this.destroyed) {
this.displayed.set(this.combatStore.combat());
}
}
}

View File

@@ -1,72 +0,0 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-combat-placeholder-page',
template: `
<section class="combat-placeholder" aria-label="Kampfvorbereitung">
<span class="combat-placeholder__eyebrow">KAMPF</span>
<h2>Vorbereitung auf den Kampf</h2>
<p>
Die Klinge ist gezogen, der Gegner steht bereit doch das eigentliche Gefecht liegt noch
vor dir. Diese Ansicht ist ein Zwischenhalt auf dem Weg in den Kampf, der in einem
späteren Schritt folgt.
</p>
@if (encounterId) {
<p class="combat-placeholder__id" data-encounter-id>
Vorbereitung auf den Kampf gegen Begegnung {{ encounterId }}…
</p>
}
</section>
`,
styles: [
`
:host {
display: block;
}
.combat-placeholder {
display: grid;
gap: var(--ar-space-3);
max-inline-size: 40rem;
margin: var(--ar-space-6) auto;
padding: var(--ar-space-5);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.combat-placeholder__eyebrow {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.combat-placeholder h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.6rem;
font-weight: 400;
}
.combat-placeholder p {
margin: 0;
color: var(--ar-text-muted);
line-height: 1.55;
}
.combat-placeholder__id {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic;
}
`,
],
})
export class CombatPlaceholderPageComponent {
private readonly route = inject(ActivatedRoute);
protected readonly encounterId = this.route.snapshot.queryParamMap.get('encounterId');
}

View File

@@ -0,0 +1,194 @@
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { from, of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type { Combat } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { CombatStore } from './combat.store';
const startedCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
},
events: [],
};
const afterAttack: Combat = {
...startedCombat,
round: 2,
player: { ...startedCombat.player, currentHp: 95 },
monster: { ...startedCombat.monster, currentHp: 31 },
events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
describe('CombatStore', () => {
let api: {
startCombat: ReturnType<typeof vi.fn>;
getCombat: ReturnType<typeof vi.fn>;
getActiveCombat: ReturnType<typeof vi.fn>;
performCombatAction: ReturnType<typeof vi.fn>;
};
let store: CombatStore;
beforeEach(() => {
api = {
startCombat: vi.fn(() => of(startedCombat)),
getCombat: vi.fn(() => of(startedCombat)),
getActiveCombat: vi.fn(() => of(startedCombat)),
performCombatAction: vi.fn(() => of(afterAttack)),
};
TestBed.configureTestingModule({
providers: [CombatStore, { provide: GameApiService, useValue: api }],
});
store = TestBed.inject(CombatStore);
});
it('starts a combat and stores it', async () => {
await store.startCombat('encounter-1');
expect(api.startCombat).toHaveBeenCalledWith('encounter-1');
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBeNull();
});
it('clears any previous combat and reports the mapped error when starting fails', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
}),
),
);
await store.startCombat('encounter-1');
expect(store.combat()).toBeNull();
expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
});
it('loads the running combat and clears the error that sent us looking for it', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
}),
),
);
await store.startCombat('encounter-1');
const active = await store.loadActiveCombat();
expect(api.getActiveCombat).toHaveBeenCalledOnce();
expect(active).toEqual(startedCombat);
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBeNull();
expect(store.errorCode()).toBeNull();
});
it('resolves null and keeps the combat empty when no fight is running', async () => {
api.getActiveCombat.mockReturnValue(of(null));
const active = await store.loadActiveCombat();
expect(active).toBeNull();
expect(store.combat()).toBeNull();
});
it('loads a combat by id', async () => {
await store.loadCombat('combat-1');
expect(api.getCombat).toHaveBeenCalledWith('combat-1');
expect(store.combat()).toEqual(startedCombat);
});
it('reports the mapped error when loading an unknown combat', async () => {
api.getCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 404,
error: { statusCode: 404, code: 'COMBAT_NOT_FOUND', message: 'Not found.' },
}),
),
);
await store.loadCombat('unknown');
expect(store.error()).toBe('Dieser Kampf wurde nicht gefunden.');
});
it('sends only the ATTACK action and replaces combat with the server response', async () => {
await store.startCombat('encounter-1');
await store.attack();
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK');
expect(store.combat()).toEqual(afterAttack);
});
it('does nothing when attacking without a loaded combat', async () => {
await store.attack();
expect(api.performCombatAction).not.toHaveBeenCalled();
});
it('ignores a second attack while the first is still pending', async () => {
await store.startCombat('encounter-1');
let resolveAttack!: (value: Combat) => void;
api.performCombatAction.mockReturnValue(
from(
new Promise<Combat>((resolve) => {
resolveAttack = resolve;
}),
),
);
const first = store.attack();
expect(store.actionPending()).toBe(true);
const second = store.attack();
resolveAttack(afterAttack);
await Promise.all([first, second]);
expect(api.performCombatAction).toHaveBeenCalledOnce();
});
it('clears actionPending after a failed attack and keeps the previous combat state', async () => {
await store.startCombat('encounter-1');
api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
await store.attack();
expect(store.actionPending()).toBe(false);
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBe('Netzwerkfehler');
});
it('clears the error message', async () => {
api.startCombat.mockReturnValue(throwError(() => new Error('x')));
await store.startCombat('encounter-1');
expect(store.error()).not.toBeNull();
store.clearError();
expect(store.error()).toBeNull();
});
});

View File

@@ -0,0 +1,131 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Combat } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
// Mirrors the combat error codes returned by the combat endpoints.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.',
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
};
@Injectable({ providedIn: 'root' })
export class CombatStore {
private readonly combatState = signal<Combat | null>(null);
private readonly loadingState = signal(false);
private readonly actionPendingState = signal(false);
private readonly errorState = signal<string | null>(null);
private readonly errorCodeState = signal<string | null>(null);
readonly combat = this.combatState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly actionPending = this.actionPendingState.asReadonly();
readonly error = this.errorState.asReadonly();
readonly errorCode = this.errorCodeState.asReadonly();
constructor(private readonly api: GameApiService) {}
async startCombat(encounterId: string): Promise<void> {
this.loadingState.set(true);
this.clearError();
try {
const combat = await firstValueFrom(this.api.startCombat(encounterId));
this.combatState.set(combat);
} catch (error) {
this.combatState.set(null);
this.setError(error);
} finally {
this.loadingState.set(false);
}
}
// Resolves the combat the character is already in, so an attack rejected with
// COMBAT_ALREADY_ACTIVE can rejoin that fight instead of dead-ending.
async loadActiveCombat(): Promise<Combat | null> {
this.loadingState.set(true);
try {
const combat = await firstValueFrom(this.api.getActiveCombat());
if (combat) {
this.combatState.set(combat);
this.clearError();
}
return combat;
} catch (error) {
this.setError(error);
return null;
} finally {
this.loadingState.set(false);
}
}
async loadCombat(combatId: string): Promise<void> {
this.loadingState.set(true);
this.clearError();
try {
const combat = await firstValueFrom(this.api.getCombat(combatId));
this.combatState.set(combat);
} catch (error) {
this.setError(error);
} finally {
this.loadingState.set(false);
}
}
async attack(): Promise<void> {
const combat = this.combatState();
if (!combat || this.actionPendingState()) {
return;
}
this.actionPendingState.set(true);
this.clearError();
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
this.combatState.set(updated);
} catch (error) {
this.setError(error);
} finally {
this.actionPendingState.set(false);
}
}
clearError(): void {
this.errorState.set(null);
this.errorCodeState.set(null);
}
private setError(error: unknown): void {
this.errorCodeState.set(this.toErrorCode(error));
this.errorState.set(this.toErrorMessage(error));
}
private toErrorCode(error: unknown): string | null {
if (error instanceof HttpErrorResponse) {
return (error.error as { code?: string } | null)?.code ?? null;
}
return null;
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && COMBAT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
}
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
}
}

View File

@@ -1,12 +1,8 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { HuntEncounter } from '../../../core/api/game-api.models';
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork';
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
const runtimeArtworkPaths: Readonly<Record<string, string>> = {
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
};
@Component({
selector: 'app-encounter-card',
imports: [DangerBadgeComponent],
@@ -22,6 +18,6 @@ export class EncounterCardComponent {
}
protected runtimeArtworkPath(artworkPath: string): string | undefined {
return runtimeArtworkPaths[artworkPath];
return runtimeMonsterArtworkPath(artworkPath);
}
}

View File

@@ -64,4 +64,11 @@
<button type="button" data-hunt-retry (click)="retry()">Erneut versuchen</button>
</section>
}
@if (combatStore.error(); as combatError) {
<section class="hunt-page__error" role="alert">
<p>{{ combatError }}</p>
<button type="button" data-hunt-combat-dismiss (click)="dismissCombatError()">Schließen</button>
</section>
}
</section>

View File

@@ -1,8 +1,10 @@
// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component';
@@ -42,12 +44,7 @@ const threeEncounterHunt: HuntResult = {
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
},
{
@@ -62,17 +59,28 @@ const threeEncounterHunt: HuntResult = {
},
{
id: 'encounter-3',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
},
],
};
const startedCombat: Combat = {
id: 'combat-2',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
maxHp: 75,
currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
};
describe('HuntPageComponent', () => {
let worldStore: {
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
@@ -87,6 +95,14 @@ describe('HuntPageComponent', () => {
refreshHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
error: ReturnType<typeof signal<string | null>>;
errorCode: ReturnType<typeof signal<string | null>>;
startCombat: ReturnType<typeof vi.fn>;
loadActiveCombat: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
@@ -101,6 +117,14 @@ describe('HuntPageComponent', () => {
refreshHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(),
};
combatStore = {
combat: signal<Combat | null>(null),
error: signal<string | null>(null),
errorCode: signal<string | null>(null),
startCombat: vi.fn(() => Promise.resolve()),
loadActiveCombat: vi.fn(() => Promise.resolve(null)),
clearError: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [HuntPageComponent],
@@ -108,6 +132,7 @@ describe('HuntPageComponent', () => {
provideRouter([]),
{ provide: WorldStore, useValue: worldStore },
{ provide: HuntingStore, useValue: huntingStore },
{ provide: CombatStore, useValue: combatStore },
],
}).compileComponents();
@@ -124,9 +149,7 @@ describe('HuntPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Keine Jagd verfügbar');
expect(element.textContent).toContain(
'Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.',
);
expect(element.textContent).toContain('Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.');
expect(
Array.from(element.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === 'Jagd beginnen',
@@ -171,8 +194,11 @@ describe('HuntPageComponent', () => {
expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
});
it('navigates to /combat/new with the encounter id (not the monster key) when Angreifen is clicked', async () => {
it('starts a real combat from the encounter id (not the monster key) and navigates to /combat/:combatId', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.combat.set(startedCombat);
});
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
@@ -181,14 +207,84 @@ describe('HuntPageComponent', () => {
expect(attackButtons.length).toBe(3);
attackButtons[1].click();
await Promise.resolve();
await Promise.resolve();
expect(huntingStore.selectEncounter).toHaveBeenCalledWith('encounter-2');
expect(router.navigate).toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'encounter-2' },
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-2');
expect(combatStore.startCombat).not.toHaveBeenCalledWith('road-bandit');
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-2']);
});
it('does not navigate when starting the combat fails', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-1');
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('rejoins the running combat when the attack is rejected with COMBAT_ALREADY_ACTIVE', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('COMBAT_ALREADY_ACTIVE');
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
});
expect(router.navigate).not.toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'road-bandit' },
combatStore.loadActiveCombat.mockResolvedValue({ ...startedCombat, id: 'combat-running' });
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.loadActiveCombat).toHaveBeenCalledOnce();
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-running']);
});
it('does not look for a running combat when the attack fails for another reason', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('HUNT_ENCOUNTER_ALREADY_CONSUMED');
combatStore.error.set('Diese Begegnung wurde bereits genutzt.');
});
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.loadActiveCombat).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('shows a combat-start error and dismisses it', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const alerts = Array.from(element.querySelectorAll('[role="alert"]'));
expect(alerts.some((alert) => alert.textContent?.includes('Du befindest dich bereits in einem Kampf.'))).toBe(
true,
);
element.querySelector<HTMLButtonElement>('[data-hunt-combat-dismiss]')?.click();
expect(combatStore.clearError).toHaveBeenCalledOnce();
});
it('does not trigger a hunt automatically on page entry', async () => {

View File

@@ -1,8 +1,9 @@
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
import { HuntingStore } from '../hunting.store';
import { WorldStore } from '../../world/world.store';
@Component({
selector: 'app-hunt-page',
@@ -13,6 +14,7 @@ import { WorldStore } from '../../world/world.store';
export class HuntPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore);
protected readonly huntingStore = inject(HuntingStore);
protected readonly combatStore = inject(CombatStore);
private readonly router = inject(Router);
ngOnInit(): void {
@@ -41,8 +43,25 @@ export class HuntPageComponent implements OnInit {
void this.router.navigate(['/world']);
}
protected onAttack(encounterId: string): void {
this.huntingStore.selectEncounter(encounterId);
void this.router.navigate(['/combat/new'], { queryParams: { encounterId } });
protected async onAttack(encounterId: string): Promise<void> {
await this.combatStore.startCombat(encounterId);
const combat = this.combatStore.combat();
if (combat) {
void this.router.navigate(['/combat', combat.id]);
return;
}
// A fight already running is not a dead end: rejoin it rather than
// leaving the player stuck behind an error they cannot act on.
if (this.combatStore.errorCode() === 'COMBAT_ALREADY_ACTIVE') {
const active = await this.combatStore.loadActiveCombat();
if (active) {
void this.router.navigate(['/combat', active.id]);
}
}
}
protected dismissCombatError(): void {
this.combatStore.clearError();
}
}

View File

@@ -8,9 +8,13 @@
align-items: center;
min-block-size: 5.6rem;
padding: var(--ar-space-2) var(--ar-space-5);
border-block: 1px solid var(--ar-border-highlight);
background: linear-gradient(180deg, rgb(255 255 255 / 0.035), transparent 62%), var(--ar-panel);
// border-block: 1px solid var(--ar-border-highlight);
//background: linear-gradient(180deg, rgb(255 255 255 / 0.035), transparent 62%), var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
background-image: url('../../../../public/assets/hud-elements/statusbar.png');
background-repeat: repeat-x;
background-size: 547px;
background-position: center;
}
.top-bar__character {
@@ -18,15 +22,22 @@
align-items: center;
gap: var(--ar-space-3);
min-inline-size: 0;
background-image: url("../../../../public/assets/hud-elements/character-statusbar.png");
background-size: 458px;
background-position: left;
background-repeat: no-repeat;
}
.top-bar__portrait {
inline-size: 4.25rem;
block-size: 4.25rem;
object-fit: cover;
border: 1px solid var(--ar-border-highlight);
// border: 1px solid var(--ar-border-highlight);
border-radius: 50%;
background: #080909;
// background: #080909;
left: 7px;
position: relative;
top: -2px;
}
.top-bar__identity,

View File

@@ -0,0 +1,13 @@
import { runtimeMonsterArtworkPath } from './monster-artwork';
describe('runtimeMonsterArtworkPath', () => {
it('returns the optimized JPEG derivative for a known monster artwork path', () => {
expect(runtimeMonsterArtworkPath('/images/monsters/ash-rat.png')).toBe(
'/images/monsters/runtime/ash-rat-560.jpg',
);
});
it('returns undefined for an artwork path with no runtime derivative', () => {
expect(runtimeMonsterArtworkPath('/images/enemies/Dawnwolf.png')).toBeUndefined();
});
});

View File

@@ -0,0 +1,39 @@
const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
};
export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
return RUNTIME_MONSTER_ARTWORK[artworkPath];
}
const COMBAT_MONSTER_SPRITE: Readonly<Record<string, string>> = {
'ash-rat': '/images/combat/sprites/ash-rat-760.png',
'road-bandit': '/images/combat/sprites/road-bandit-620.png',
};
const COMBAT_MONSTER_ICON: Readonly<Record<string, string>> = {
'ash-rat': '/images/combat/icons/ash-rat-128.png',
'road-bandit': '/images/combat/icons/road-bandit-128.png',
};
// Share of the battlefield height each monster sprite occupies, so a hulking
// bandit and a low-slung rat keep believable proportions against the player.
const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
'ash-rat': 0.46,
'road-bandit': 0.82,
};
const DEFAULT_MONSTER_SCALE = 0.6;
export function combatMonsterSpritePath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_SPRITE[monsterKey];
}
export function combatMonsterSpriteScale(monsterKey: string): number {
return COMBAT_MONSTER_SCALE[monsterKey] ?? DEFAULT_MONSTER_SCALE;
}
export function combatMonsterIconPath(monsterKey: string): string | undefined {
return COMBAT_MONSTER_ICON[monsterKey];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB