feat(combat): rejoin the running combat instead of dead-ending
Attacking while a combat is already active returned COMBAT_ALREADY_ACTIVE and left the hunt page showing an error the player could not act on, with no way back into the fight they were already in. Add GET /api/combats/active so the client can resolve that combat, and have the hunt page navigate into it when an attack is rejected for this reason. CombatStore now also exposes the error code so callers can tell this case apart from a genuinely failed attack.
This commit is contained in:
@@ -10,14 +10,18 @@ import { CombatService } from './combat.service';
|
||||
describe('CombatController', () => {
|
||||
let app: INestApplication<App>;
|
||||
const getCombat = jest.fn();
|
||||
const getActiveCombat = jest.fn();
|
||||
const performAction = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
getCombat.mockReset();
|
||||
getActiveCombat.mockReset();
|
||||
performAction.mockReset();
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [CombatController],
|
||||
providers: [{ provide: CombatService, useValue: { getCombat, performAction } }],
|
||||
providers: [
|
||||
{ provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication<App>();
|
||||
@@ -39,6 +43,27 @@ describe('CombatController', () => {
|
||||
expect(response.body).toEqual(combat);
|
||||
});
|
||||
|
||||
it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => {
|
||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [] };
|
||||
getActiveCombat.mockResolvedValue(combat);
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
|
||||
|
||||
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||
expect(getCombat).not.toHaveBeenCalled();
|
||||
expect(response.body).toEqual(combat);
|
||||
});
|
||||
|
||||
it('returns an empty body from GET /api/combats/active when no combat is running', async () => {
|
||||
getActiveCombat.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200);
|
||||
|
||||
expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||
expect(response.body).toEqual({});
|
||||
expect(getCombat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates POST /api/combats/:combatId/actions with only the action field', async () => {
|
||||
const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] };
|
||||
performAction.mockResolvedValue(combat);
|
||||
|
||||
@@ -7,6 +7,12 @@ import { CombatService } from './combat.service';
|
||||
export class CombatController {
|
||||
constructor(private readonly combatService: CombatService) {}
|
||||
|
||||
// Declared before ':combatId' so the literal segment wins the route match.
|
||||
@Get('active')
|
||||
getActiveCombat() {
|
||||
return this.combatService.getActiveCombat(DEMO_CHARACTER_ID);
|
||||
}
|
||||
|
||||
@Get(':combatId')
|
||||
getCombat(@Param('combatId') combatId: string) {
|
||||
return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId);
|
||||
|
||||
@@ -587,6 +587,58 @@ describe('CombatService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the character ACTIVE combat so the hunt page can rejoin it', async () => {
|
||||
const context = createService();
|
||||
const started = await context.service.startCombat(
|
||||
CHARACTER_ID,
|
||||
ENCOUNTER_ID,
|
||||
);
|
||||
await context.service.performAction(
|
||||
CHARACTER_ID,
|
||||
started.id,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
const active = await context.service.getActiveCombat(CHARACTER_ID);
|
||||
|
||||
expect(active?.id).toBe(started.id);
|
||||
expect(active?.status).toBe('ACTIVE');
|
||||
expect(active?.round).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves null when the character has no ACTIVE combat', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.getActiveCombat(CHARACTER_ID)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves null once the only combat has finished', async () => {
|
||||
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||
const context = createService({ state });
|
||||
const started = await context.service.startCombat(
|
||||
CHARACTER_ID,
|
||||
ENCOUNTER_ID,
|
||||
);
|
||||
await context.service.performAction(
|
||||
CHARACTER_ID,
|
||||
started.id,
|
||||
CombatAction.ATTACK,
|
||||
);
|
||||
|
||||
await expect(
|
||||
context.service.getActiveCombat(CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('does not resolve another character ACTIVE combat', async () => {
|
||||
const context = createService();
|
||||
await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||
|
||||
await expect(
|
||||
context.service.getActiveCombat(OTHER_CHARACTER_ID),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps returning LOST after the combat has ended', async () => {
|
||||
const state = createState({
|
||||
characters: [character({ baseHp: 1 })],
|
||||
|
||||
@@ -168,6 +168,24 @@ export class CombatService {
|
||||
return this.toCombatDto(combat, character.name, monster, events);
|
||||
}
|
||||
|
||||
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
|
||||
const combats = this.dataSource.getRepository(Combat);
|
||||
const combat = await combats.findOne({
|
||||
where: { characterId, status: CombatStatus.ACTIVE },
|
||||
});
|
||||
if (!combat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [character, monster, events] = await Promise.all([
|
||||
this.loadCharacter(combat.characterId),
|
||||
this.loadMonster(combat.monsterDefinitionId),
|
||||
this.loadEvents(combat.id),
|
||||
]);
|
||||
|
||||
return this.toCombatDto(combat, character.name, monster, events);
|
||||
}
|
||||
|
||||
async performAction(
|
||||
characterId: string,
|
||||
combatId: string,
|
||||
|
||||
Reference in New Issue
Block a user