From 62425eadd7f2f0dcca0b12095292e964f4938d24 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Mon, 23 Feb 2026 18:48:52 -0500 Subject: [PATCH] Update combat orchestration for multiplayer turns startTurn resets energy/block for all players and draws 5 cards each, also resets playersReady. resolveEnemyTurn iterates all enemies, resets each enemy's block, and has each enemy act against player 0. checkCombatEnd checks all enemies (victory) and any player (defeat). --- src/combat.js | 81 ++++++++++++++++++++++++++++---------------- src/combat.test.js | 84 ++++++++++++++++++++++++++++++++++++++++++++-- src/state.js | 2 ++ src/state.test.js | 8 +++++ 4 files changed, 145 insertions(+), 30 deletions(-) diff --git a/src/combat.js b/src/combat.js index 26c1aa2..9ddd45d 100644 --- a/src/combat.js +++ b/src/combat.js @@ -5,51 +5,76 @@ import { drawCards } from "./state.js"; export function startTurn(state) { const dieResult = rollDie(); + + // reset energy and block for all players + const players = state.players.map((p) => ({ + ...p, + energy: p.maxEnergy, + block: 0, + })); + let next = { ...state, - player: { - ...state.player, - energy: state.player.maxEnergy, - block: 0, - }, + players, + // keep compat alias in sync + player: players[0], combat: { ...state.combat, phase: "player_turn", dieResult, selectedCard: null, + playersReady: [], }, }; - next = drawCards(next, 5); + + // draw 5 cards for each player + for (let i = 0; i < next.players.length; i++) { + next = drawCards(next, i, 5); + } + return next; } export function resolveEnemyTurn(state) { - let next = { - ...state, - enemy: { ...state.enemy, block: 0 }, - }; + // reset block on all enemies before they act + const enemies = state.enemies.map((e) => ({ ...e, block: 0 })); + let next = { ...state, enemies, enemy: enemies[0] }; - const action = resolveEnemyAction( - next.enemy, - next.combat.dieResult, - next.enemy.trackPosition, - ); - - if (action?.effects) { - next = resolveEffects(next, action.effects, "enemy", "player"); - } - - let trackPosition = next.enemy.trackPosition; - if (next.enemy.actionType === "cube") { - trackPosition = Math.min( - trackPosition + 1, - (next.enemy.actionTrack || []).length - 1, + // each enemy acts; enemies target player 0 by default + for (let i = 0; i < next.enemies.length; i++) { + const enemy = next.enemies[i]; + const action = resolveEnemyAction( + enemy, + next.combat.dieResult, + enemy.trackPosition, ); + + if (action?.effects) { + // TODO: implement enemy targeting logic for multiplayer (random, lowest HP, etc.) + next = resolveEffects( + next, + action.effects, + { type: "enemy", index: i }, + { type: "player", index: 0 }, + ); + } + + // advance cube track for this enemy if applicable + if (enemy.actionType === "cube") { + const trackPosition = Math.min( + enemy.trackPosition + 1, + (enemy.actionTrack || []).length - 1, + ); + const updatedEnemy = { ...next.enemies[i], trackPosition }; + const updatedEnemies = next.enemies.map((e, j) => + j === i ? updatedEnemy : e, + ); + next = { ...next, enemies: updatedEnemies, enemy: updatedEnemies[0] }; + } } return { ...next, - enemy: { ...next.enemy, trackPosition }, combat: { ...next.combat, phase: "player_turn", @@ -59,7 +84,7 @@ export function resolveEnemyTurn(state) { } export function checkCombatEnd(state) { - if (state.enemy.hp <= 0) return "victory"; - if (state.player.hp <= 0) return "defeat"; + if (state.enemies.every((e) => e.hp <= 0)) return "victory"; + if (state.players.some((p) => p.hp <= 0)) return "defeat"; return null; } diff --git a/src/combat.test.js b/src/combat.test.js index ab5a027..c887572 100644 --- a/src/combat.test.js +++ b/src/combat.test.js @@ -49,13 +49,15 @@ describe("resolveEnemyTurn", () => { describe("checkCombatEnd", () => { test("returns 'victory' when enemy hp is 0", () => { let state = createCombatState("ironclad", "jaw_worm"); - state = { ...state, enemy: { ...state.enemy, hp: 0 } }; + const deadEnemy = { ...state.enemies[0], hp: 0 }; + state = { ...state, enemy: deadEnemy, enemies: [deadEnemy] }; expect(checkCombatEnd(state)).toBe("victory"); }); test("returns 'defeat' when player hp is 0", () => { let state = createCombatState("ironclad", "jaw_worm"); - state = { ...state, player: { ...state.player, hp: 0 } }; + const deadPlayer = { ...state.players[0], hp: 0 }; + state = { ...state, player: deadPlayer, players: [deadPlayer] }; expect(checkCombatEnd(state)).toBe("defeat"); }); @@ -64,3 +66,81 @@ describe("checkCombatEnd", () => { expect(checkCombatEnd(state)).toBeNull(); }); }); + +describe("startTurn - multiplayer", () => { + test("resets energy and block for all players, draws 5 each", () => { + let state = createCombatState(["ironclad", "ironclad"], ["jaw_worm"]); + state = { + ...state, + players: state.players.map((p) => ({ ...p, energy: 0, block: 5 })), + }; + const next = startTurn(state); + expect(next.players[0].energy).toBe(3); + expect(next.players[0].block).toBe(0); + expect(next.players[0].hand).toHaveLength(5); + expect(next.players[1].energy).toBe(3); + expect(next.players[1].block).toBe(0); + expect(next.players[1].hand).toHaveLength(5); + }); + + test("playersReady resets on startTurn", () => { + let state = createCombatState(["ironclad", "ironclad"], ["jaw_worm"]); + state = { + ...state, + combat: { ...state.combat, playersReady: [0, 1] }, + }; + const next = startTurn(state); + expect(next.combat.playersReady).toEqual([]); + }); +}); + +describe("resolveEnemyTurn - multiplayer", () => { + test("all enemies act, block resets for each", () => { + let state = createCombatState(["ironclad"], ["jaw_worm", "jaw_worm"]); + state = { + ...state, + enemies: state.enemies.map((e) => ({ ...e, block: 3 })), + combat: { ...state.combat, dieResult: 1 }, + }; + const next = resolveEnemyTurn(state); + // die result 1 is defend (3 hit + 1 block), so block resets then gains 1 + expect(next.enemies[0].block).toBe(1); + expect(next.enemies[1].block).toBe(1); + expect(next.combat.turn).toBe(state.combat.turn + 1); + }); +}); + +describe("checkCombatEnd - multiplayer", () => { + test("victory when all enemies at 0 hp", () => { + let state = createCombatState(["ironclad"], ["jaw_worm", "jaw_worm"]); + state = { + ...state, + enemies: state.enemies.map((e) => ({ ...e, hp: 0 })), + }; + expect(checkCombatEnd(state)).toBe("victory"); + }); + + test("no victory if only one enemy at 0 hp", () => { + let state = createCombatState(["ironclad"], ["jaw_worm", "jaw_worm"]); + state = { + ...state, + enemies: [ + { ...state.enemies[0], hp: 0 }, + { ...state.enemies[1], hp: 5 }, + ], + }; + expect(checkCombatEnd(state)).toBeNull(); + }); + + test("defeat when any player at 0 hp", () => { + let state = createCombatState(["ironclad", "ironclad"], ["jaw_worm"]); + state = { + ...state, + players: [ + { ...state.players[0], hp: 0 }, + { ...state.players[1], hp: 11 }, + ], + }; + expect(checkCombatEnd(state)).toBe("defeat"); + }); +}); diff --git a/src/state.js b/src/state.js index 0f728ba..e194fe9 100644 --- a/src/state.js +++ b/src/state.js @@ -185,6 +185,8 @@ export function endTurn(state, playerIndex) { }; } + if (state.combat.playersReady.includes(playerIndex)) return state; + const player = state.players[playerIndex]; const updatedPlayer = { ...player, diff --git a/src/state.test.js b/src/state.test.js index 572cbc4..8bdb8a7 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -217,4 +217,12 @@ describe("endTurn - indexed player", () => { expect(next.combat.phase).toBe("enemy_turn"); expect(next.combat.playersReady).toHaveLength(2); }); + + test("calling endTurn twice for the same player does not add duplicate to playersReady", () => { + let state = createCombatState(["ironclad", "ironclad"], ["jaw_worm"]); + state = endTurn(state, 0); + const next = endTurn(state, 0); + expect(next.combat.playersReady).toHaveLength(1); + expect(next.combat.phase).toBe("player_turn"); + }); });