From b56482fdfee2024d98f751f5ede7ba5a065a95b6 Mon Sep 17 00:00:00 2001
From: Mnehmos
Date: Fri, 17 Apr 2026 23:06:26 -0700
Subject: [PATCH] fix(combat): honor participant `side` field as alias for
isEnemy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The combat_manage create schema only accepted isEnemy:boolean. Callers
following the consolidated tool's runtime hint sent side:"enemy" instead,
which Zod silently dropped. Enemies came back with isEnemy=undefined →
false, so they rendered with the PC glyph and the engine prompted
"PLAYER TURN" on enemy turns.
Adds a `side` enum (party/enemy/hostile/ally/friendly/neutral) on the
participant schema and a deriveIsEnemy helper. Convention:
- explicit isEnemy wins
- otherwise side="enemy"|"hostile" → isEnemy=true
- otherwise → false
The transform happens at the consolidated-tool boundary; the underlying
handleCreateEncounter contract is unchanged.
Closes #46
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/server/consolidated/combat-manage.ts | 26 +++++++++++-
.../server/consolidated/combat-manage.test.ts | 40 +++++++++++++++++++
2 files changed, 64 insertions(+), 2 deletions(-)
diff --git a/src/server/consolidated/combat-manage.ts b/src/server/consolidated/combat-manage.ts
index ff4932f..56f1618 100644
--- a/src/server/consolidated/combat-manage.ts
+++ b/src/server/consolidated/combat-manage.ts
@@ -41,6 +41,12 @@ const ParticipantSchema = z.object({
hp: z.number().int().nonnegative(), // Allow 0 HP for dying characters
maxHp: z.number().int().positive(),
isEnemy: z.boolean().optional(),
+ /**
+ * Convenience alias for `isEnemy`. Values "enemy" / "hostile" map to
+ * isEnemy=true; "party" / "ally" / "friendly" / "neutral" map to false.
+ * If both `side` and `isEnemy` are provided, `isEnemy` wins.
+ */
+ side: z.enum(['party', 'enemy', 'hostile', 'ally', 'friendly', 'neutral']).optional(),
conditions: z.array(z.string()).default([]),
position: z.object({
x: z.number(),
@@ -52,6 +58,16 @@ const ParticipantSchema = z.object({
immunities: z.array(z.string()).optional()
});
+/**
+ * Coerce a participant's `side` into an `isEnemy` boolean.
+ * Explicit `isEnemy` wins; otherwise derived from `side`.
+ */
+function deriveIsEnemy(p: { isEnemy?: boolean; side?: string }): boolean | undefined {
+ if (typeof p.isEnemy === 'boolean') return p.isEnemy;
+ if (!p.side) return undefined;
+ return p.side === 'enemy' || p.side === 'hostile';
+}
+
const TerrainSchema = z.object({
obstacles: z.array(z.string()).default([]),
difficultTerrain: z.array(z.string()).optional(),
@@ -136,10 +152,16 @@ const definitions: Record = {
schema: CreateSchema,
handler: async (params: z.infer) => {
if (!currentContext) throw new Error('No session context');
- // Transform params to original format
+ // Map convenience `side` field down to canonical `isEnemy` and drop `side`
+ // before forwarding to handleCreateEncounter (which doesn't accept it).
+ const normalizedParticipants = params.participants.map((p) => {
+ const { side: _side, ...rest } = p;
+ const derived = deriveIsEnemy(p);
+ return derived === undefined ? rest : { ...rest, isEnemy: derived };
+ });
const originalParams = {
seed: params.seed,
- participants: params.participants,
+ participants: normalizedParticipants,
terrain: params.terrain
};
const result = await handleCreateEncounter(originalParams, currentContext);
diff --git a/tests/server/consolidated/combat-manage.test.ts b/tests/server/consolidated/combat-manage.test.ts
index 82d3774..62b4a73 100644
--- a/tests/server/consolidated/combat-manage.test.ts
+++ b/tests/server/consolidated/combat-manage.test.ts
@@ -114,6 +114,46 @@ describe('combat_manage consolidated tool', () => {
expect(data.success).toBe(true);
});
+ // Regression for issue #46: side="enemy" was silently dropped, leaving
+ // isEnemy=undefined → false. Enemies showed as PCs in the turn prompt.
+ it('honors participant `side` as alias for isEnemy', async () => {
+ const result = await handleCombatManage({
+ action: 'create',
+ seed: 'side-alias-test',
+ participants: [
+ { id: 'pc-vela', name: 'Vela', initiativeBonus: 0, hp: 38, maxHp: 38, side: 'party', position: { x: 1, y: 1 } },
+ { id: 'pc-tobin', name: 'Tobin', initiativeBonus: 4, hp: 28, maxHp: 28, side: 'ally', position: { x: 1, y: 2 } },
+ { id: 'enemy-rurk', name: 'Rurk', initiativeBonus: 1, hp: 22, maxHp: 22, side: 'enemy', position: { x: 5, y: 5 } },
+ { id: 'enemy-mira', name: 'Mira', initiativeBonus: 2, hp: 16, maxHp: 16, side: 'hostile', position: { x: 6, y: 5 } }
+ ]
+ }, ctx);
+
+ const data = parseResult(result);
+ expect(data.success).toBe(true);
+
+ const byId = Object.fromEntries(
+ (data.participants as Array<{ id: string; isEnemy: boolean }>).map((p) => [p.id, p.isEnemy])
+ );
+ expect(byId['pc-vela']).toBe(false);
+ expect(byId['pc-tobin']).toBe(false);
+ expect(byId['enemy-rurk']).toBe(true);
+ expect(byId['enemy-mira']).toBe(true);
+ });
+
+ it('explicit isEnemy wins over side when both are supplied', async () => {
+ const result = await handleCombatManage({
+ action: 'create',
+ seed: 'side-conflict-test',
+ participants: [
+ { id: 'overridden', name: 'Override', initiativeBonus: 0, hp: 10, maxHp: 10, side: 'enemy', isEnemy: false }
+ ]
+ }, ctx);
+
+ const data = parseResult(result);
+ const p = (data.participants as Array<{ id: string; isEnemy: boolean }>).find((x) => x.id === 'overridden');
+ expect(p?.isEnemy).toBe(false);
+ });
+
it('should accept "start" alias', async () => {
const result = await handleCombatManage({
action: 'start',