Merge branch 'worktree-slice-0.3-first-combat'
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
28
apps/api/src/characters/character-combat-stats.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
6
apps/api/src/combat/combat-action.enum.ts
Normal 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',
|
||||
}
|
||||
17
apps/api/src/combat/combat-damage.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
13
apps/api/src/combat/combat-damage.ts
Normal 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));
|
||||
}
|
||||
107
apps/api/src/combat/combat-engine.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
apps/api/src/combat/combat-engine.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
apps/api/src/combat/combat-engine.types.ts
Normal 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[];
|
||||
}
|
||||
5
apps/api/src/combat/combat-event-type.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum CombatEventType {
|
||||
DAMAGE = 'DAMAGE',
|
||||
COMBAT_WON = 'COMBAT_WON',
|
||||
COMBAT_LOST = 'COMBAT_LOST',
|
||||
}
|
||||
5
apps/api/src/combat/combat-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum CombatStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
WON = 'WON',
|
||||
LOST = 'LOST',
|
||||
}
|
||||
72
apps/api/src/combat/combat.controller.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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 performAction = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
getCombat.mockReset();
|
||||
performAction.mockReset();
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [CombatController],
|
||||
providers: [{ provide: CombatService, useValue: { getCombat, 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 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();
|
||||
});
|
||||
});
|
||||
19
apps/api/src/combat/combat.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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) {}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
87
apps/api/src/combat/combat.errors.ts
Normal 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';
|
||||
25
apps/api/src/combat/combat.module.ts
Normal 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 {}
|
||||
614
apps/api/src/combat/combat.service.spec.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
});
|
||||
339
apps/api/src/combat/combat.service.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
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 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
4
apps/api/src/combat/combatant.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum Combatant {
|
||||
PLAYER = 'PLAYER',
|
||||
MONSTER = 'MONSTER',
|
||||
}
|
||||
7
apps/api/src/combat/dto/combat-action.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { CombatAction } from '../combat-action.enum';
|
||||
|
||||
export class CombatActionDto {
|
||||
@IsEnum(CombatAction)
|
||||
action!: CombatAction;
|
||||
}
|
||||
62
apps/api/src/combat/entities/combat-event.entity.ts
Normal 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;
|
||||
}
|
||||
89
apps/api/src/combat/entities/combat.entity.ts
Normal 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;
|
||||
}
|
||||
41
apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
13
apps/api/src/combat/hunt-encounter-attack.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
BIN
apps/web/public/images/combat/icons/ash-rat-128.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/images/combat/icons/road-bandit-128.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/public/images/combat/sprites/ash-rat-760.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
apps/web/public/images/combat/sprites/road-bandit-620.png
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
apps/web/public/images/combat/sprites/warrior-attack-512.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/web/public/images/hud/runtime/AttackIcon-96.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
apps/web/public/images/hud/runtime/fight-action-frame-360.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
@@ -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,
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -47,4 +47,30 @@ 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('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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,16 @@ 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}`);
|
||||
}
|
||||
|
||||
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
|
||||
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<section class="combat" aria-label="Kampf">
|
||||
@if (combatStore.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">
|
||||
<img class="sprite sprite--player" [src]="playerSprite" [alt]="combat.player.name" />
|
||||
<img
|
||||
class="sprite sprite--monster"
|
||||
[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]="combatStore.actionPending()"
|
||||
(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>
|
||||
@@ -0,0 +1,479 @@
|
||||
: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 ---------- */
|
||||
|
||||
.combat__stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
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: linear-gradient(180deg, rgb(0 0 0 / 0.85), rgb(0 0 0 / 0.6));
|
||||
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-block-size: 100%;
|
||||
inline-size: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 1rem 1.5rem rgb(0 0 0 / 0.75));
|
||||
}
|
||||
|
||||
.sprite--player {
|
||||
justify-self: start;
|
||||
block-size: clamp(11rem, 30vh, 19rem);
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.sprite--monster {
|
||||
justify-self: end;
|
||||
block-size: clamp(9rem, 24vh, 15rem);
|
||||
}
|
||||
|
||||
/* ---------- 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 {
|
||||
position: absolute;
|
||||
inset-block-start: 32%;
|
||||
inset-inline-start: 50%;
|
||||
inline-size: 34%;
|
||||
translate: -50% -50%;
|
||||
border-radius: 50%;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.action__label {
|
||||
position: absolute;
|
||||
inset-block-start: 62%;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% -50%;
|
||||
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 {
|
||||
position: absolute;
|
||||
inset-block-start: 90%;
|
||||
inset-inline-start: 50%;
|
||||
translate: -50% -50%;
|
||||
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 {
|
||||
margin-block-start: var(--ar-space-2);
|
||||
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: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;
|
||||
padding: var(--ar-space-2) var(--ar-space-3);
|
||||
border: 1px solid var(--ar-border-highlight);
|
||||
border-radius: var(--ar-radius-sm);
|
||||
color: var(--ar-text);
|
||||
background: #1a2023;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 40rem) {
|
||||
.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;
|
||||
}
|
||||
|
||||
.sprite--player {
|
||||
block-size: clamp(8rem, 22vh, 12rem);
|
||||
}
|
||||
|
||||
.sprite--monster {
|
||||
block-size: clamp(7rem, 18vh, 10rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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 },
|
||||
],
|
||||
};
|
||||
|
||||
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();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import type { CombatEvent } from '../../../core/api/game-api.models';
|
||||
import {
|
||||
combatMonsterIconPath,
|
||||
combatMonsterSpritePath,
|
||||
runtimeMonsterArtworkPath,
|
||||
} from '../../../shared/monster-artwork';
|
||||
import { CombatStore } from '../combat.store';
|
||||
|
||||
interface CombatLogRound {
|
||||
round: number;
|
||||
events: CombatEvent[];
|
||||
}
|
||||
|
||||
const PLAYER_SPRITE = '/images/combat/sprites/warrior-attack-512.png';
|
||||
const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png';
|
||||
|
||||
@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);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadFromRoute();
|
||||
}
|
||||
|
||||
protected attack(): void {
|
||||
void this.combatStore.attack();
|
||||
}
|
||||
|
||||
protected retry(): void {
|
||||
this.loadFromRoute();
|
||||
}
|
||||
|
||||
protected goToHunt(): void {
|
||||
void this.router.navigate(['/hunt']);
|
||||
}
|
||||
|
||||
protected readonly playerSprite = PLAYER_SPRITE;
|
||||
protected readonly playerIcon = PLAYER_ICON;
|
||||
|
||||
protected monsterSprite(monsterKey: string, artworkPath: string): string {
|
||||
return combatMonsterSpritePath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
|
||||
}
|
||||
|
||||
protected monsterIcon(monsterKey: string, artworkPath: string): string {
|
||||
return combatMonsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
|
||||
}
|
||||
|
||||
protected playerHpPercent(): number {
|
||||
const combat = this.combatStore.combat();
|
||||
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
|
||||
}
|
||||
|
||||
protected monsterHpPercent(): number {
|
||||
const combat = this.combatStore.combat();
|
||||
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
|
||||
}
|
||||
|
||||
protected logRounds(): CombatLogRound[] {
|
||||
const combat = this.combatStore.combat();
|
||||
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.combatStore.combat();
|
||||
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 loadFromRoute(): void {
|
||||
const combatId = this.route.snapshot.paramMap.get('combatId');
|
||||
if (combatId) {
|
||||
void this.combatStore.loadCombat(combatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
161
apps/web/src/app/features/combat/combat.store.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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>;
|
||||
performCombatAction: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let store: CombatStore;
|
||||
|
||||
beforeEach(() => {
|
||||
api = {
|
||||
startCombat: vi.fn(() => of(startedCombat)),
|
||||
getCombat: 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.');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
95
apps/web/src/app/features/combat/combat.store.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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);
|
||||
|
||||
readonly combat = this.combatState.asReadonly();
|
||||
readonly loading = this.loadingState.asReadonly();
|
||||
readonly actionPending = this.actionPendingState.asReadonly();
|
||||
readonly error = this.errorState.asReadonly();
|
||||
|
||||
constructor(private readonly api: GameApiService) {}
|
||||
|
||||
async startCombat(encounterId: string): Promise<void> {
|
||||
this.loadingState.set(true);
|
||||
this.errorState.set(null);
|
||||
|
||||
try {
|
||||
const combat = await firstValueFrom(this.api.startCombat(encounterId));
|
||||
this.combatState.set(combat);
|
||||
} catch (error) {
|
||||
this.combatState.set(null);
|
||||
this.errorState.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.loadingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadCombat(combatId: string): Promise<void> {
|
||||
this.loadingState.set(true);
|
||||
this.errorState.set(null);
|
||||
|
||||
try {
|
||||
const combat = await firstValueFrom(this.api.getCombat(combatId));
|
||||
this.combatState.set(combat);
|
||||
} catch (error) {
|
||||
this.errorState.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.loadingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async attack(): Promise<void> {
|
||||
const combat = this.combatState();
|
||||
if (!combat || this.actionPendingState()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionPendingState.set(true);
|
||||
this.errorState.set(null);
|
||||
|
||||
try {
|
||||
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
|
||||
this.combatState.set(updated);
|
||||
} catch (error) {
|
||||
this.errorState.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.actionPendingState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
clearError(): void {
|
||||
this.errorState.set(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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,12 @@ 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>>;
|
||||
startCombat: ReturnType<typeof vi.fn>;
|
||||
clearError: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let router: Router;
|
||||
|
||||
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
|
||||
@@ -101,6 +115,12 @@ describe('HuntPageComponent', () => {
|
||||
refreshHunt: vi.fn(() => Promise.resolve()),
|
||||
selectEncounter: vi.fn(),
|
||||
};
|
||||
combatStore = {
|
||||
combat: signal<Combat | null>(null),
|
||||
error: signal<string | null>(null),
|
||||
startCombat: vi.fn(() => Promise.resolve()),
|
||||
clearError: vi.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HuntPageComponent],
|
||||
@@ -108,6 +128,7 @@ describe('HuntPageComponent', () => {
|
||||
provideRouter([]),
|
||||
{ provide: WorldStore, useValue: worldStore },
|
||||
{ provide: HuntingStore, useValue: huntingStore },
|
||||
{ provide: CombatStore, useValue: combatStore },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -124,9 +145,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 +190,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 +203,43 @@ 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(router.navigate).not.toHaveBeenCalledWith(['/combat/new'], {
|
||||
queryParams: { encounterId: 'road-bandit' },
|
||||
});
|
||||
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('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 () => {
|
||||
|
||||
@@ -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,15 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
protected dismissCombatError(): void {
|
||||
this.combatStore.clearError();
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/web/src/app/shared/monster-artwork.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
apps/web/src/app/shared/monster-artwork.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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',
|
||||
};
|
||||
|
||||
export function combatMonsterSpritePath(monsterKey: string): string | undefined {
|
||||
return COMBAT_MONSTER_SPRITE[monsterKey];
|
||||
}
|
||||
|
||||
export function combatMonsterIconPath(monsterKey: string): string | undefined {
|
||||
return COMBAT_MONSTER_ICON[monsterKey];
|
||||
}
|
||||