Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/engine/combat/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,35 @@ export class CombatEngine {
participant.movementRemaining = participant.movementSpeed ?? 30;
}

/**
* Apply the Dash action for a participant. Adds a fresh movement
* allotment on top of whatever they already have left, and flags
* hasDashed so the budget-check in move actions sees the doubled
* window. Consumes the main action.
*/
applyDash(participantId: string): { ok: true; movementRemaining: number } | { ok: false; error: string } {
if (!this.state) return { ok: false, error: 'No active combat' };
const participant = this.state.participants.find((p) => p.id === participantId);
if (!participant) return { ok: false, error: `Participant ${participantId} not found` };
if (participant.hasDashed) return { ok: false, error: 'Already dashed this turn' };

// Dash IS the action. Reject if the participant already burned their
// main action this turn (5e action economy). Without this guard, a
// caller could attack and then dash for a free 60ft of movement.
const econ = this.validateActionEconomy(participantId, 'action');
if (!econ.valid) {
return { ok: false, error: econ.error || 'Action already used this turn' };
}

const baseSpeed = participant.movementSpeed ?? 30;
const currentRemaining = participant.movementRemaining ?? baseSpeed;
participant.movementRemaining = currentRemaining + baseSpeed;
Comment on lines +1008 to +1020

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate action economy before applying Dash

applyDash only checks hasDashed and then always adds movement, so a participant who already spent their Action can still dash and gain extra movement in the same turn. This is reachable because attack/heal/disengage consume actions via commitAction, but this path never calls validateActionEconomy(..., 'action'); as a result, dash can be used as an illegal second main action (and by incapacitated actors) and produces incorrect combat state.

Useful? React with 👍 / 👎.

participant.hasDashed = true;
participant.actionUsed = true;

return { ok: true, movementRemaining: participant.movementRemaining };
}

/**
* Process start-of-turn condition effects
*/
Expand Down
62 changes: 59 additions & 3 deletions src/server/consolidated/combat-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { createActionRouter, ActionDefinition, McpResponse } from '../../utils/a
import { SessionContext } from '../types.js';
import { RichFormatter } from '../utils/formatter.js';
import { handleExecuteCombatAction } from '../handlers/combat-handlers.js';
import { getCombatManager } from '../state/combat-manager.js';
import { getDb } from '../../storage/index.js';
import { EncounterRepository } from '../../storage/repos/encounter.repo.js';
import { CombatEngine } from '../../engine/combat/engine.js';

