import { z } from 'zod';
import { CombatEngine, CombatParticipant } from '../engine/combat/engine.js';
import { PubSub } from '../engine/pubsub.js';
import { getCombatManager } from './state/combat-manager.js';
import { getDb } from '../storage/index.js';
import { EncounterRepository } from '../storage/repos/encounter.repo.js';
import { SessionContext } from './types.js';
// Global combat state (in-memory for MVP)
let pubsub: PubSub | null = null;
export function setCombatPubSub(instance: PubSub) {
pubsub = instance;
}
// Tool definitions
export const CombatTools = {
CREATE_ENCOUNTER: {
name: 'create_encounter',
description: `Create a new combat encounter with the specified participants.
Example:
{
"seed": "battle-1",
"participants": [
{
"id": "hero-1",
"name": "Valeros",
"initiativeBonus": 2,
"hp": 20,
"maxHp": 20
},
{
"id": "goblin-1",
"name": "Goblin",
"initiativeBonus": 1,
"hp": 7,
"maxHp": 7
}
]
}`,
inputSchema: z.object({
seed: z.string().describe('Seed for deterministic combat resolution'),
participants: z.array(z.object({
id: z.string(),
name: z.string(),
initiativeBonus: z.number().int(),
hp: z.number().int().positive(),
maxHp: z.number().int().positive(),
conditions: z.array(z.any()).default([])
})).min(1)
})
},
GET_ENCOUNTER_STATE: {
name: 'get_encounter_state',
description: 'Get the current state of the active combat encounter.',
inputSchema: z.object({
encounterId: z.string().describe('The ID of the encounter')
})
},
EXECUTE_COMBAT_ACTION: {
name: 'execute_combat_action',
description: `Execute a combat action (attack, heal, etc.).
Examples:
{
"action": "attack",
"actorId": "hero-1",
"targetId": "goblin-1",
"attackBonus": 5,
"dc": 12,
"damage": 6
}
{
"action": "heal",
"actorId": "cleric-1",
"targetId": "hero-1",
"amount": 8
}`,
inputSchema: z.object({
encounterId: z.string().describe('The ID of the encounter'),
action: z.enum(['attack', 'heal']),
actorId: z.string(),
targetId: z.string(),
attackBonus: z.number().int().optional(),
dc: z.number().int().optional(),
damage: z.number().int().optional(),
amount: z.number().int().optional()
})
},
ADVANCE_TURN: {
name: 'advance_turn',
description: 'Advance to the next combatant\'s turn.',
inputSchema: z.object({
encounterId: z.string().describe('The ID of the encounter')
})
},
END_ENCOUNTER: {
name: 'end_encounter',
description: 'End the current combat encounter.',
inputSchema: z.object({
encounterId: z.string().describe('The ID of the encounter')
})
},
LOAD_ENCOUNTER: {
name: 'load_encounter',
description: 'Load a combat encounter from the database.',
inputSchema: z.object({
encounterId: z.string().describe('The ID of the encounter to load')
})
}
} as const;
// Tool handlers
export async function handleCreateEncounter(args: unknown, ctx: SessionContext) {
// No need to check for existing encounter globally anymore
const parsed = CombatTools.CREATE_ENCOUNTER.inputSchema.parse(args);
// Create combat engine
const engine = new CombatEngine(parsed.seed, pubsub || undefined);
// Convert participants to proper format
const participants: CombatParticipant[] = parsed.participants.map(p => ({
...p,
conditions: []
}));
// Start encounter
const state = engine.startEncounter(participants);
// Generate encounter ID
const encounterId = `encounter-${parsed.seed}-${Date.now()}`;
// Store with session namespace
getCombatManager().create(`${ctx.sessionId}:${encounterId}`, engine);
// Persist initial state
const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db');
const repo = new EncounterRepository(db);
// Create the encounter record first
repo.create({
id: encounterId,
// regionId is optional
tokens: state.participants.map(p => ({
id: p.id,
name: p.name,
initiativeBonus: p.initiativeBonus,
hp: p.hp,
maxHp: p.maxHp,
conditions: p.conditions,
abilityScores: p.abilityScores
})),
round: state.round,
activeTokenId: state.turnOrder[state.currentTurnIndex],
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
const currentParticipant = engine.getCurrentParticipant();
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
encounterId,
message: 'Combat encounter started',
turnOrder: state.turnOrder,
round: state.round,
currentTurn: currentParticipant?.name || null
}, null, 2)
}
]
};
}
export async function handleGetEncounterState(args: unknown, ctx: SessionContext) {
const parsed = CombatTools.GET_ENCOUNTER_STATE.inputSchema.parse(args);
let engine = getCombatManager().get(`${ctx.sessionId}:${parsed.encounterId}`);
// Auto-load from database if not in memory
if (!engine) {
const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db');
const repo = new EncounterRepository(db);
const state = repo.loadState(parsed.encounterId);
if (!state) {
throw new Error(`Encounter ${parsed.encounterId} not found.`);
}
// Create engine and load state
engine = new CombatEngine(parsed.encounterId, pubsub || undefined);
engine.loadState(state);
getCombatManager().create(`${ctx.sessionId}:${parsed.encounterId}`, engine);
}
const state = engine.getState();
if (!state) {
throw new Error('No active encounter');
}
const currentParticipant = engine.getCurrentParticipant();
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
encounterId: parsed.encounterId,
round: state.round,
currentTurn: {
participantId: currentParticipant?.id,
participantName: currentParticipant?.name
},
participants: state.participants.map(p => ({
id: p.id,
name: p.name,
hp: p.hp,
maxHp: p.maxHp,
conditions: p.conditions
})),
turnOrder: state.turnOrder
}, null, 2)
}
]
};
}
export async function handleExecuteCombatAction(args: unknown, ctx: SessionContext) {
const parsed = CombatTools.EXECUTE_COMBAT_ACTION.inputSchema.parse(args);
let engine = getCombatManager().get(`${ctx.sessionId}:${parsed.encounterId}`);
// Auto-load from database if not in memory
if (!engine) {
const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db');
const repo = new EncounterRepository(db);
const state = repo.loadState(parsed.encounterId);
if (!state) {
throw new Error(`Encounter ${parsed.encounterId} not found.`);
}
engine = new CombatEngine(parsed.encounterId, pubsub || undefined);
engine.loadState(state);
getCombatManager().create(`${ctx.sessionId}:${parsed.encounterId}`, engine);
}
let result: any = {
action: parsed.action,
actorId: parsed.actorId,
targetId: parsed.targetId
};
if (parsed.action === 'attack') {
if (parsed.attackBonus === undefined || parsed.dc === undefined) {
throw new Error('Attack action requires attackBonus and dc');
}
// Make attack check
const degree = engine.makeCheck(parsed.attackBonus, parsed.dc);
const success = degree === 'success' || degree === 'critical-success';
result.success = success;
result.degree = degree;
if (success && parsed.damage) {
engine.applyDamage(parsed.targetId, parsed.damage);
result.damageDealt = parsed.damage;
} else {
result.damageDealt = 0;
}
} else if (parsed.action === 'heal') {
if (parsed.amount === undefined) {
throw new Error('Heal action requires amount');
}
engine.heal(parsed.targetId, parsed.amount);
result.amountHealed = parsed.amount;
}
// Save state
const state = engine.getState();
if (state) {
const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db');
const repo = new EncounterRepository(db);
repo.saveState(parsed.encounterId, state);
}
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result, null, 2)
}
]
};
}
export async function handleAdvanceTurn(args: unknown, ctx: SessionContext) {
const parsed = CombatTools.ADVANCE_TURN.inputSchema.parse(args);
let engine = getCombatManager().get(`${ctx.sessionId}:${parsed.encounterId}`);
// Auto-load from database if not in memory
if (!engine) {
const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db');
const repo = new EncounterRepository(db);
const state = repo.loadState(parsed.encounterId);
if (!state) {
throw new Error(`Encounter ${parsed.encounterId} not found.`);
}
engine = new CombatEngine(parsed.encounterId, pubsub || undefined);
engine.loadState(state);
getCombatManager().create(`${ctx.sessionId}:${parsed.encounterId}`, engine);
}
const previousParticipant = engine.getCurrentParticipant();
const newParticipant = engine.nextTurnWithConditions();
const state = engine.getState();
// Save state
if (state) {
const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db');
const repo = new EncounterRepository(db);
repo.saveState(parsed.encounterId, state);
}
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
previousTurn: previousParticipant?.name || null,
currentTurn: newParticipant?.name || null,
round: state?.round || 0
}, null, 2)
}
]
};
}
export async function handleEndEncounter(args: unknown, ctx: SessionContext) {
const parsed = CombatTools.END_ENCOUNTER.inputSchema.parse(args);
const success = getCombatManager().delete(`${ctx.sessionId}:${parsed.encounterId}`);
if (!success) {
throw new Error(`Encounter ${parsed.encounterId} not found.`);
}
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
message: 'Encounter ended',
encounterId: parsed.encounterId
}, null, 2)
}
]
};
}
export async function handleLoadEncounter(args: unknown, ctx: SessionContext) {
const parsed = CombatTools.LOAD_ENCOUNTER.inputSchema.parse(args);
const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db');
const repo = new EncounterRepository(db);
const state = repo.loadState(parsed.encounterId);
if (!state) {
throw new Error(`Encounter ${parsed.encounterId} not found in database.`);
}
// Create engine and load state
const engine = new CombatEngine(parsed.encounterId, pubsub || undefined);
engine.loadState(state);
getCombatManager().create(`${ctx.sessionId}:${parsed.encounterId}`, engine);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
message: 'Encounter loaded',
encounterId: parsed.encounterId,
round: state.round
}, null, 2)
}]
};
}
// Helper for tests
export function clearCombatState() {
// No-op or clear manager
}