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:
parent
57d15914f4
commit
b2056d8368
3 changed files with 303 additions and 76 deletions
88
src/effects.js
vendored
88
src/effects.js
vendored
|
|
@ -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),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
167
src/state.js
167
src/state.js
|
|
@ -2,10 +2,10 @@ 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) {
|
||||||
const enemy = getEnemy(enemyId);
|
|
||||||
return {
|
return {
|
||||||
player: {
|
id: `player_${index}`,
|
||||||
|
character,
|
||||||
hp: 11,
|
hp: 11,
|
||||||
maxHp: 11,
|
maxHp: 11,
|
||||||
energy: 3,
|
energy: 3,
|
||||||
|
|
@ -19,9 +19,14 @@ export function createCombatState(character, enemyId) {
|
||||||
discardPile: [],
|
discardPile: [],
|
||||||
exhaustPile: [],
|
exhaustPile: [],
|
||||||
powers: [],
|
powers: [],
|
||||||
},
|
};
|
||||||
enemy: {
|
}
|
||||||
|
|
||||||
|
function makeEnemy(enemyId, index) {
|
||||||
|
const enemy = getEnemy(enemyId);
|
||||||
|
return {
|
||||||
id: enemy.id,
|
id: enemy.id,
|
||||||
|
instanceId: `${enemy.id}_${index}`,
|
||||||
name: enemy.name,
|
name: enemy.name,
|
||||||
hp: enemy.hp,
|
hp: enemy.hp,
|
||||||
maxHp: enemy.hp,
|
maxHp: enemy.hp,
|
||||||
|
|
@ -33,23 +38,59 @@ export function createCombatState(character, enemyId) {
|
||||||
actions: enemy.actions,
|
actions: enemy.actions,
|
||||||
actionTrack: enemy.actionTrack || null,
|
actionTrack: enemy.actionTrack || null,
|
||||||
trackPosition: 0,
|
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: {
|
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,43 +100,115 @@ 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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
player: {
|
players,
|
||||||
...state.player,
|
player: players[0],
|
||||||
hand: [],
|
|
||||||
discardPile: [...state.player.discardPile, ...state.player.hand],
|
|
||||||
},
|
|
||||||
combat: { ...state.combat, phase: "enemy_turn" },
|
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,
|
||||||
|
players,
|
||||||
|
player: players[0],
|
||||||
|
combat: {
|
||||||
|
...state.combat,
|
||||||
|
playersReady,
|
||||||
|
phase: allReady ? "enemy_turn" : state.combat.phase,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function shuffle(arr) {
|
function shuffle(arr) {
|
||||||
const a = [...arr];
|
const a = [...arr];
|
||||||
for (let i = a.length - 1; i > 0; i--) {
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue