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.
This commit is contained in:
Jared Miller 2026-02-23 18:43:33 -05:00
parent 57d15914f4
commit b2056d8368
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 303 additions and 76 deletions

88
src/effects.js vendored
View file

@ -19,6 +19,44 @@ export function calculateHitDamage(
return Math.max(0, damage); 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) { export function resolveEffects(state, effects, source, target) {
let current = state; let current = state;
for (const effect of effects) { for (const effect of effects) {
@ -53,8 +91,8 @@ function resolveSingleEffect(state, effect, source, target) {
} }
function resolveHit(state, baseValue, source, target) { function resolveHit(state, baseValue, source, target) {
const attacker = state[source]; const attacker = getEntity(state, source);
const defender = state[target]; const defender = getEntity(state, target);
const damage = calculateHitDamage( const damage = calculateHitDamage(
baseValue, baseValue,
@ -75,38 +113,40 @@ function resolveHit(state, baseValue, source, target) {
hp = Math.max(0, hp - remaining); hp = Math.max(0, hp - remaining);
const vulnerable = Math.max(0, defender.vulnerable - 1); 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 { let next = setEntity(state, target, { ...defender, hp, block, vulnerable });
...state, next = setEntity(next, source, {
[target]: { ...defender, hp, block, vulnerable }, ...getEntity(next, source),
[source]: { ...state[source], weak: attackerWeak }, weak: attackerWeak,
}; });
return next;
} }
function resolveBlock(state, value, source) { function resolveBlock(state, value, source) {
const entity = state[source]; const entity = getEntity(state, source);
const maxBlock = source === "player" ? 20 : 999; // 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); const block = Math.min(entity.block + value, maxBlock);
return { return setEntity(state, source, { ...entity, block });
...state,
[source]: { ...entity, block },
};
} }
function applyStatus(state, target, status, value, max) { function applyStatus(state, target, status, value, max) {
const entity = state[target]; const entity = getEntity(state, target);
const current = entity[status] || 0; const current = entity[status] || 0;
return { return setEntity(state, target, {
...state, ...entity,
[target]: { ...entity, [status]: Math.min(current + value, max) }, [status]: Math.min(current + value, max),
}; });
} }
function directDamage(state, target, value) { function directDamage(state, target, value) {
const entity = state[target]; const entity = getEntity(state, target);
return { return setEntity(state, target, {
...state, ...entity,
[target]: { ...entity, hp: Math.max(0, entity.hp - value) }, hp: Math.max(0, entity.hp - value),
}; });
} }

View file

@ -2,54 +2,95 @@ import { getCard, getStarterDeck } from "./cards.js";
import { resolveEffects } from "./effects.js"; import { resolveEffects } from "./effects.js";
import { getEnemy } from "./enemies.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); const enemy = getEnemy(enemyId);
return { return {
player: { id: enemy.id,
hp: 11, instanceId: `${enemy.id}_${index}`,
maxHp: 11, name: enemy.name,
energy: 3, hp: enemy.hp,
maxEnergy: 3, maxHp: enemy.hp,
block: 0, block: 0,
strength: 0, strength: 0,
vulnerable: 0, vulnerable: 0,
weak: 0, weak: 0,
drawPile: shuffle([...getStarterDeck(character)]), actionType: enemy.actionType,
hand: [], actions: enemy.actions,
discardPile: [], actionTrack: enemy.actionTrack || null,
exhaustPile: [], trackPosition: 0,
powers: [], row: index,
}, };
enemy: { }
id: enemy.id,
name: enemy.name, export function createCombatState(characterOrChars, enemyIdOrIds) {
hp: enemy.hp, const characters = Array.isArray(characterOrChars)
maxHp: enemy.hp, ? characterOrChars
block: 0, : [characterOrChars];
strength: 0, const enemyIds = Array.isArray(enemyIdOrIds) ? enemyIdOrIds : [enemyIdOrIds];
vulnerable: 0,
weak: 0, const players = characters.map((c, i) => makePlayer(c, i));
actionType: enemy.actionType, const enemies = enemyIds.map((id, i) => makeEnemy(id, i));
actions: enemy.actions,
actionTrack: enemy.actionTrack || null, const state = {
trackPosition: 0, players,
}, enemies,
combat: { combat: {
turn: 1, turn: 1,
phase: "player_turn", phase: "player_turn",
dieResult: null, dieResult: null,
selectedCard: null, selectedCard: null,
log: [], 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) { export function drawCards(state, playerIndexOrCount, count) {
let drawPile = [...state.player.drawPile]; // drawCards(state, count) — old single-player form
let discardPile = [...state.player.discardPile]; // drawCards(state, playerIndex, count) — new indexed form
const hand = [...state.player.hand]; 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) { if (drawPile.length === 0) {
drawPile = shuffle(discardPile); drawPile = shuffle(discardPile);
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 { return {
...state, ...state,
player: { ...state.player, drawPile, hand, discardPile }, players,
// keep top-level compat alias in sync
player: players[0],
}; };
} }
export function playCard(state, handIndex) { export function playCard(
const cardId = state.player.hand[handIndex]; state,
const card = getCard(cardId); playerIndexOrHandIndex,
if (state.player.energy < card.cost) return null; 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); hand.splice(handIndex, 1);
const discardPile = [...state.player.discardPile, cardId]; const discardPile = [...player.discardPile, cardId];
const energy = state.player.energy - card.cost; 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 = { let next = {
...state, ...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; 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 { return {
...state, ...state,
player: { players,
...state.player, player: players[0],
hand: [], combat: {
discardPile: [...state.player.discardPile, ...state.player.hand], ...state.combat,
playersReady,
phase: allReady ? "enemy_turn" : state.combat.phase,
}, },
combat: { ...state.combat, phase: "enemy_turn" },
}; };
} }

View file

@ -61,7 +61,8 @@ describe("playCard", () => {
test("returns null if not enough energy", () => { test("returns null if not enough energy", () => {
let state = createCombatState("ironclad", "jaw_worm"); let state = createCombatState("ironclad", "jaw_worm");
state = drawCards(state, 5); 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 cardIndex = state.player.hand.indexOf("strike_r");
const result = playCard(state, cardIndex); const result = playCard(state, cardIndex);
expect(result).toBeNull(); expect(result).toBeNull();
@ -78,3 +79,76 @@ describe("endTurn", () => {
expect(next.combat.phase).toBe("enemy_turn"); 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);
});
});