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
26 changes: 24 additions & 2 deletions src/server/consolidated/combat-manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -136,10 +152,16 @@ const definitions: Record<CombatManageAction, ActionDefinition> = {
schema: CreateSchema,
handler: async (params: z.infer<typeof CreateSchema>) => {
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);
Expand Down
40 changes: 40 additions & 0 deletions tests/server/consolidated/combat-manage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading