From 4831dc20b0c37b08532c2a868fa5e9ef7afaeeea Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 19 Aug 2026 16:09:08 +0200 Subject: [PATCH] feat(combat): add HTTP controllers for starting, reading, and acting on combats Co-Authored-By: Claude Sonnet 5 --- apps/api/src/combat/combat.controller.spec.ts | 72 +++++++++++++++++++ apps/api/src/combat/combat.controller.ts | 19 +++++ apps/api/src/combat/dto/combat-action.dto.ts | 7 ++ .../hunt-encounter-attack.controller.spec.ts | 41 +++++++++++ .../hunt-encounter-attack.controller.ts | 13 ++++ 5 files changed, 152 insertions(+) create mode 100644 apps/api/src/combat/combat.controller.spec.ts create mode 100644 apps/api/src/combat/combat.controller.ts create mode 100644 apps/api/src/combat/dto/combat-action.dto.ts create mode 100644 apps/api/src/combat/hunt-encounter-attack.controller.spec.ts create mode 100644 apps/api/src/combat/hunt-encounter-attack.controller.ts 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/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/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); + } +}