From b2056d8368d2f087eba04ba4f0cdd8687002a13a Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Mon, 23 Feb 2026 18:43:33 -0500 Subject: [PATCH] Expand state model for multiple players and enemies createCombatState now accepts arrays of characters and enemy IDs, producing players[] and enemies[] arrays. Backward compat aliases (state.player, state.enemy) are kept in sync. effects.js updated to resolve entity descriptors as either legacy string keys or {type, index} objects so both old and new call sites work. --- src/effects.js | 88 +++++++++++++------ src/state.js | 215 +++++++++++++++++++++++++++++++++++----------- src/state.test.js | 76 +++++++++++++++- 3 files changed, 303 insertions(+), 76 deletions(-) diff --git a/src/effects.js b/src/effects.js index a3272ea..bb1df67 100644 --- a/src/effects.js +++ b/src/effects.js @@ -19,6 +19,44 @@ export function calculateHitDamage( return Math.max(0, damage); } +// resolve source/target descriptor into the entity object and a setter +// descriptor is either a string key ("player","enemy") or {type, index} +function getEntity(state, descriptor) { + if (typeof descriptor === "string") { + return state[descriptor]; + } + if (descriptor.type === "player") { + return state.players[descriptor.index]; + } + if (descriptor.type === "enemy") { + return state.enemies[descriptor.index]; + } + return null; +} + +function setEntity(state, descriptor, updated) { + if (typeof descriptor === "string") { + const next = { ...state, [descriptor]: updated }; + // keep compat alias in sync + if (descriptor === "player") next.player = updated; + if (descriptor === "enemy") next.enemy = updated; + return next; + } + if (descriptor.type === "player") { + const players = state.players.map((p, i) => + i === descriptor.index ? updated : p, + ); + return { ...state, players, player: players[0] }; + } + if (descriptor.type === "enemy") { + const enemies = state.enemies.map((e, i) => + i === descriptor.index ? updated : e, + ); + return { ...state, enemies, enemy: enemies[0] }; + } + return state; +} + export function resolveEffects(state, effects, source, target) { let current = state; for (const effect of effects) { @@ -53,8 +91,8 @@ function resolveSingleEffect(state, effect, source, target) { } function resolveHit(state, baseValue, source, target) { - const attacker = state[source]; - const defender = state[target]; + const attacker = getEntity(state, source); + const defender = getEntity(state, target); const damage = calculateHitDamage( baseValue, @@ -75,38 +113,40 @@ function resolveHit(state, baseValue, source, target) { hp = Math.max(0, hp - remaining); const vulnerable = Math.max(0, defender.vulnerable - 1); - const attackerWeak = Math.max(0, state[source].weak - 1); + const attackerWeak = Math.max(0, attacker.weak - 1); - return { - ...state, - [target]: { ...defender, hp, block, vulnerable }, - [source]: { ...state[source], weak: attackerWeak }, - }; + let next = setEntity(state, target, { ...defender, hp, block, vulnerable }); + next = setEntity(next, source, { + ...getEntity(next, source), + weak: attackerWeak, + }); + return next; } function resolveBlock(state, value, source) { - const entity = state[source]; - const maxBlock = source === "player" ? 20 : 999; + const entity = getEntity(state, source); + // player block cap at 20, enemy uncapped + const isPlayer = + source === "player" || + (typeof source === "object" && source.type === "player"); + const maxBlock = isPlayer ? 20 : 999; const block = Math.min(entity.block + value, maxBlock); - return { - ...state, - [source]: { ...entity, block }, - }; + return setEntity(state, source, { ...entity, block }); } function applyStatus(state, target, status, value, max) { - const entity = state[target]; + const entity = getEntity(state, target); const current = entity[status] || 0; - return { - ...state, - [target]: { ...entity, [status]: Math.min(current + value, max) }, - }; + return setEntity(state, target, { + ...entity, + [status]: Math.min(current + value, max), + }); } function directDamage(state, target, value) { - const entity = state[target]; - return { - ...state, - [target]: { ...entity, hp: Math.max(0, entity.hp - value) }, - }; + const entity = getEntity(state, target); + return setEntity(state, target, { + ...entity, + hp: Math.max(0, entity.hp - value), + }); } diff --git a/src/state.js b/src/state.js index aad9f0f..0f728ba 100644 --- a/src/state.js +++ b/src/state.js @@ -2,54 +2,95 @@ import { getCard, getStarterDeck } from "./cards.js"; import { resolveEffects } from "./effects.js"; import { getEnemy } from "./enemies.js"; -export function createCombatState(character, enemyId) { +function makePlayer(character, index) { + return { + id: `player_${index}`, + character, + hp: 11, + maxHp: 11, + energy: 3, + maxEnergy: 3, + block: 0, + strength: 0, + vulnerable: 0, + weak: 0, + drawPile: shuffle([...getStarterDeck(character)]), + hand: [], + discardPile: [], + exhaustPile: [], + powers: [], + }; +} + +function makeEnemy(enemyId, index) { const enemy = getEnemy(enemyId); return { - player: { - hp: 11, - maxHp: 11, - energy: 3, - maxEnergy: 3, - block: 0, - strength: 0, - vulnerable: 0, - weak: 0, - drawPile: shuffle([...getStarterDeck(character)]), - hand: [], - discardPile: [], - exhaustPile: [], - powers: [], - }, - enemy: { - id: enemy.id, - name: enemy.name, - hp: enemy.hp, - maxHp: enemy.hp, - block: 0, - strength: 0, - vulnerable: 0, - weak: 0, - actionType: enemy.actionType, - actions: enemy.actions, - actionTrack: enemy.actionTrack || null, - trackPosition: 0, - }, + id: enemy.id, + instanceId: `${enemy.id}_${index}`, + name: enemy.name, + hp: enemy.hp, + maxHp: enemy.hp, + block: 0, + strength: 0, + vulnerable: 0, + weak: 0, + actionType: enemy.actionType, + actions: enemy.actions, + actionTrack: enemy.actionTrack || null, + trackPosition: 0, + row: index, + }; +} + +export function createCombatState(characterOrChars, enemyIdOrIds) { + const characters = Array.isArray(characterOrChars) + ? characterOrChars + : [characterOrChars]; + const enemyIds = Array.isArray(enemyIdOrIds) ? enemyIdOrIds : [enemyIdOrIds]; + + const players = characters.map((c, i) => makePlayer(c, i)); + const enemies = enemyIds.map((id, i) => makeEnemy(id, i)); + + const state = { + players, + enemies, combat: { turn: 1, phase: "player_turn", dieResult: null, selectedCard: null, log: [], + playerCount: players.length, + activePlayerIndex: null, // TODO: unused — remove once no callers remain + playersReady: [], }, }; + + // backward-compat aliases — only valid for single-enemy encounters + state.player = players[0]; + state.enemy = enemies[0]; + + return state; } -export function drawCards(state, count) { - let drawPile = [...state.player.drawPile]; - let discardPile = [...state.player.discardPile]; - const hand = [...state.player.hand]; +export function drawCards(state, playerIndexOrCount, count) { + // drawCards(state, count) — old single-player form + // drawCards(state, playerIndex, count) — new indexed form + let playerIndex, drawCount; + if (count === undefined) { + playerIndex = 0; + drawCount = playerIndexOrCount; + } else { + playerIndex = playerIndexOrCount; + drawCount = count; + } - for (let i = 0; i < count; i++) { + const player = state.players[playerIndex]; + let drawPile = [...player.drawPile]; + let discardPile = [...player.discardPile]; + const hand = [...player.hand]; + + for (let i = 0; i < drawCount; i++) { if (drawPile.length === 0) { drawPile = shuffle(discardPile); discardPile = []; @@ -59,40 +100,112 @@ export function drawCards(state, count) { } } + const updatedPlayer = { ...player, drawPile, hand, discardPile }; + const players = state.players.map((p, i) => + i === playerIndex ? updatedPlayer : p, + ); + return { ...state, - player: { ...state.player, drawPile, hand, discardPile }, + players, + // keep top-level compat alias in sync + player: players[0], }; } -export function playCard(state, handIndex) { - const cardId = state.player.hand[handIndex]; - const card = getCard(cardId); - if (state.player.energy < card.cost) return null; +export function playCard( + state, + playerIndexOrHandIndex, + handIndexOrTarget, + targetIndex, +) { + // playCard(state, handIndex) — old form, targets enemy[0] + // playCard(state, playerIndex, handIndex, targetIndex) — new form + let playerIndex, handIndex, enemyIndex; + if (handIndexOrTarget === undefined) { + playerIndex = 0; + handIndex = playerIndexOrHandIndex; + enemyIndex = 0; + } else if (targetIndex === undefined) { + playerIndex = 0; + handIndex = playerIndexOrHandIndex; + enemyIndex = handIndexOrTarget; + } else { + playerIndex = playerIndexOrHandIndex; + handIndex = handIndexOrTarget; + enemyIndex = targetIndex; + } - const hand = [...state.player.hand]; + const player = state.players[playerIndex]; + const cardId = player.hand[handIndex]; + const card = getCard(cardId); + if (player.energy < card.cost) return null; + + const hand = [...player.hand]; hand.splice(handIndex, 1); - const discardPile = [...state.player.discardPile, cardId]; - const energy = state.player.energy - card.cost; + const discardPile = [...player.discardPile, cardId]; + const energy = player.energy - card.cost; + + const updatedPlayer = { ...player, hand, discardPile, energy }; + const players = state.players.map((p, i) => + i === playerIndex ? updatedPlayer : p, + ); let next = { ...state, - player: { ...state.player, hand, discardPile, energy }, + players, + player: players[0], }; - next = resolveEffects(next, card.effects, "player", "enemy"); + next = resolveEffects( + next, + card.effects, + { type: "player", index: playerIndex }, + { type: "enemy", index: enemyIndex }, + ); return next; } -export function endTurn(state) { +export function endTurn(state, playerIndex) { + // endTurn(state) — old form: discards all, sets enemy phase + // endTurn(state, playerIndex) — new form: marks one player done + if (playerIndex === undefined) { + const player = state.players[0]; + const updatedPlayer = { + ...player, + hand: [], + discardPile: [...player.discardPile, ...player.hand], + }; + const players = state.players.map((p, i) => (i === 0 ? updatedPlayer : p)); + return { + ...state, + players, + player: players[0], + combat: { ...state.combat, phase: "enemy_turn" }, + }; + } + + const player = state.players[playerIndex]; + const updatedPlayer = { + ...player, + hand: [], + discardPile: [...player.discardPile, ...player.hand], + }; + const players = state.players.map((p, i) => + i === playerIndex ? updatedPlayer : p, + ); + const playersReady = [...state.combat.playersReady, playerIndex]; + const allReady = playersReady.length >= state.combat.playerCount; + return { ...state, - player: { - ...state.player, - hand: [], - discardPile: [...state.player.discardPile, ...state.player.hand], + players, + player: players[0], + combat: { + ...state.combat, + playersReady, + phase: allReady ? "enemy_turn" : state.combat.phase, }, - combat: { ...state.combat, phase: "enemy_turn" }, }; } diff --git a/src/state.test.js b/src/state.test.js index 7db5742..4d5eda0 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -61,7 +61,8 @@ describe("playCard", () => { test("returns null if not enough energy", () => { let state = createCombatState("ironclad", "jaw_worm"); state = drawCards(state, 5); - state = { ...state, player: { ...state.player, energy: 0 } }; + const zeroEnergy = { ...state.player, energy: 0 }; + state = { ...state, player: zeroEnergy, players: [zeroEnergy] }; const cardIndex = state.player.hand.indexOf("strike_r"); const result = playCard(state, cardIndex); expect(result).toBeNull(); @@ -78,3 +79,76 @@ describe("endTurn", () => { expect(next.combat.phase).toBe("enemy_turn"); }); }); + +describe("createCombatState - multi player/enemy", () => { + test("creates state with 2 players and 2 enemies via arrays", () => { + const state = createCombatState( + ["ironclad", "ironclad"], + ["jaw_worm", "jaw_worm"], + ); + expect(state.players).toHaveLength(2); + expect(state.enemies).toHaveLength(2); + }); + + test("each player gets own deck, HP, and energy", () => { + const state = createCombatState( + ["ironclad", "ironclad"], + ["jaw_worm", "jaw_worm"], + ); + const p0 = state.players[0]; + const p1 = state.players[1]; + expect(p0.hp).toBe(11); + expect(p0.maxHp).toBe(11); + expect(p0.energy).toBe(3); + expect(p0.drawPile).toHaveLength(10); + expect(p0.hand).toHaveLength(0); + expect(p1.hp).toBe(11); + expect(p1.energy).toBe(3); + expect(p1.drawPile).toHaveLength(10); + }); + + test("each player has a unique id", () => { + const state = createCombatState( + ["ironclad", "ironclad"], + ["jaw_worm", "jaw_worm"], + ); + expect(state.players[0].id).toBe("player_0"); + expect(state.players[1].id).toBe("player_1"); + }); + + test("each enemy gets own HP and actions", () => { + const state = createCombatState( + ["ironclad", "ironclad"], + ["jaw_worm", "jaw_worm"], + ); + const e0 = state.enemies[0]; + const e1 = state.enemies[1]; + expect(e0.hp).toBeGreaterThan(0); + expect(e0.actions).toBeDefined(); + expect(e1.hp).toBeGreaterThan(0); + expect(e1.actions).toBeDefined(); + }); + + test("combat metadata includes playerCount and playersReady", () => { + const state = createCombatState( + ["ironclad", "ironclad"], + ["jaw_worm", "jaw_worm"], + ); + expect(state.combat.playerCount).toBe(2); + expect(state.combat.playersReady).toEqual([]); + expect(state.combat.activePlayerIndex).toBeNull(); + }); + + test("enemies have row field", () => { + const state = createCombatState(["ironclad"], ["jaw_worm", "jaw_worm"]); + expect(state.enemies[0].row).toBeDefined(); + expect(state.enemies[1].row).toBeDefined(); + }); + + test("two jaw worms have different instanceIds", () => { + const state = createCombatState(["ironclad"], ["jaw_worm", "jaw_worm"]); + expect(state.enemies[0].instanceId).toBeDefined(); + expect(state.enemies[1].instanceId).toBeDefined(); + expect(state.enemies[0].instanceId).not.toBe(state.enemies[1].instanceId); + }); +});