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',