// ═══════════════════════════════════════════════════════════════════════════
// CONSTANTS
Expand Down Expand Up @@ -188,13 +192,65 @@ const definitions: Record<CombatAction, ActionDefinition> = {
schema: DashSchema,
handler: async (params: z.infer<typeof DashSchema>) => {
if (!currentContext) throw new Error('No session context');
// Dash doubles movement speed for the turn

const sessionKey = `${currentContext.sessionId}:${params.encounterId}`;
let engine = getCombatManager().get(sessionKey);

// Auto-load from DB if the engine isn't in memory (matches the
// pattern in handleExecuteCombatAction). Without this, dash
// returned "not found" after a process restart even when the
// encounter still existed and other actions worked.
//
// Race-safe restore (PR #60 reviewer ask): two concurrent
// requests can both find the engine missing and both load from
// DB. CombatManager.create throws if the key already exists, so
// wrap the create in a try/get fallback — the loser of the race
// adopts the winner's engine.
if (!engine) {
const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db');
const repo = new EncounterRepository(db);
const persisted = repo.loadState(params.encounterId);
if (persisted) {
// Re-check in case another concurrent request restored it
// between our initial get() and now.
engine = getCombatManager().get(sessionKey);
if (!engine) {
const candidate = new CombatEngine(params.encounterId);
candidate.loadState(persisted);
try {
getCombatManager().create(sessionKey, candidate);
engine = candidate;
} catch {
// Lost the race — adopt the engine the winner created.
engine = getCombatManager().get(sessionKey);
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!engine) {
return {
error: true,
actionType: 'dash',
message: `Encounter ${params.encounterId} not found.`
};
}
const result = engine.applyDash(params.actorId);
if (!result.ok) {
return {
error: true,
actionType: 'dash',
actorId: params.actorId,
message: result.error
};
}
return {
success: true,
actionType: 'dash',
actorId: params.actorId,
effect: 'Movement speed doubled for this turn',
message: `${params.actorId} takes the Dash action, doubling movement speed.`
movementRemaining: result.movementRemaining,
effect: `Movement speed doubled for this turn (budget now ${result.movementRemaining}ft)`,
message: `${params.actorId} takes the Dash action. Movement doubled; ${result.movementRemaining}ft remaining.`
};
},
aliases: ['sprint', 'run', 'hustle']
Expand Down
95 changes: 95 additions & 0 deletions tests/server/consolidated/combat-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,101 @@ describe('combat_action consolidated tool', () => {
// Alias resolves to dash
expect(data.actionType).toBe('dash');
});

// Reviewer follow-ups on PR #60:
// - dash must consume the action economy slot (else attack-then-dash).
// - dash must auto-load the engine from DB (matches other actions).
it('dash refuses when the actor already used their main action this turn', async () => {
// hero-1 attacks (consumes action) then tries to dash.
await handleCombatAction({
action: 'attack',
encounterId: testEncounterId,
actorId: 'hero-1',
targetId: 'goblin-1',
attackBonus: 5,
damage: 4
}, ctx);

const dashResult = await handleCombatAction({
action: 'dash',
encounterId: testEncounterId,
actorId: 'hero-1'
}, ctx);
const dashData = parseResult(dashResult);
expect(dashData.error).toBe(true);
expect(dashData.message).toMatch(/already used|action/i);
});

// Reviewer follow-up on PR #60 (Minor): two concurrent dash calls
// hitting the auto-load path could both try to register the same
// engine key, throwing "Encounter X already exists" on the loser.
it('dash survives two concurrent auto-load requests without throwing', async () => {
const { getCombatManager } = await import('../../../src/server/state/combat-manager.js');
getCombatManager().clear();

const [a, b] = await Promise.all([
handleCombatAction({
action: 'dash',
encounterId: testEncounterId,
actorId: 'hero-1'
}, ctx),
handleCombatAction({
action: 'dash',
encounterId: testEncounterId,
actorId: 'hero-1'
}, ctx)
]);

const da = parseResult(a);
const db = parseResult(b);
// One must succeed (the first) and the other must reject due to
// hasDashed/economy — but neither may throw "already exists".
expect([da, db].some((d) => d.success === true)).toBe(true);
for (const d of [da, db]) {
if (d.error) {
expect(String(d.message ?? '')).not.toMatch(/already exists/i);
}
}
});

it('dash auto-loads the encounter from DB when engine is evicted', async () => {
const { getCombatManager } = await import('../../../src/server/state/combat-manager.js');
getCombatManager().clear();

const dashResult = await handleCombatAction({
action: 'dash',
encounterId: testEncounterId,
actorId: 'hero-1'
}, ctx);
const dashData = parseResult(dashResult);
expect(dashData.success).toBe(true);
expect(dashData.movementRemaining).toBe(60);
});

// Regression for issue #50: dash was a stub that returned a success
// message without actually extending movementRemaining, so the next
// move call still enforced the base 30ft budget.
it('dash actually doubles the enforced move budget', async () => {
const dashResult = await handleCombatAction({
action: 'dash',
encounterId: testEncounterId,
actorId: 'hero-1'
}, ctx);
const dashData = parseResult(dashResult);
expect(dashData.success).toBe(true);
expect(dashData.movementRemaining).toBe(60);

// Move 9 tiles diagonally (~45ft at 5ft/sq). Without dash this
// would exceed the 30ft budget; with dash (60ft) it fits.
const moveResult = await handleCombatAction({
action: 'move',
encounterId: testEncounterId,
actorId: 'hero-1',
targetPosition: { x: 14, y: 14 }
}, ctx);
const moveData = parseResult(moveResult);
expect(moveData.success).toBe(true);
});
});

describe('dodge action', () => {
Expand Down
Loading