diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index fff7a2a..c8be7e0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/characters/character-combat-stats.service.spec.ts b/apps/api/src/characters/character-combat-stats.service.spec.ts new file mode 100644 index 0000000..2cb2279 --- /dev/null +++ b/apps/api/src/characters/character-combat-stats.service.spec.ts @@ -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, + }); + }); +}); diff --git a/apps/api/src/characters/character-combat-stats.service.ts b/apps/api/src/characters/character-combat-stats.service.ts new file mode 100644 index 0000000..665d893 --- /dev/null +++ b/apps/api/src/characters/character-combat-stats.service.ts @@ -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, + }; + } +} diff --git a/apps/api/src/characters/characters.module.ts b/apps/api/src/characters/characters.module.ts index cb5e2c3..f8a46c1 100644 --- a/apps/api/src/characters/characters.module.ts +++ b/apps/api/src/characters/characters.module.ts @@ -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 {} diff --git a/apps/api/src/combat/combat-action.enum.ts b/apps/api/src/combat/combat-action.enum.ts new file mode 100644 index 0000000..1dadb45 --- /dev/null +++ b/apps/api/src/combat/combat-action.enum.ts @@ -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', +} diff --git a/apps/api/src/combat/combat-damage.spec.ts b/apps/api/src/combat/combat-damage.spec.ts new file mode 100644 index 0000000..a5a2c4e --- /dev/null +++ b/apps/api/src/combat/combat-damage.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/combat/combat-damage.ts b/apps/api/src/combat/combat-damage.ts new file mode 100644 index 0000000..a8d8810 --- /dev/null +++ b/apps/api/src/combat/combat-damage.ts @@ -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)); +} diff --git a/apps/api/src/combat/combat-engine.service.spec.ts b/apps/api/src/combat/combat-engine.service.spec.ts new file mode 100644 index 0000000..b94732d --- /dev/null +++ b/apps/api/src/combat/combat-engine.service.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/src/combat/combat-engine.service.ts b/apps/api/src/combat/combat-engine.service.ts new file mode 100644 index 0000000..a7e1725 --- /dev/null +++ b/apps/api/src/combat/combat-engine.service.ts @@ -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, + }; + } +} diff --git a/apps/api/src/combat/combat-engine.types.ts b/apps/api/src/combat/combat-engine.types.ts new file mode 100644 index 0000000..b108363 --- /dev/null +++ b/apps/api/src/combat/combat-engine.types.ts @@ -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[]; +} diff --git a/apps/api/src/combat/combat-event-type.enum.ts b/apps/api/src/combat/combat-event-type.enum.ts new file mode 100644 index 0000000..61294e1 --- /dev/null +++ b/apps/api/src/combat/combat-event-type.enum.ts @@ -0,0 +1,5 @@ +export enum CombatEventType { + DAMAGE = 'DAMAGE', + COMBAT_WON = 'COMBAT_WON', + COMBAT_LOST = 'COMBAT_LOST', +} diff --git a/apps/api/src/combat/combat-status.enum.ts b/apps/api/src/combat/combat-status.enum.ts new file mode 100644 index 0000000..16b321d --- /dev/null +++ b/apps/api/src/combat/combat-status.enum.ts @@ -0,0 +1,5 @@ +export enum CombatStatus { + ACTIVE = 'ACTIVE', + WON = 'WON', + LOST = 'LOST', +} diff --git a/apps/api/src/combat/combat.controller.spec.ts b/apps/api/src/combat/combat.controller.spec.ts new file mode 100644 index 0000000..a2e5be3 --- /dev/null +++ b/apps/api/src/combat/combat.controller.spec.ts @@ -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; + 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(); + 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(); + }); +}); diff --git a/apps/api/src/combat/combat.controller.ts b/apps/api/src/combat/combat.controller.ts new file mode 100644 index 0000000..4d68e56 --- /dev/null +++ b/apps/api/src/combat/combat.controller.ts @@ -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); + } +} diff --git a/apps/api/src/combat/combat.errors.ts b/apps/api/src/combat/combat.errors.ts new file mode 100644 index 0000000..bc125ba --- /dev/null +++ b/apps/api/src/combat/combat.errors.ts @@ -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'; diff --git a/apps/api/src/combat/combat.module.ts b/apps/api/src/combat/combat.module.ts new file mode 100644 index 0000000..10ace1b --- /dev/null +++ b/apps/api/src/combat/combat.module.ts @@ -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 {} diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts new file mode 100644 index 0000000..e0ee9ac --- /dev/null +++ b/apps/api/src/combat/combat.service.spec.ts @@ -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 { + constructor( + private readonly state: FakeState, + private readonly target: EntityTarget, + private readonly inTransaction: boolean, + private readonly dataSource: FakeDataSource, + ) {} + + findOne(options: { + where: Partial; + lock?: { mode: string }; + }): Promise { + 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): Promise { + return Promise.resolve( + this.rows().find((row) => this.matches(row, where)) ?? null, + ); + } + + find(options: { + where: Partial; + order?: Partial>; + }): Promise { + 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 }): Promise { + return Promise.resolve( + this.rows().filter((row) => this.matches(row, options.where)).length, + ); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + 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): 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(target: EntityTarget) { + return new FakeRepository(this.state, target, true, this.dataSource); + } +} + +class FakeDataSource { + readonly locks: Array<{ target: EntityTarget; mode: string }> = []; + private readonly idCounters = new Map(); + + constructor(public state: FakeState) {} + + getRepository(target: EntityTarget) { + return new FakeRepository(this.state, target, false, this); + } + + async transaction( + work: (manager: EntityManager) => Promise, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + code: string, +): Promise { + 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); + }); + }); +}); diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts new file mode 100644 index 0000000..e85434d --- /dev/null +++ b/apps/api/src/combat/combat.service.ts @@ -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 { + 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 { + 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 { + 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, + characterId: string, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + })), + }; + } +} diff --git a/apps/api/src/combat/combatant.enum.ts b/apps/api/src/combat/combatant.enum.ts new file mode 100644 index 0000000..db5a06f --- /dev/null +++ b/apps/api/src/combat/combatant.enum.ts @@ -0,0 +1,4 @@ +export enum Combatant { + PLAYER = 'PLAYER', + MONSTER = 'MONSTER', +} diff --git a/apps/api/src/combat/dto/combat-action.dto.ts b/apps/api/src/combat/dto/combat-action.dto.ts new file mode 100644 index 0000000..494fdf2 --- /dev/null +++ b/apps/api/src/combat/dto/combat-action.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { CombatAction } from '../combat-action.enum'; + +export class CombatActionDto { + @IsEnum(CombatAction) + action!: CombatAction; +} diff --git a/apps/api/src/combat/entities/combat-event.entity.ts b/apps/api/src/combat/entities/combat-event.entity.ts new file mode 100644 index 0000000..93bdf5a --- /dev/null +++ b/apps/api/src/combat/entities/combat-event.entity.ts @@ -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; +} diff --git a/apps/api/src/combat/entities/combat.entity.ts b/apps/api/src/combat/entities/combat.entity.ts new file mode 100644 index 0000000..9f602e8 --- /dev/null +++ b/apps/api/src/combat/entities/combat.entity.ts @@ -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; +} diff --git a/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts b/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts new file mode 100644 index 0000000..a862876 --- /dev/null +++ b/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts @@ -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; + const startCombat = jest.fn(); + + beforeEach(async () => { + startCombat.mockReset(); + const module = await Test.createTestingModule({ + controllers: [HuntEncounterAttackController], + providers: [{ provide: CombatService, useValue: { startCombat } }], + }).compile(); + + app = module.createNestApplication(); + 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); + }); +}); diff --git a/apps/api/src/combat/hunt-encounter-attack.controller.ts b/apps/api/src/combat/hunt-encounter-attack.controller.ts new file mode 100644 index 0000000..f6da9cf --- /dev/null +++ b/apps/api/src/combat/hunt-encounter-attack.controller.ts @@ -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); + } +} diff --git a/apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts b/apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts new file mode 100644 index 0000000..26fd03b --- /dev/null +++ b/apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts @@ -0,0 +1,89 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCombatSystem1788100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"', + ); + } +} diff --git a/apps/api/src/database/migrations/combat-system.migration.spec.ts b/apps/api/src/database/migrations/combat-system.migration.spec.ts new file mode 100644 index 0000000..373894f --- /dev/null +++ b/apps/api/src/database/migrations/combat-system.migration.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/hunting/entities/hunt-encounter.entity.ts b/apps/api/src/hunting/entities/hunt-encounter.entity.ts index 2a76fe4..e6f0aa8 100644 --- a/apps/api/src/hunting/entities/hunt-encounter.entity.ts +++ b/apps/api/src/hunting/entities/hunt-encounter.entity.ts @@ -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; diff --git a/apps/web/public/images/combat/icons/ash-rat-128.png b/apps/web/public/images/combat/icons/ash-rat-128.png new file mode 100644 index 0000000..f1c3f2c Binary files /dev/null and b/apps/web/public/images/combat/icons/ash-rat-128.png differ diff --git a/apps/web/public/images/combat/icons/road-bandit-128.png b/apps/web/public/images/combat/icons/road-bandit-128.png new file mode 100644 index 0000000..689b4a5 Binary files /dev/null and b/apps/web/public/images/combat/icons/road-bandit-128.png differ diff --git a/apps/web/public/images/combat/sprites/ash-rat-760.png b/apps/web/public/images/combat/sprites/ash-rat-760.png new file mode 100644 index 0000000..2cb0e9a Binary files /dev/null and b/apps/web/public/images/combat/sprites/ash-rat-760.png differ diff --git a/apps/web/public/images/combat/sprites/road-bandit-620.png b/apps/web/public/images/combat/sprites/road-bandit-620.png new file mode 100644 index 0000000..82a4b62 Binary files /dev/null and b/apps/web/public/images/combat/sprites/road-bandit-620.png differ diff --git a/apps/web/public/images/combat/sprites/warrior-attack-512.png b/apps/web/public/images/combat/sprites/warrior-attack-512.png new file mode 100644 index 0000000..c4fd678 Binary files /dev/null and b/apps/web/public/images/combat/sprites/warrior-attack-512.png differ diff --git a/apps/web/public/images/hud/runtime/AttackIcon-96.png b/apps/web/public/images/hud/runtime/AttackIcon-96.png new file mode 100644 index 0000000..63b7b40 Binary files /dev/null and b/apps/web/public/images/hud/runtime/AttackIcon-96.png differ diff --git a/apps/web/public/images/hud/runtime/fight-action-frame-360.png b/apps/web/public/images/hud/runtime/fight-action-frame-360.png new file mode 100644 index 0000000..ca1a7cd Binary files /dev/null and b/apps/web/public/images/hud/runtime/fight-action-frame-360.png differ diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 58c1bc8..8a0c082 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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, ), }, ], diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index cd185f2..e7646fc 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -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[]; +} diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts index d05bf54..932aea3 100644 --- a/apps/web/src/app/core/api/game-api.service.spec.ts +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -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({}); + }); }); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index 8ae3195..f3167f1 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -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 { return this.http.post('/api/hunts', {}); } + + startCombat(encounterId: string): Observable { + return this.http.post(`/api/hunt-encounters/${encounterId}/attack`, {}); + } + + getCombat(combatId: string): Observable { + return this.http.get(`/api/combats/${combatId}`); + } + + performCombatAction(combatId: string, action: CombatAction): Observable { + return this.http.post(`/api/combats/${combatId}/actions`, { action }); + } } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html new file mode 100644 index 0000000..d6349a3 --- /dev/null +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -0,0 +1,119 @@ +
+ @if (combatStore.combat(); as combat) { +
+
+
+ +
+

{{ combat.player.name }}

+
+ + {{ combat.player.currentHp }} / {{ combat.player.maxHp }} +
+
+
+ +

Runde {{ combat.round }}

+ +
+
+

+ {{ combat.monster.name }} + Stufe {{ combat.monster.level }} +

+
+ + {{ combat.monster.currentHp }} / {{ combat.monster.maxHp }} +
+
+ +
+
+ +
+ + +
+ +
+ @if (combat.status === 'ACTIVE') { + + } +
+ + @if (combat.status === 'WON') { +
+

Sieg

+

{{ combat.monster.name }} wurde besiegt.

+

Belohnungen werden im nächsten Schritt verarbeitet.

+ +
+ } @else if (combat.status === 'LOST') { +
+

Niederlage

+

{{ combat.player.name }} wurde im Kampf besiegt.

+ +
+ } +
+ + + } @else if (combatStore.loading()) { +

Kampf wird geladen…

+ } + + @if (combatStore.error(); as error) { + + } +
diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss new file mode 100644 index 0000000..1401051 --- /dev/null +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -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); + } +} diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts new file mode 100644 index 0000000..cafe065 --- /dev/null +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -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>; + loading: ReturnType>; + actionPending: ReturnType>; + error: ReturnType>; + loadCombat: ReturnType; + attack: ReturnType; + }; + let router: Router; + + async function setup(combat: Combat | null) { + combatStore = { + combat: signal(combat), + loading: signal(false), + actionPending: signal(false), + error: signal(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('[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('[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('[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('[data-combat-retry]')?.click(); + + expect(combatStore.loadCombat).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts new file mode 100644 index 0000000..78e9194 --- /dev/null +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -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(); + 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); + } + } +} diff --git a/apps/web/src/app/features/combat/combat-placeholder-page.component.ts b/apps/web/src/app/features/combat/combat-placeholder-page.component.ts deleted file mode 100644 index bffeff5..0000000 --- a/apps/web/src/app/features/combat/combat-placeholder-page.component.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; - -@Component({ - selector: 'app-combat-placeholder-page', - template: ` -
- KAMPF -

Vorbereitung auf den Kampf

-

- 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. -

- @if (encounterId) { -

- Vorbereitung auf den Kampf gegen Begegnung {{ encounterId }}… -

- } -
- `, - 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'); -} diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts new file mode 100644 index 0000000..1e439a6 --- /dev/null +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -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; + getCombat: ReturnType; + performCombatAction: ReturnType; + }; + 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((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(); + }); +}); diff --git a/apps/web/src/app/features/combat/combat.store.ts b/apps/web/src/app/features/combat/combat.store.ts new file mode 100644 index 0000000..34b4578 --- /dev/null +++ b/apps/web/src/app/features/combat/combat.store.ts @@ -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> = { + 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(null); + private readonly loadingState = signal(false); + private readonly actionPendingState = signal(false); + private readonly errorState = signal(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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts index aefb280..0ac0e6e 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts @@ -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> = { - '/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); } } diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html index 77b1d87..5ec2587 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html @@ -64,4 +64,11 @@ } + + @if (combatStore.error(); as combatError) { + + } diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index 5452bc1..430ff18 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -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>; @@ -87,6 +95,12 @@ describe('HuntPageComponent', () => { refreshHunt: ReturnType; selectEncounter: ReturnType; }; + let combatStore: { + combat: ReturnType>; + error: ReturnType>; + startCombat: ReturnType; + clearError: ReturnType; + }; 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(null), + error: signal(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('[data-hunt-combat-dismiss]')?.click(); + + expect(combatStore.clearError).toHaveBeenCalledOnce(); }); it('does not trigger a hunt automatically on page entry', async () => { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts index 0f60dfd..476f7cc 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts @@ -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 { + 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(); } } diff --git a/apps/web/src/app/shared/monster-artwork.spec.ts b/apps/web/src/app/shared/monster-artwork.spec.ts new file mode 100644 index 0000000..eb90bbb --- /dev/null +++ b/apps/web/src/app/shared/monster-artwork.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/src/app/shared/monster-artwork.ts b/apps/web/src/app/shared/monster-artwork.ts new file mode 100644 index 0000000..60d69b4 --- /dev/null +++ b/apps/web/src/app/shared/monster-artwork.ts @@ -0,0 +1,26 @@ +const RUNTIME_MONSTER_ARTWORK: Readonly> = { + '/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> = { + 'ash-rat': '/images/combat/sprites/ash-rat-760.png', + 'road-bandit': '/images/combat/sprites/road-bandit-620.png', +}; + +const COMBAT_MONSTER_ICON: Readonly> = { + '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]; +} diff --git a/art/hud/fight-action-background.png b/art/hud/fight-action-background.png new file mode 100644 index 0000000..3762f25 Binary files /dev/null and b/art/hud/fight-action-background.png differ