Compare commits

...

23 commits

Author SHA1 Message Date
90c2aca72e
Fix empty combat view on load and null state crash
Game div now starts hidden so map screen is the first thing visible.
Combat event handlers guard against null state.
2026-02-25 11:50:26 -05:00
8ae3252a9c
Mark all Act 1 plan tasks complete 2026-02-25 11:34:29 -05:00
cd42652087
Add integration tests for Act 1 edge cases 2026-02-25 11:34:29 -05:00
7280af2075
Add campfire UI with rest and smith actions 2026-02-25 10:47:07 -05:00
51dce7eba1
Add campfireSmith and getUpgradableCards with tests 2026-02-25 10:47:06 -05:00
91d32a71ee
Add campfireRest function with tests 2026-02-25 10:32:55 -05:00
febb05764b
Wire map into game loop with node-driven combat 2026-02-25 10:25:41 -05:00
4d0692cf44
Add map screen rendering and HTML section 2026-02-25 10:25:41 -05:00
7dfcc416d4
Add enemy pools and getNodeEnemy by node type 2026-02-25 09:53:16 -05:00
a7c5cbc56a
Add map state module with createMap, advanceMap, getCurrentNode 2026-02-25 09:52:22 -05:00
4f91396e3a
Add keyword integration tests
Exercises exhaust, ethereal, retain, poison, unplayable, and ironclad
heal-on-victory in a single test file using manual state construction
to control hand contents without relying on random draws.
2026-02-25 09:44:54 -05:00
ac45cb8758
Add ironclad end-of-combat heal passive 2026-02-25 09:44:54 -05:00
dd343be64a
Add poison tick at start of enemy turn 2026-02-25 09:44:54 -05:00
02f9e1ec2c
Add poison effect to enemies 2026-02-25 09:24:13 -05:00
62a1ef051c
Add retain keyword to endTurn 2026-02-25 09:22:19 -05:00
8a6a2f7662
Add ethereal keyword to endTurn 2026-02-25 09:22:02 -05:00
e05663d1c7
Add unplayable keyword to playCard 2026-02-25 09:20:19 -05:00
fe95c03529
Add exhaust keyword to playCard 2026-02-25 09:19:59 -05:00
77f65ace98
Wire run loop with victory rewards, defeat restart, and combat chaining 2026-02-25 08:02:04 -05:00
4e457b80af
Add createCombatFromRun to create combat state from run 2026-02-25 08:02:04 -05:00
46bd6e6d2b
Add reward reveal, pick, and skip functions to run module 2026-02-25 08:02:04 -05:00
11e12ee906
Add run state module with createRunState and rewards deck builder 2026-02-24 22:35:21 -05:00
28e54d502f
Export shuffle from state and add getAllCards to cards module 2026-02-24 22:34:19 -05:00
18 changed files with 3395 additions and 67 deletions

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<div id="game"> <div id="game" hidden>
<section id="enemy-zone"> <section id="enemy-zone">
<div id="enemy-info"> <div id="enemy-info">
<span id="enemy-name"></span> <span id="enemy-name"></span>
@ -34,7 +34,27 @@
<section id="hand"></section> <section id="hand"></section>
</div> </div>
<div id="overlay" hidden></div> <section id="map-screen" hidden>
<h2>act 1</h2>
<div id="map-nodes"></div>
<button type="button" id="map-proceed-btn">proceed</button>
</section>
<section id="campfire-screen" hidden>
<h2>campfire</h2>
<p id="campfire-hp"></p>
<div id="campfire-choices">
<button type="button" id="campfire-rest-btn">rest (heal 3 HP)</button>
<button type="button" id="campfire-smith-btn">smith (upgrade a card)</button>
</div>
<div id="smith-cards" hidden></div>
</section>
<div id="overlay" hidden>
<div id="overlay-text"></div>
<div id="reward-cards"></div>
<button type="button" id="skip-btn" hidden>skip</button>
</div>
<script type="module" src="src/main.js"></script> <script type="module" src="src/main.js"></script>
</body> </body>

View file

@ -17,3 +17,7 @@ export function getStarterDeck(character) {
} }
return []; return [];
} }
export function getAllCards() {
return Object.values(cardDb);
}

View file

@ -1,5 +1,5 @@
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { getCard, getStarterDeck, initCards } from "./cards.js"; import { getAllCards, getCard, getStarterDeck, initCards } from "./cards.js";
beforeAll(async () => { beforeAll(async () => {
await initCards(); await initCards();
@ -26,3 +26,18 @@ describe("cards", () => {
expect(deck.filter((id) => id === "bash")).toHaveLength(1); expect(deck.filter((id) => id === "bash")).toHaveLength(1);
}); });
}); });
describe("getAllCards", () => {
test("returns array with more than 100 cards", () => {
const cards = getAllCards();
expect(cards.length).toBeGreaterThan(100);
});
test("each card has id and name properties", () => {
const cards = getAllCards();
for (const card of cards) {
expect(card.id).toBeDefined();
expect(card.name).toBeDefined();
}
});
});

View file

@ -38,11 +38,24 @@ export function startTurn(state) {
export function resolveEnemyTurn(state) { export function resolveEnemyTurn(state) {
// reset block on all enemies before they act // reset block on all enemies before they act
const enemies = state.enemies.map((e) => ({ ...e, block: 0 })); const enemies = state.enemies.map((e) => ({ ...e, block: 0 }));
let next = { ...state, enemies, enemy: enemies[0] };
// poison tick — damage and decrement before enemies act
const afterPoison = enemies.map((e) => {
if (e.poison > 0) {
return {
...e,
hp: Math.max(0, e.hp - e.poison),
poison: e.poison - 1,
};
}
return e;
});
let next = { ...state, enemies: afterPoison, enemy: afterPoison[0] };
// each enemy acts; enemies target player 0 by default // each enemy acts; enemies target player 0 by default
for (let i = 0; i < next.enemies.length; i++) { for (let i = 0; i < next.enemies.length; i++) {
const enemy = next.enemies[i]; const enemy = next.enemies[i];
if (enemy.hp <= 0) continue; // dead enemies don't act
const action = resolveEnemyAction( const action = resolveEnemyAction(
enemy, enemy,
next.combat.dieResult, next.combat.dieResult,

View file

@ -1,6 +1,7 @@
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { initCards } from "./cards.js"; import { initCards } from "./cards.js";
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js"; import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
import { resolveEffects } from "./effects.js";
import { initEnemies } from "./enemies.js"; import { initEnemies } from "./enemies.js";
import { createCombatState } from "./state.js"; import { createCombatState } from "./state.js";
@ -144,3 +145,112 @@ describe("checkCombatEnd - multiplayer", () => {
expect(checkCombatEnd(state)).toBe("defeat"); expect(checkCombatEnd(state)).toBe("defeat");
}); });
}); });
describe("poison effect", () => {
test("applying poison adds poison to target enemy", () => {
let state = createCombatState("ironclad", "jaw_worm");
state = {
...state,
enemies: [{ ...state.enemies[0], poison: 0 }],
enemy: { ...state.enemy, poison: 0 },
};
const next = resolveEffects(
state,
[{ type: "poison", value: 3 }],
{ type: "player", index: 0 },
{ type: "enemy", index: 0 },
);
expect(next.enemies[0].poison).toBe(3);
});
test("poison stacks additively up to 30", () => {
let state = createCombatState("ironclad", "jaw_worm");
state = {
...state,
enemies: [{ ...state.enemies[0], poison: 0 }],
enemy: { ...state.enemy, poison: 0 },
};
let next = resolveEffects(
state,
[{ type: "poison", value: 20 }],
{ type: "player", index: 0 },
{ type: "enemy", index: 0 },
);
next = resolveEffects(
next,
[{ type: "poison", value: 15 }],
{ type: "player", index: 0 },
{ type: "enemy", index: 0 },
);
expect(next.enemies[0].poison).toBe(30);
});
});
describe("poison tick", () => {
test("poison damages enemy and decrements at start of enemy turn", () => {
let state = createCombatState("ironclad", "jaw_worm");
const poisonedEnemy = { ...state.enemies[0], poison: 5, hp: 10 };
state = {
...state,
enemies: [poisonedEnemy],
enemy: poisonedEnemy,
combat: { ...state.combat, dieResult: 1 },
};
const next = resolveEnemyTurn(state);
expect(next.enemies[0].hp).toBe(5);
expect(next.enemies[0].poison).toBe(4);
});
test("enemy dies from poison", () => {
let state = createCombatState("ironclad", "jaw_worm");
const poisonedEnemy = { ...state.enemies[0], poison: 10, hp: 3 };
state = {
...state,
enemies: [poisonedEnemy],
enemy: poisonedEnemy,
combat: { ...state.combat, dieResult: 1 },
};
const next = resolveEnemyTurn(state);
expect(next.enemies[0].hp).toBe(0);
expect(next.enemies[0].poison).toBe(9);
});
test("zero poison does not tick", () => {
let state = createCombatState("ironclad", "jaw_worm");
state = { ...state, combat: { ...state.combat, dieResult: 1 } };
const next = resolveEnemyTurn(state);
expect(next.enemies[0].poison).toBe(0);
});
test("enemy killed by poison does not act", () => {
let state = createCombatState("ironclad", "jaw_worm");
const poisonedEnemy = { ...state.enemies[0], poison: 10, hp: 3 };
state = {
...state,
enemies: [poisonedEnemy],
enemy: poisonedEnemy,
players: [{ ...state.players[0], hp: 11, block: 0 }],
player: { ...state.players[0], hp: 11, block: 0 },
combat: { ...state.combat, dieResult: 1 },
};
const next = resolveEnemyTurn(state);
expect(next.enemies[0].hp).toBe(0);
expect(next.players[0].hp).toBe(11); // enemy died before acting
});
test("poison ignores enemy block", () => {
let state = createCombatState("ironclad", "jaw_worm");
const blockedEnemy = { ...state.enemies[0], poison: 3, hp: 10, block: 5 };
state = {
...state,
enemies: [blockedEnemy],
enemy: blockedEnemy,
combat: { ...state.combat, dieResult: 1 },
};
const next = resolveEnemyTurn(state);
expect(next.enemies[0].hp).toBe(7); // 10 - 3, block didn't absorb poison
// block resets at turn start; enemy may regain some via action — not 5 from before
expect(next.enemies[0].block).toBeLessThan(5);
expect(next.enemies[0].poison).toBe(2);
});
});

2
src/effects.js vendored
View file

@ -71,6 +71,8 @@ function resolveSingleEffect(state, effect, source, target) {
return applyStatus(state, target, "vulnerable", effect.value, 3); return applyStatus(state, target, "vulnerable", effect.value, 3);
case "weak": case "weak":
return applyStatus(state, target, "weak", effect.value, 3); return applyStatus(state, target, "weak", effect.value, 3);
case "poison":
return applyStatus(state, target, "poison", effect.value, 30);
case "strength": case "strength":
return applyStatus(state, source, "strength", effect.value, 8); return applyStatus(state, source, "strength", effect.value, 8);
case "draw": { case "draw": {

106
src/integration.test.js Normal file
View file

@ -0,0 +1,106 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { initCards } from "./cards.js";
import { getEnemy, initEnemies } from "./enemies.js";
import {
campfireRest,
campfireSmith,
createRunState,
endCombat,
pickReward,
revealRewards,
skipRewards,
} from "./run.js";
import { createCombatFromRun } from "./state.js";
beforeAll(async () => {
await Promise.all([initCards(), initEnemies()]);
});
describe("skip all rewards → deck stays at 10", () => {
test("3 combats with skipped rewards leave deck unchanged", () => {
let run = createRunState("ironclad");
expect(run.deck).toHaveLength(10);
for (let i = 0; i < 3; i++) {
const { revealed, run: afterReveal } = revealRewards(run);
run = skipRewards(afterReveal, revealed);
expect(run.deck).toHaveLength(10);
}
});
});
describe("take every reward → deck grows", () => {
test("3 combats with picked rewards add 3 cards to deck", () => {
let run = createRunState("ironclad");
expect(run.deck).toHaveLength(10);
for (let i = 0; i < 3; i++) {
const { revealed, run: afterReveal } = revealRewards(run);
run = pickReward(afterReveal, revealed, 0);
}
expect(run.deck).toHaveLength(13);
});
});
describe("rest at campfires → HP carries into combat", () => {
test("healed HP is reflected in combat player state", () => {
let run = { ...createRunState("ironclad"), hp: 5 };
run = campfireRest(run);
expect(run.hp).toBe(8);
const combat = createCombatFromRun(run, "jaw_worm");
expect(combat.players[0].hp).toBe(8);
});
test("campfireRest caps at maxHp and combat reflects the cap", () => {
let run = { ...createRunState("ironclad"), hp: 10 };
run = campfireRest(run);
expect(run.hp).toBe(11);
const combat = createCombatFromRun(run, "jaw_worm");
expect(combat.players[0].hp).toBe(11);
});
});
describe("smith upgrades change cards in combat", () => {
test("smithed card appears in draw pile when combat is created", () => {
let run = createRunState("ironclad");
run = campfireSmith(run, "strike_r");
expect(run.deck).toContain("strike_r+");
const combat = createCombatFromRun(run, "jaw_worm");
const drawPile = combat.players[0].drawPile;
expect(drawPile).toContain("strike_r+");
});
});
describe("fresh run after death resets everything", () => {
test("death leaves hp 0, new run restores full HP and original deck", () => {
let run = createRunState("ironclad");
// simulate dying: endCombat with 0 HP
// ironclad gets +1 from burning blood, so post-combat hp is 1
run = endCombat(run, 0);
expect(run.hp).toBe(1);
// fresh run represents starting over
const fresh = createRunState("ironclad");
expect(fresh.hp).toBe(11);
expect(fresh.maxHp).toBe(11);
expect(fresh.deck).toHaveLength(10);
expect(fresh.combatCount).toBe(0);
});
});
describe("boss enemies are harder than encounter enemies", () => {
test("all bosses have higher HP than all encounter enemies", () => {
const bosses = ["slime_boss", "the_guardian"].map((id) => getEnemy(id));
const encounters = ["jaw_worm", "cultist"].map((id) => getEnemy(id));
const minBossHp = Math.min(...bosses.map((e) => e.hp));
const maxEncounterHp = Math.max(...encounters.map((e) => e.hp));
expect(minBossHp).toBeGreaterThan(maxEncounterHp);
});
});

168
src/keywords.test.js Normal file
View file

@ -0,0 +1,168 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { initCards } from "./cards.js";
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
import { initEnemies } from "./enemies.js";
import { createRunState, endCombat } from "./run.js";
import { createCombatFromRun, drawCards, endTurn, playCard } from "./state.js";
beforeAll(async () => {
await Promise.all([initCards(), initEnemies()]);
});
describe("keyword integration", () => {
test("exhaust card removed from cycle after play", () => {
const run = createRunState("ironclad");
let state = createCombatFromRun(run, "jaw_worm");
state = drawCards(state, 5);
// force true_grit into hand at index 0
const player = {
...state.players[0],
hand: ["true_grit", ...state.players[0].hand.slice(1)],
};
state = { ...state, players: [player], player };
state = playCard(state, 0);
expect(state.players[0].exhaustPile).toContain("true_grit");
state = endTurn(state);
state = { ...state, combat: { ...state.combat, dieResult: 3 } };
state = resolveEnemyTurn(state);
state = startTurn(state);
expect(state.players[0].hand).not.toContain("true_grit");
expect(state.players[0].drawPile).not.toContain("true_grit");
expect(state.players[0].discardPile).not.toContain("true_grit");
expect(state.players[0].exhaustPile).toContain("true_grit");
});
test("ethereal card exhausts if not played by end of turn", () => {
const run = createRunState("ironclad");
let state = createCombatFromRun(run, "jaw_worm");
const player = { ...state.players[0], hand: ["carnage", "strike_r"] };
state = { ...state, players: [player], player };
state = endTurn(state);
expect(state.players[0].exhaustPile).toContain("carnage");
expect(state.players[0].discardPile).not.toContain("carnage");
expect(state.players[0].discardPile).toContain("strike_r");
expect(state.players[0].exhaustPile).not.toContain("strike_r");
});
test("retained card stays through turn cycle", () => {
const run = createRunState("ironclad");
let state = createCombatFromRun(run, "jaw_worm");
const player = { ...state.players[0], hand: ["equilibrium", "strike_r"] };
state = { ...state, players: [player], player };
state = endTurn(state);
expect(state.players[0].hand).toContain("equilibrium");
expect(state.players[0].discardPile).toContain("strike_r");
state = { ...state, combat: { ...state.combat, dieResult: 3 } };
state = resolveEnemyTurn(state);
state = startTurn(state);
// equilibrium was retained, startTurn draws 5 more on top of it
expect(state.players[0].hand).toContain("equilibrium");
expect(state.players[0].hand).toHaveLength(6);
});
test("poison ticks down and kills enemy", () => {
const run = createRunState("ironclad");
let state = createCombatFromRun(run, "jaw_worm");
const enemy = { ...state.enemies[0], poison: 3, hp: 5 };
state = {
...state,
enemies: [enemy],
enemy,
combat: { ...state.combat, dieResult: 3 },
};
state = resolveEnemyTurn(state);
expect(state.enemies[0].hp).toBe(2);
expect(state.enemies[0].poison).toBe(2);
const dying = { ...state.enemies[0], poison: 5, hp: 3 };
state = {
...state,
enemies: [dying],
enemy: dying,
combat: { ...state.combat, dieResult: 3 },
};
state = resolveEnemyTurn(state);
expect(state.enemies[0].hp).toBe(0);
expect(checkCombatEnd(state)).toBe("victory");
});
test("unplayable card blocks play", () => {
const run = createRunState("ironclad");
let state = createCombatFromRun(run, "jaw_worm");
const player = { ...state.players[0], hand: ["tactician", "strike_r"] };
state = { ...state, players: [player], player };
const result = playCard(state, 0);
expect(result).toBeNull();
expect(state.players[0].hand).toEqual(["tactician", "strike_r"]);
});
test("ironclad heals after victory", () => {
let run = createRunState("ironclad");
run = { ...run, hp: 8 };
const updated = endCombat(run, 8);
expect(updated.hp).toBe(9);
expect(updated.combatCount).toBe(1);
});
test("full combat: exhaust + poison + victory heal", () => {
let run = createRunState("ironclad");
run = { ...run, hp: 10 };
let state = createCombatFromRun(run, "jaw_worm");
state = startTurn(state);
// inject true_grit into hand and set enemy poison
const player = {
...state.players[0],
hand: ["true_grit", ...state.players[0].hand.slice(1)],
};
const enemy = { ...state.enemies[0], poison: 5 };
state = {
...state,
players: [player],
player,
enemies: [enemy],
enemy,
};
state = playCard(state, 0);
expect(state.players[0].exhaustPile).toContain("true_grit");
// set enemy hp below poison so it dies from the tick
const dying = { ...state.enemies[0], hp: 4 };
state = {
...state,
enemies: [dying],
enemy: dying,
combat: { ...state.combat, dieResult: 3 },
};
state = endTurn(state);
state = resolveEnemyTurn(state);
expect(checkCombatEnd(state)).toBe("victory");
const finalHp = state.players[0].hp;
const afterRun = endCombat(run, finalHp);
expect(afterRun.hp).toBe(finalHp + 1);
expect(afterRun.combatCount).toBe(1);
});
});

View file

@ -1,28 +1,124 @@
import { getCard, initCards } from "./cards.js"; import { getCard, initCards } from "./cards.js";
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js"; import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
import { initEnemies } from "./enemies.js"; import { initEnemies } from "./enemies.js";
import { render } from "./render.js"; import { advanceMap, getCurrentNode, getNodeEnemy } from "./map.js";
import { createCombatState, endTurn, playCard } from "./state.js"; import {
hideCampfire,
render,
renderCampfire,
renderMap,
showGame,
} from "./render.js";
import {
campfireRest,
campfireSmith,
createRunState,
endCombat,
getUpgradableCards,
pickReward,
revealRewards,
skipRewards,
} from "./run.js";
import { createCombatFromRun, endTurn, playCard } from "./state.js";
let state = null; let state = null;
let run = null;
let revealed = null;
async function init() { async function init() {
await Promise.all([initCards(), initEnemies()]); await Promise.all([initCards(), initEnemies()]);
state = createCombatState("ironclad", "jaw_worm"); startNewRun();
state = startTurn(state);
render(state);
bindEvents(); bindEvents();
} }
function startNewRun() {
run = createRunState("ironclad");
revealed = null;
showMapScreen();
}
function showMapScreen() {
renderMap(run.map);
}
function showCampfire() {
renderCampfire(run);
}
function proceedFromMap() {
const node = getCurrentNode(run.map);
if (node.type === "campfire") {
showCampfire();
return;
}
const enemyId = getNodeEnemy(node.type);
startNextCombat(enemyId);
}
function startNextCombat(enemyId) {
showGame();
state = createCombatFromRun(run, enemyId);
state = startTurn(state);
revealed = null;
render(state, revealed);
}
function handleVictory() {
const clearedNode = getCurrentNode(run.map);
run = endCombat(run, state.players[0].hp);
run = { ...run, map: advanceMap(run.map) };
if (clearedNode.type === "boss") {
state = {
...state,
combat: { ...state.combat, phase: "ended", result: "act_complete" },
};
render(state, revealed);
return;
}
const result = revealRewards(run);
revealed = result.revealed;
run = result.run;
state = {
...state,
combat: { ...state.combat, phase: "rewards" },
};
render(state, revealed);
}
function handleDefeat() {
run = { ...run, hp: state.players[0].hp };
state = {
...state,
combat: { ...state.combat, phase: "ended", result: "defeat" },
};
render(state, revealed);
}
function checkEnd() {
const end = checkCombatEnd(state);
if (end === "victory") {
handleVictory();
return true;
}
if (end === "defeat") {
handleDefeat();
return true;
}
return false;
}
function bindEvents() { function bindEvents() {
document.getElementById("hand").addEventListener("click", (e) => { document.getElementById("hand").addEventListener("click", (e) => {
const cardEl = e.target.closest(".card"); const cardEl = e.target.closest(".card");
if (!state) return;
if (!cardEl || state.combat.phase !== "player_turn") return; if (!cardEl || state.combat.phase !== "player_turn") return;
const index = Number(cardEl.dataset.index); const index = Number(cardEl.dataset.index);
if (state.combat.selectedCard === index) { if (state.combat.selectedCard === index) {
state = { ...state, combat: { ...state.combat, selectedCard: null } }; state = { ...state, combat: { ...state.combat, selectedCard: null } };
render(state); render(state, revealed);
return; return;
} }
@ -32,78 +128,113 @@ function bindEvents() {
const card = getCard(cardId); const card = getCard(cardId);
if (card.type === "skill") { if (card.type === "skill") {
// auto-play skills (they target self)
const result = playCard(state, index); const result = playCard(state, index);
if (result === null) { if (result === null) {
// not enough energy
state = { ...state, combat: { ...state.combat, selectedCard: null } }; state = { ...state, combat: { ...state.combat, selectedCard: null } };
render(state); render(state, revealed);
return; return;
} }
state = { ...result, combat: { ...result.combat, selectedCard: null } }; state = { ...result, combat: { ...result.combat, selectedCard: null } };
const end = checkCombatEnd(state); if (!checkEnd()) render(state, revealed);
if (end) {
state = {
...state,
combat: { ...state.combat, phase: "ended", result: end },
};
}
render(state);
return; return;
} }
render(state); render(state, revealed);
}); });
document.getElementById("enemy-zone").addEventListener("click", () => { document.getElementById("enemy-zone").addEventListener("click", () => {
if (!state) return;
if (state.combat.selectedCard === null) return; if (state.combat.selectedCard === null) return;
if (state.combat.phase !== "player_turn") return; if (state.combat.phase !== "player_turn") return;
const result = playCard(state, state.combat.selectedCard); const result = playCard(state, state.combat.selectedCard);
if (result === null) { if (result === null) {
state = { ...state, combat: { ...state.combat, selectedCard: null } }; state = { ...state, combat: { ...state.combat, selectedCard: null } };
render(state); render(state, revealed);
return; return;
} }
state = { ...result, combat: { ...result.combat, selectedCard: null } }; state = { ...result, combat: { ...result.combat, selectedCard: null } };
if (!checkEnd()) render(state, revealed);
const end = checkCombatEnd(state);
if (end) {
state = {
...state,
combat: { ...state.combat, phase: "ended", result: end },
};
render(state);
return;
}
render(state);
}); });
document document
.getElementById("end-turn-btn") .getElementById("end-turn-btn")
.addEventListener("click", async () => { .addEventListener("click", async () => {
if (!state) return;
if (state.combat.phase !== "player_turn") return; if (state.combat.phase !== "player_turn") return;
state = endTurn(state); state = endTurn(state);
render(state); render(state, revealed);
await delay(800); await delay(800);
state = resolveEnemyTurn(state); state = resolveEnemyTurn(state);
const end = checkCombatEnd(state); if (!checkEnd()) {
if (end) { state = startTurn(state);
state = { render(state, revealed);
...state, }
combat: { ...state.combat, phase: "ended", result: end }, });
};
render(state); document.getElementById("reward-cards").addEventListener("click", (e) => {
const img = e.target.closest(".reward-card");
if (!img || !revealed) return;
const cardId = img.dataset.cardId;
const index = revealed.indexOf(cardId);
if (index === -1) return;
run = pickReward(run, revealed, index);
showMapScreen();
});
document.getElementById("skip-btn").addEventListener("click", () => {
if (!revealed) return;
run = skipRewards(run, revealed);
showMapScreen();
});
document
.getElementById("map-proceed-btn")
.addEventListener("click", proceedFromMap);
document.getElementById("campfire-rest-btn").addEventListener("click", () => {
run = campfireRest(run);
run = { ...run, map: advanceMap(run.map) };
hideCampfire();
showMapScreen();
});
document
.getElementById("campfire-smith-btn")
.addEventListener("click", () => {
const upgradable = getUpgradableCards(run);
renderCampfire(run, upgradable);
});
document.getElementById("smith-cards").addEventListener("click", (e) => {
const btn = e.target.closest(".smith-card-btn");
if (!btn) return;
const cardId = btn.dataset.cardId;
run = campfireSmith(run, cardId);
run = { ...run, map: advanceMap(run.map) };
hideCampfire();
showMapScreen();
});
document.getElementById("overlay").addEventListener("click", (e) => {
if (e.target.closest("#reward-cards") || e.target.closest("#skip-btn"))
return;
// restart on defeat overlay click
if (state.combat.phase === "ended" && state.combat.result === "defeat") {
startNewRun();
return; return;
} }
// restart on act complete overlay click
state = startTurn(state); if (
render(state); state.combat.phase === "ended" &&
state.combat.result === "act_complete"
) {
startNewRun();
}
}); });
} }

58
src/map.js Normal file
View file

@ -0,0 +1,58 @@
const ENCOUNTER_POOL = [
"jaw_worm",
"cultist",
"fungi_beast",
"small_slime",
"red_louse",
"green_louse",
"blue_slaver",
];
const ELITE_POOL = ["lagavulin", "gremlin_nob", "sentry"];
const BOSS_POOL = ["slime_boss", "the_guardian"];
const ACT1_LAYOUT = [
"encounter",
"encounter",
"campfire",
"encounter",
"elite",
"encounter",
"campfire",
"encounter",
"elite",
"boss",
];
export function createMap() {
return {
nodes: ACT1_LAYOUT.map((type, id) => ({ id, type, cleared: false })),
currentNode: 0,
};
}
export function advanceMap(map) {
const last = map.nodes.length - 1;
const nodes = map.nodes.map((n, i) =>
i === map.currentNode ? { ...n, cleared: true } : n,
);
return {
...map,
nodes,
currentNode: Math.min(map.currentNode + 1, last),
};
}
export function getCurrentNode(map) {
return map.nodes[map.currentNode];
}
export function getNodeEnemy(nodeType) {
const pools = {
encounter: ENCOUNTER_POOL,
elite: ELITE_POOL,
boss: BOSS_POOL,
};
const pool = pools[nodeType];
if (!pool) return null;
return pool[Math.floor(Math.random() * pool.length)];
}

126
src/map.test.js Normal file
View file

@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test";
import { advanceMap, createMap, getCurrentNode, getNodeEnemy } from "./map.js";
describe("createMap", () => {
test("returns 10 nodes", () => {
const map = createMap();
expect(map.nodes).toHaveLength(10);
});
test("first node is encounter, last node is boss", () => {
const map = createMap();
expect(map.nodes[0].type).toBe("encounter");
expect(map.nodes[9].type).toBe("boss");
});
test("contains expected node type counts", () => {
const map = createMap();
const types = map.nodes.map((n) => n.type);
expect(types.filter((t) => t === "encounter")).toHaveLength(5);
expect(types.filter((t) => t === "campfire")).toHaveLength(2);
expect(types.filter((t) => t === "elite")).toHaveLength(2);
expect(types.filter((t) => t === "boss")).toHaveLength(1);
});
test("all nodes start cleared: false", () => {
const map = createMap();
expect(map.nodes.every((n) => n.cleared === false)).toBe(true);
});
test("each node has sequential id 0-9", () => {
const map = createMap();
map.nodes.forEach((n, i) => {
expect(n.id).toBe(i);
});
});
test("currentNode starts at 0", () => {
const map = createMap();
expect(map.currentNode).toBe(0);
});
});
describe("advanceMap", () => {
test("marks current node cleared and increments currentNode", () => {
const map = createMap();
const next = advanceMap(map);
expect(next.nodes[0].cleared).toBe(true);
expect(next.currentNode).toBe(1);
});
test("does not mutate the input map", () => {
const map = createMap();
advanceMap(map);
expect(map.nodes[0].cleared).toBe(false);
expect(map.currentNode).toBe(0);
});
test("does not go past last node (index 9)", () => {
let map = createMap();
for (let i = 0; i < 10; i++) {
map = advanceMap(map);
}
expect(map.currentNode).toBe(9);
});
});
describe("getCurrentNode", () => {
test("returns the node at currentNode index", () => {
const map = createMap();
const node = getCurrentNode(map);
expect(node).toBe(map.nodes[map.currentNode]);
expect(node.id).toBe(0);
expect(node.type).toBe("encounter");
});
test("returns correct node after advancing", () => {
const map = advanceMap(createMap());
const node = getCurrentNode(map);
expect(node.id).toBe(1);
});
});
const ENCOUNTER_POOL = [
"jaw_worm",
"cultist",
"fungi_beast",
"small_slime",
"red_louse",
"green_louse",
"blue_slaver",
];
const ELITE_POOL = ["lagavulin", "gremlin_nob", "sentry"];
const BOSS_POOL = ["slime_boss", "the_guardian"];
describe("getNodeEnemy", () => {
test("encounter returns an id from the encounter pool", () => {
const enemy = getNodeEnemy("encounter");
expect(ENCOUNTER_POOL).toContain(enemy);
});
test("elite returns an id from the elite pool", () => {
const enemy = getNodeEnemy("elite");
expect(ELITE_POOL).toContain(enemy);
});
test("boss returns an id from the boss pool", () => {
const enemy = getNodeEnemy("boss");
expect(BOSS_POOL).toContain(enemy);
});
test("campfire returns null", () => {
expect(getNodeEnemy("campfire")).toBeNull();
});
test("unknown type returns null", () => {
expect(getNodeEnemy("shop")).toBeNull();
});
test("encounter pool has randomness across 50 calls", () => {
const results = new Set();
for (let i = 0; i < 50; i++) {
results.add(getNodeEnemy("encounter"));
}
expect(results.size).toBeGreaterThan(1);
});
});

View file

@ -1,11 +1,11 @@
import { getCard } from "./cards.js"; import { getCard } from "./cards.js";
import { resolveEnemyAction } from "./enemies.js"; import { resolveEnemyAction } from "./enemies.js";
export function render(state) { export function render(state, revealed) {
renderEnemy(state); renderEnemy(state);
renderInfoBar(state); renderInfoBar(state);
renderHand(state); renderHand(state);
renderOverlay(state); renderOverlay(state, revealed);
} }
function renderEnemy(state) { function renderEnemy(state) {
@ -23,6 +23,7 @@ function renderEnemy(state) {
if (enemy.vulnerable > 0) tokens.push(`vuln ${enemy.vulnerable}`); if (enemy.vulnerable > 0) tokens.push(`vuln ${enemy.vulnerable}`);
if (enemy.weak > 0) tokens.push(`weak ${enemy.weak}`); if (enemy.weak > 0) tokens.push(`weak ${enemy.weak}`);
if (enemy.strength > 0) tokens.push(`str ${enemy.strength}`); if (enemy.strength > 0) tokens.push(`str ${enemy.strength}`);
if (enemy.poison > 0) tokens.push(`psn ${enemy.poison}`);
statusEl.textContent = tokens.join(" | "); statusEl.textContent = tokens.join(" | ");
const intentEl = document.getElementById("enemy-intent"); const intentEl = document.getElementById("enemy-intent");
@ -51,6 +52,7 @@ function formatIntent(action, enemy) {
if (e.type === "strength") return `str +${e.value}`; if (e.type === "strength") return `str +${e.value}`;
return e.type; return e.type;
}); });
if (parts.length === 0) return "zzz";
return parts.join(", "); return parts.join(", ");
} }
@ -103,16 +105,104 @@ function renderHand(state) {
}); });
} }
function renderOverlay(state) { export function renderMap(map) {
const overlay = document.getElementById("overlay"); const container = document.getElementById("map-nodes");
const result = state.combat.phase === "ended" ? state.combat.result : null; container.innerHTML = "";
if (result === "victory") {
overlay.hidden = false; map.nodes.forEach((node, index) => {
overlay.textContent = "victory"; const div = document.createElement("div");
} else if (result === "defeat") { let label = node.type;
overlay.hidden = false; if (index === map.currentNode) label = `> ${label}`;
overlay.textContent = "defeat"; if (node.cleared) label = `${label}`;
if (index === map.currentNode) {
const b = document.createElement("b");
b.textContent = label;
div.appendChild(b);
} else { } else {
overlay.hidden = true; div.textContent = label;
}
container.appendChild(div);
});
document.getElementById("map-screen").hidden = false;
document.getElementById("game").hidden = true;
document.getElementById("overlay").hidden = true;
document.getElementById("campfire-screen").hidden = true;
}
export function showGame() {
document.getElementById("game").hidden = false;
document.getElementById("map-screen").hidden = true;
document.getElementById("campfire-screen").hidden = true;
}
export function renderCampfire(run, upgradableCards) {
document.getElementById("game").hidden = true;
document.getElementById("map-screen").hidden = true;
document.getElementById("overlay").hidden = true;
document.getElementById("campfire-screen").hidden = false;
document.getElementById("campfire-hp").textContent =
`HP: ${run.hp}/${run.maxHp}`;
document.getElementById("smith-cards").hidden = true;
if (upgradableCards) {
document.getElementById("campfire-choices").hidden = true;
const smithEl = document.getElementById("smith-cards");
smithEl.hidden = false;
smithEl.innerHTML = "";
for (const id of upgradableCards) {
const card = getCard(id);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "smith-card-btn";
btn.textContent = card.name;
btn.dataset.cardId = id;
smithEl.appendChild(btn);
}
} else {
document.getElementById("campfire-choices").hidden = false;
}
}
export function hideCampfire() {
document.getElementById("campfire-screen").hidden = true;
}
function renderOverlay(state, revealed) {
const overlay = document.getElementById("overlay");
const overlayText = document.getElementById("overlay-text");
const rewardCards = document.getElementById("reward-cards");
const skipBtn = document.getElementById("skip-btn");
if (state.combat.phase === "rewards" && revealed) {
overlay.hidden = false;
overlayText.textContent = "card reward";
rewardCards.innerHTML = "";
for (const cardId of revealed) {
const card = getCard(cardId);
const img = document.createElement("img");
img.src = card.image || "";
img.alt = card.name;
img.title = `${card.name} (${card.cost}) - ${card.description}`;
img.dataset.cardId = cardId;
img.className = "reward-card";
rewardCards.appendChild(img);
}
skipBtn.hidden = false;
} else if (state.combat.phase === "ended") {
overlay.hidden = false;
if (state.combat.result === "defeat") {
overlayText.textContent = "defeat — click to restart";
} else if (state.combat.result === "act_complete") {
overlayText.textContent = "act 1 complete! — click to start new run";
} else {
overlayText.textContent = "victory";
}
rewardCards.innerHTML = "";
skipBtn.hidden = true;
} else {
overlay.hidden = true;
rewardCards.innerHTML = "";
skipBtn.hidden = true;
} }
} }

86
src/run.js Normal file
View file

@ -0,0 +1,86 @@
import { getAllCards, getCard, getStarterDeck } from "./cards.js";
import { createMap } from "./map.js";
import { shuffle } from "./state.js";
export function createRunState(character) {
return {
character,
hp: 11,
maxHp: 11,
deck: [...getStarterDeck(character)],
cardRewardsDeck: buildRewardsDeck(character),
potions: [],
combatCount: 0,
map: createMap(),
};
}
export function endCombat(run, combatHp) {
let hp = combatHp;
if (run.character === "ironclad") {
hp = Math.min(hp + 1, run.maxHp);
}
return { ...run, hp, combatCount: run.combatCount + 1 };
}
export function campfireRest(run) {
return { ...run, hp: Math.min(run.hp + 3, run.maxHp) };
}
export function campfireSmith(run, cardId) {
const card = getCard(cardId);
if (!card?.upgraded) return run;
const idx = run.deck.indexOf(cardId);
if (idx === -1) return run;
const deck = [...run.deck];
deck[idx] = card.upgraded;
return { ...run, deck };
}
export function getUpgradableCards(run) {
const seen = new Set();
const result = [];
for (const id of run.deck) {
if (seen.has(id)) continue;
seen.add(id);
if (id.endsWith("+")) continue;
const card = getCard(id);
if (card?.upgraded) result.push(id);
}
return result;
}
export function revealRewards(run) {
const count = Math.min(3, run.cardRewardsDeck.length);
const revealed = run.cardRewardsDeck.slice(0, count);
const cardRewardsDeck = run.cardRewardsDeck.slice(count);
return { revealed, run: { ...run, cardRewardsDeck } };
}
export function pickReward(run, revealed, index) {
return {
...run,
deck: [...run.deck, revealed[index]],
};
}
export function skipRewards(run, revealed) {
return {
...run,
cardRewardsDeck: shuffle([...run.cardRewardsDeck, ...revealed]),
};
}
function buildRewardsDeck(character) {
const ids = [];
for (const card of getAllCards()) {
if (
card.character === character &&
(card.rarity === "common" || card.rarity === "uncommon") &&
!card.id.endsWith("+")
) {
ids.push(card.id);
}
}
return shuffle(ids);
}

213
src/run.test.js Normal file
View file

@ -0,0 +1,213 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { getAllCards, initCards } from "./cards.js";
import { initEnemies } from "./enemies.js";
import {
campfireRest,
campfireSmith,
createRunState,
endCombat,
getUpgradableCards,
pickReward,
revealRewards,
skipRewards,
} from "./run.js";
beforeAll(async () => {
await Promise.all([initCards(), initEnemies()]);
});
describe("createRunState", () => {
test("initializes ironclad with correct defaults", () => {
const run = createRunState("ironclad");
expect(run.character).toBe("ironclad");
expect(run.hp).toBe(11);
expect(run.maxHp).toBe(11);
expect(run.deck).toEqual([
"strike_r",
"strike_r",
"strike_r",
"strike_r",
"strike_r",
"defend_r",
"defend_r",
"defend_r",
"defend_r",
"bash",
]);
expect(run.combatCount).toBe(0);
expect(run.potions).toEqual([]);
});
test("rewards deck has more than 20 cards, excludes starters and rares and upgraded", () => {
const run = createRunState("ironclad");
expect(run.cardRewardsDeck.length).toBeGreaterThan(20);
for (const cardId of run.cardRewardsDeck) {
const card = getAllCards().find((c) => c.id === cardId);
expect(card).toBeDefined();
expect(card.rarity).not.toBe("starter");
expect(card.rarity).not.toBe("rare");
expect(cardId.endsWith("+")).toBe(false);
}
});
test("rewards deck is shuffled", () => {
const run = createRunState("ironclad");
const sorted = [...run.cardRewardsDeck].sort();
// at least one card should be out of sorted order
const allSorted = run.cardRewardsDeck.every((id, i) => id === sorted[i]);
expect(allSorted).toBe(false);
});
});
describe("endCombat", () => {
test("ironclad heals 1 HP after combat", () => {
const run = { ...createRunState("ironclad"), hp: 8 };
const updated = endCombat(run, 8);
expect(updated.hp).toBe(9);
expect(updated.combatCount).toBe(1);
});
test("ironclad heal does not exceed maxHp", () => {
const run = { ...createRunState("ironclad"), hp: 11, maxHp: 11 };
const updated = endCombat(run, 11);
expect(updated.hp).toBe(11);
});
test("non-ironclad characters do not heal", () => {
const run = {
character: "silent",
hp: 8,
maxHp: 13,
combatCount: 0,
};
const updated = endCombat(run, 8);
expect(updated.hp).toBe(8);
});
test("combatCount increments", () => {
const run = createRunState("ironclad");
const after1 = endCombat(run, run.hp);
const after2 = endCombat(after1, after1.hp);
expect(after2.combatCount).toBe(2);
});
});
describe("campfireRest", () => {
test("campfireRest heals 3 HP", () => {
const run = { hp: 5, maxHp: 11 };
const result = campfireRest(run);
expect(result.hp).toBe(8);
});
test("campfireRest does not heal above maxHp", () => {
const run = { hp: 10, maxHp: 11 };
const result = campfireRest(run);
expect(result.hp).toBe(11);
});
});
describe("campfireSmith", () => {
test("replaces first occurrence of base card with upgraded version", () => {
const run = { deck: ["strike_r", "strike_r", "defend_r"] };
const result = campfireSmith(run, "strike_r");
expect(result.deck).toEqual(["strike_r+", "strike_r", "defend_r"]);
});
test("does nothing if card not in deck", () => {
const run = { deck: ["defend_r", "bash"] };
const result = campfireSmith(run, "strike_r");
expect(result.deck).toEqual(["defend_r", "bash"]);
});
test("does nothing if card has no upgrade", () => {
const run = { deck: ["bash+", "strike_r"] };
const result = campfireSmith(run, "bash+");
expect(result.deck).toEqual(["bash+", "strike_r"]);
});
test("smiths strike_r from full ironclad starter deck", () => {
const run = createRunState("ironclad");
const originalCount = run.deck.filter((id) => id === "strike_r").length;
const result = campfireSmith(run, "strike_r");
expect(result.deck).toContain("strike_r+");
expect(result.deck.filter((id) => id === "strike_r")).toHaveLength(
originalCount - 1,
);
expect(result.deck).toHaveLength(run.deck.length);
});
});
describe("getUpgradableCards", () => {
test("returns unique base cards that have upgrades", () => {
const run = { deck: ["strike_r", "strike_r", "bash", "defend_r"] };
const result = getUpgradableCards(run);
expect(result).toContain("strike_r");
expect(result).toContain("bash");
expect(result).toContain("defend_r");
// duplicates deduplicated
expect(result.filter((id) => id === "strike_r")).toHaveLength(1);
});
test("excludes already-upgraded cards", () => {
const run = { deck: ["strike_r+", "bash+", "defend_r"] };
const result = getUpgradableCards(run);
expect(result).not.toContain("strike_r+");
expect(result).not.toContain("bash+");
expect(result).toContain("defend_r");
});
});
describe("rewards", () => {
test("revealRewards removes 3 from rewards deck and returns them", () => {
const run = createRunState("ironclad");
const originalLen = run.cardRewardsDeck.length;
const { revealed, run: updated } = revealRewards(run);
expect(revealed).toHaveLength(3);
expect(updated.cardRewardsDeck).toHaveLength(originalLen - 3);
});
test("revealRewards handles deck with fewer than 3 cards", () => {
const run = createRunState("ironclad");
const modified = { ...run, cardRewardsDeck: ["card_a", "card_b"] };
const { revealed, run: updated } = revealRewards(modified);
expect(revealed).toHaveLength(2);
expect(updated.cardRewardsDeck).toHaveLength(0);
});
test("revealRewards with empty deck returns empty revealed", () => {
const run = { ...createRunState("ironclad"), cardRewardsDeck: [] };
const { revealed, run: updated } = revealRewards(run);
expect(revealed).toHaveLength(0);
expect(updated.cardRewardsDeck).toHaveLength(0);
});
test("pickReward adds picked card to deck", () => {
const run = createRunState("ironclad");
const originalDeckLen = run.deck.length;
const { revealed, run: afterReveal } = revealRewards(run);
const updated = pickReward(afterReveal, revealed, 0);
expect(updated.deck).toHaveLength(originalDeckLen + 1);
expect(updated.deck).toContain(revealed[0]);
});
test("pickReward discards unpicked cards", () => {
const run = createRunState("ironclad");
const { revealed, run: afterReveal } = revealRewards(run);
const rewardsDeckLen = afterReveal.cardRewardsDeck.length;
const updated = pickReward(afterReveal, revealed, 1);
// unpicked cards are gone, not returned to rewards deck
expect(updated.cardRewardsDeck).toHaveLength(rewardsDeckLen);
expect(updated.deck).not.toContain(revealed[0]);
expect(updated.deck).toContain(revealed[1]);
expect(updated.deck).not.toContain(revealed[2]);
});
test("skipRewards reshuffles all 3 back into rewards deck", () => {
const run = createRunState("ironclad");
const { revealed, run: afterReveal } = revealRewards(run);
const rewardsDeckLen = afterReveal.cardRewardsDeck.length;
const updated = skipRewards(afterReveal, revealed);
expect(updated.cardRewardsDeck).toHaveLength(rewardsDeckLen + 3);
expect(updated.deck).toHaveLength(run.deck.length); // deck unchanged
});
});

View file

@ -14,6 +14,7 @@ function makePlayer(character, index) {
strength: 0, strength: 0,
vulnerable: 0, vulnerable: 0,
weak: 0, weak: 0,
poison: 0,
drawPile: shuffle([...getStarterDeck(character)]), drawPile: shuffle([...getStarterDeck(character)]),
hand: [], hand: [],
discardPile: [], discardPile: [],
@ -34,6 +35,7 @@ function makeEnemy(enemyId, index) {
strength: 0, strength: 0,
vulnerable: 0, vulnerable: 0,
weak: 0, weak: 0,
poison: 0,
actionType: enemy.actionType, actionType: enemy.actionType,
actions: enemy.actions, actions: enemy.actions,
actionTrack: enemy.actionTrack || null, actionTrack: enemy.actionTrack || null,
@ -73,6 +75,49 @@ export function createCombatState(characterOrChars, enemyIdOrIds) {
return state; return state;
} }
export function createCombatFromRun(run, enemyIdOrIds) {
const enemyIds = Array.isArray(enemyIdOrIds) ? enemyIdOrIds : [enemyIdOrIds];
const enemies = enemyIds.map((id, i) => makeEnemy(id, i));
const player = {
id: "player_0",
character: run.character,
hp: run.hp,
maxHp: run.maxHp,
energy: 3,
maxEnergy: 3,
block: 0,
strength: 0,
vulnerable: 0,
weak: 0,
poison: 0,
drawPile: shuffle([...run.deck]),
hand: [],
discardPile: [],
exhaustPile: [],
powers: [],
};
const state = {
players: [player],
enemies,
combat: {
turn: 1,
phase: "player_turn",
dieResult: null,
selectedCard: null,
log: [],
playerCount: 1,
activePlayerIndex: null,
playersReady: [],
},
};
state.player = player;
state.enemy = enemies[0];
return state;
}
export function drawCards(state, playerIndexOrCount, count) { export function drawCards(state, playerIndexOrCount, count) {
// drawCards(state, count) — old single-player form // drawCards(state, count) — old single-player form
// drawCards(state, playerIndex, count) — new indexed form // drawCards(state, playerIndex, count) — new indexed form
@ -120,14 +165,21 @@ export function playCard(state, handIndex, opts = {}) {
const player = state.players[playerIndex]; const player = state.players[playerIndex];
const cardId = player.hand[handIndex]; const cardId = player.hand[handIndex];
const card = getCard(cardId); const card = getCard(cardId);
if (card.keywords?.includes("unplayable")) return null;
if (player.energy < card.cost) return null; if (player.energy < card.cost) return null;
const hand = [...player.hand]; const hand = [...player.hand];
hand.splice(handIndex, 1); hand.splice(handIndex, 1);
const discardPile = [...player.discardPile, cardId]; const exhausted = card.keywords?.includes("exhaust");
const discardPile = exhausted
? [...player.discardPile]
: [...player.discardPile, cardId];
const exhaustPile = exhausted
? [...player.exhaustPile, cardId]
: [...player.exhaustPile];
const energy = player.energy - card.cost; const energy = player.energy - card.cost;
const updatedPlayer = { ...player, hand, discardPile, energy }; const updatedPlayer = { ...player, hand, discardPile, exhaustPile, energy };
const players = state.players.map((p, i) => const players = state.players.map((p, i) =>
i === playerIndex ? updatedPlayer : p, i === playerIndex ? updatedPlayer : p,
); );
@ -152,10 +204,24 @@ export function endTurn(state, playerIndex) {
// endTurn(state, playerIndex) — new form: marks one player done // endTurn(state, playerIndex) — new form: marks one player done
if (playerIndex === undefined) { if (playerIndex === undefined) {
const player = state.players[0]; const player = state.players[0];
const retained = [];
const exhausted = [];
const discarded = [];
for (const cardId of player.hand) {
const card = getCard(cardId);
if (card.keywords?.includes("retain")) {
retained.push(cardId);
} else if (card.keywords?.includes("ethereal")) {
exhausted.push(cardId);
} else {
discarded.push(cardId);
}
}
const updatedPlayer = { const updatedPlayer = {
...player, ...player,
hand: [], hand: retained,
discardPile: [...player.discardPile, ...player.hand], discardPile: [...player.discardPile, ...discarded],
exhaustPile: [...player.exhaustPile, ...exhausted],
}; };
const players = state.players.map((p, i) => (i === 0 ? updatedPlayer : p)); const players = state.players.map((p, i) => (i === 0 ? updatedPlayer : p));
return { return {
@ -169,10 +235,24 @@ export function endTurn(state, playerIndex) {
if (state.combat.playersReady.includes(playerIndex)) return state; if (state.combat.playersReady.includes(playerIndex)) return state;
const player = state.players[playerIndex]; const player = state.players[playerIndex];
const retained = [];
const exhausted = [];
const discarded = [];
for (const cardId of player.hand) {
const card = getCard(cardId);
if (card.keywords?.includes("retain")) {
retained.push(cardId);
} else if (card.keywords?.includes("ethereal")) {
exhausted.push(cardId);
} else {
discarded.push(cardId);
}
}
const updatedPlayer = { const updatedPlayer = {
...player, ...player,
hand: [], hand: retained,
discardPile: [...player.discardPile, ...player.hand], discardPile: [...player.discardPile, ...discarded],
exhaustPile: [...player.exhaustPile, ...exhausted],
}; };
const players = state.players.map((p, i) => const players = state.players.map((p, i) =>
i === playerIndex ? updatedPlayer : p, i === playerIndex ? updatedPlayer : p,
@ -192,7 +272,7 @@ export function endTurn(state, playerIndex) {
}; };
} }
function shuffle(arr) { export 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--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));

View file

@ -1,7 +1,14 @@
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { initCards } from "./cards.js"; import { initCards } from "./cards.js";
import { initEnemies } from "./enemies.js"; import { initEnemies } from "./enemies.js";
import { createCombatState, drawCards, endTurn, playCard } from "./state.js"; import { createRunState } from "./run.js";
import {
createCombatFromRun,
createCombatState,
drawCards,
endTurn,
playCard,
} from "./state.js";
beforeAll(async () => { beforeAll(async () => {
await Promise.all([initCards(), initEnemies()]); await Promise.all([initCards(), initEnemies()]);
@ -278,3 +285,104 @@ describe("backward compat - single string args", () => {
expect(next.player.hand).toHaveLength(0); expect(next.player.hand).toHaveLength(0);
}); });
}); });
describe("createCombatFromRun", () => {
test("uses run deck for draw pile", () => {
const run = createRunState("ironclad");
// simulate picking a reward card
const modified = { ...run, deck: [...run.deck, "pommel_strike"] };
const state = createCombatFromRun(modified, "jaw_worm");
const allCards = [
...state.players[0].drawPile,
...state.players[0].hand,
...state.players[0].discardPile,
];
expect(allCards).toHaveLength(11);
expect(allCards.filter((id) => id === "pommel_strike")).toHaveLength(1);
});
test("uses run HP instead of max", () => {
const run = createRunState("ironclad");
run.hp = 7;
const state = createCombatFromRun(run, "jaw_worm");
expect(state.players[0].hp).toBe(7);
expect(state.players[0].maxHp).toBe(11);
});
test("backward-compat aliases work", () => {
const run = createRunState("ironclad");
const state = createCombatFromRun(run, "jaw_worm");
expect(state.player).toBe(state.players[0]);
expect(state.enemy).toBe(state.enemies[0]);
});
});
describe("playCard - exhaust", () => {
test("exhaust card goes to exhaustPile instead of discardPile", () => {
let state = createCombatState("ironclad", "jaw_worm");
state = drawCards(state, 5);
// manually put true_grit in hand
const player = {
...state.player,
hand: [...state.player.hand, "true_grit"],
};
state = { ...state, player, players: [player] };
const handIndex = state.player.hand.indexOf("true_grit");
const discardBefore = [...state.player.discardPile];
const next = playCard(state, handIndex);
expect(next.player.hand).toHaveLength(state.player.hand.length - 1);
expect(next.player.exhaustPile).toContain("true_grit");
expect(next.player.discardPile).toEqual(discardBefore);
expect(next.player.energy).toBe(state.player.energy - 1);
});
});
describe("endTurn - ethereal", () => {
test("ethereal cards in hand exhaust at end of turn", () => {
let state = createCombatState("ironclad", "jaw_worm");
const player = {
...state.player,
hand: ["strike_r", "carnage", "defend_r"],
};
state = { ...state, player, players: [player] };
const next = endTurn(state);
expect(next.player.exhaustPile).toContain("carnage");
expect(next.player.discardPile).toContain("strike_r");
expect(next.player.discardPile).toContain("defend_r");
expect(next.player.hand).toHaveLength(0);
});
});
describe("endTurn - retain", () => {
test("retained cards stay in hand at end of turn", () => {
let state = createCombatState("ironclad", "jaw_worm");
const player = {
...state.player,
hand: ["strike_r", "equilibrium", "defend_r"],
};
state = { ...state, player, players: [player] };
const next = endTurn(state);
expect(next.player.hand).toContain("equilibrium");
expect(next.player.discardPile).toContain("strike_r");
expect(next.player.discardPile).toContain("defend_r");
expect(next.player.hand).toHaveLength(1);
});
});
describe("playCard - unplayable", () => {
test("unplayable card cannot be played", () => {
let state = createCombatState("ironclad", "jaw_worm");
state = drawCards(state, 5);
// manually put tactician in hand
const player = {
...state.player,
hand: [...state.player.hand, "tactician"],
};
state = { ...state, player, players: [player] };
const handIndex = state.player.hand.indexOf("tactician");
const handBefore = [...state.player.hand];
const result = playCard(state, handIndex);
expect(result).toBeNull();
expect(state.player.hand).toEqual(handBefore);
});
});

View file

@ -12,6 +12,10 @@
margin: 0 auto; margin: 0 auto;
} }
#game[hidden] {
display: none;
}
#enemy-zone { #enemy-zone {
flex: 4; flex: 4;
display: flex; display: flex;
@ -56,8 +60,10 @@
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16px;
color: white; color: white;
font-size: 2rem; font-size: 2rem;
} }
@ -65,3 +71,19 @@
#overlay[hidden] { #overlay[hidden] {
display: none; display: none;
} }
#reward-cards {
display: flex;
gap: 12px;
justify-content: center;
}
#reward-cards .reward-card {
width: 120px;
cursor: pointer;
transition: transform 0.15s;
}
#reward-cards .reward-card:hover {
transform: scale(1.1);
}