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).
This commit is contained in:
Jared Miller 2026-02-23 18:48:52 -05:00
parent e03b9b2dd7
commit 62425eadd7
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 145 additions and 30 deletions

View file

@ -5,51 +5,76 @@ import { drawCards } from "./state.js";
export function startTurn(state) { export function startTurn(state) {
const dieResult = rollDie(); const dieResult = rollDie();
// reset energy and block for all players
const players = state.players.map((p) => ({
...p,
energy: p.maxEnergy,
block: 0,
}));
let next = { let next = {
...state, ...state,
player: { players,
...state.player, // keep compat alias in sync
energy: state.player.maxEnergy, player: players[0],
block: 0,
},
combat: { combat: {
...state.combat, ...state.combat,
phase: "player_turn", phase: "player_turn",
dieResult, dieResult,
selectedCard: null, 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; return next;
} }
export function resolveEnemyTurn(state) { export function resolveEnemyTurn(state) {
let next = { // reset block on all enemies before they act
...state, const enemies = state.enemies.map((e) => ({ ...e, block: 0 }));
enemy: { ...state.enemy, block: 0 }, let next = { ...state, enemies, enemy: enemies[0] };
};
// 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( const action = resolveEnemyAction(
next.enemy, enemy,
next.combat.dieResult, next.combat.dieResult,
next.enemy.trackPosition, enemy.trackPosition,
); );
if (action?.effects) { if (action?.effects) {
next = resolveEffects(next, action.effects, "enemy", "player"); // TODO: implement enemy targeting logic for multiplayer (random, lowest HP, etc.)
next = resolveEffects(
next,
action.effects,
{ type: "enemy", index: i },
{ type: "player", index: 0 },
);
} }
let trackPosition = next.enemy.trackPosition; // advance cube track for this enemy if applicable
if (next.enemy.actionType === "cube") { if (enemy.actionType === "cube") {
trackPosition = Math.min( const trackPosition = Math.min(
trackPosition + 1, enemy.trackPosition + 1,
(next.enemy.actionTrack || []).length - 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 { return {
...next, ...next,
enemy: { ...next.enemy, trackPosition },
combat: { combat: {
...next.combat, ...next.combat,
phase: "player_turn", phase: "player_turn",
@ -59,7 +84,7 @@ export function resolveEnemyTurn(state) {
} }
export function checkCombatEnd(state) { export function checkCombatEnd(state) {
if (state.enemy.hp <= 0) return "victory"; if (state.enemies.every((e) => e.hp <= 0)) return "victory";
if (state.player.hp <= 0) return "defeat"; if (state.players.some((p) => p.hp <= 0)) return "defeat";
return null; return null;
} }

View file

@ -49,13 +49,15 @@ describe("resolveEnemyTurn", () => {
describe("checkCombatEnd", () => { describe("checkCombatEnd", () => {
test("returns 'victory' when enemy hp is 0", () => { test("returns 'victory' when enemy hp is 0", () => {
let state = createCombatState("ironclad", "jaw_worm"); 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"); expect(checkCombatEnd(state)).toBe("victory");
}); });
test("returns 'defeat' when player hp is 0", () => { test("returns 'defeat' when player hp is 0", () => {
let state = createCombatState("ironclad", "jaw_worm"); 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"); expect(checkCombatEnd(state)).toBe("defeat");
}); });
@ -64,3 +66,81 @@ describe("checkCombatEnd", () => {
expect(checkCombatEnd(state)).toBeNull(); 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");
});
});

View file

@ -185,6 +185,8 @@ export function endTurn(state, playerIndex) {
}; };
} }
if (state.combat.playersReady.includes(playerIndex)) return state;
const player = state.players[playerIndex]; const player = state.players[playerIndex];
const updatedPlayer = { const updatedPlayer = {
...player, ...player,

View file

@ -217,4 +217,12 @@ describe("endTurn - indexed player", () => {
expect(next.combat.phase).toBe("enemy_turn"); expect(next.combat.phase).toBe("enemy_turn");
expect(next.combat.playersReady).toHaveLength(2); 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");
});
}); });