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);
|
||||
}
|
||||
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
167
src/state.js
167
src/state.js
|
|
@ -2,10 +2,10 @@ import { getCard, getStarterDeck } from "./cards.js";
|
|||
import { resolveEffects } from "./effects.js";
|
||||
import { getEnemy } from "./enemies.js";
|
||||
|
||||
export function createCombatState(character, enemyId) {
|
||||
const enemy = getEnemy(enemyId);
|
||||
function makePlayer(character, index) {
|
||||
return {
|
||||
player: {
|
||||
id: `player_${index}`,
|
||||
character,
|
||||
hp: 11,
|
||||
maxHp: 11,
|
||||
energy: 3,
|
||||
|
|
@ -19,9 +19,14 @@ export function createCombatState(character, enemyId) {
|
|||
discardPile: [],
|
||||
exhaustPile: [],
|
||||
powers: [],
|
||||
},
|
||||
enemy: {
|
||||
};
|
||||
}
|
||||
|
||||
function makeEnemy(enemyId, index) {
|
||||
const enemy = getEnemy(enemyId);
|
||||
return {
|
||||
id: enemy.id,
|
||||
instanceId: `${enemy.id}_${index}`,
|
||||
name: enemy.name,
|
||||
hp: enemy.hp,
|
||||
maxHp: enemy.hp,
|
||||
|
|
@ -33,23 +38,59 @@ export function createCombatState(character, enemyId) {
|
|||
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,41 +100,113 @@ 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,
|
||||
player: {
|
||||
...state.player,
|
||||
hand: [],
|
||||
discardPile: [...state.player.discardPile, ...state.player.hand],
|
||||
},
|
||||
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,
|
||||
players,
|
||||
player: players[0],
|
||||
combat: {
|
||||
...state.combat,
|
||||
playersReady,
|
||||
phase: allReady ? "enemy_turn" : state.combat.phase,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue