Compare commits
No commits in common. "90c2aca72ed18882ef20d90bf39624198c1abcef" and "970796f85af1d56a3118ef285feec9d529e3c7b0" have entirely different histories.
90c2aca72e
...
970796f85a
18 changed files with 63 additions and 3391 deletions
File diff suppressed because it is too large
Load diff
24
index.html
24
index.html
|
|
@ -7,7 +7,7 @@
|
|||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="game" hidden>
|
||||
<div id="game">
|
||||
<section id="enemy-zone">
|
||||
<div id="enemy-info">
|
||||
<span id="enemy-name"></span>
|
||||
|
|
@ -34,27 +34,7 @@
|
|||
<section id="hand"></section>
|
||||
</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>
|
||||
<div id="overlay" hidden></div>
|
||||
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,3 @@ export function getStarterDeck(character) {
|
|||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getAllCards() {
|
||||
return Object.values(cardDb);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { getAllCards, getCard, getStarterDeck, initCards } from "./cards.js";
|
||||
import { getCard, getStarterDeck, initCards } from "./cards.js";
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCards();
|
||||
|
|
@ -26,18 +26,3 @@ describe("cards", () => {
|
|||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,24 +38,11 @@ export function startTurn(state) {
|
|||
export function resolveEnemyTurn(state) {
|
||||
// reset block on all enemies before they act
|
||||
const enemies = state.enemies.map((e) => ({ ...e, block: 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] };
|
||||
let next = { ...state, enemies, enemy: enemies[0] };
|
||||
|
||||
// each enemy acts; enemies target player 0 by default
|
||||
for (let i = 0; i < next.enemies.length; i++) {
|
||||
const enemy = next.enemies[i];
|
||||
if (enemy.hp <= 0) continue; // dead enemies don't act
|
||||
const action = resolveEnemyAction(
|
||||
enemy,
|
||||
next.combat.dieResult,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { initCards } from "./cards.js";
|
||||
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
|
||||
import { resolveEffects } from "./effects.js";
|
||||
import { initEnemies } from "./enemies.js";
|
||||
import { createCombatState } from "./state.js";
|
||||
|
||||
|
|
@ -145,112 +144,3 @@ describe("checkCombatEnd - multiplayer", () => {
|
|||
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
2
src/effects.js
vendored
|
|
@ -71,8 +71,6 @@ function resolveSingleEffect(state, effect, source, target) {
|
|||
return applyStatus(state, target, "vulnerable", effect.value, 3);
|
||||
case "weak":
|
||||
return applyStatus(state, target, "weak", effect.value, 3);
|
||||
case "poison":
|
||||
return applyStatus(state, target, "poison", effect.value, 30);
|
||||
case "strength":
|
||||
return applyStatus(state, source, "strength", effect.value, 8);
|
||||
case "draw": {
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
217
src/main.js
217
src/main.js
|
|
@ -1,124 +1,28 @@
|
|||
import { getCard, initCards } from "./cards.js";
|
||||
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
|
||||
import { initEnemies } from "./enemies.js";
|
||||
import { advanceMap, getCurrentNode, getNodeEnemy } from "./map.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";
|
||||
import { render } from "./render.js";
|
||||
import { createCombatState, endTurn, playCard } from "./state.js";
|
||||
|
||||
let state = null;
|
||||
let run = null;
|
||||
let revealed = null;
|
||||
|
||||
async function init() {
|
||||
await Promise.all([initCards(), initEnemies()]);
|
||||
startNewRun();
|
||||
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 = createCombatState("ironclad", "jaw_worm");
|
||||
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;
|
||||
render(state);
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.getElementById("hand").addEventListener("click", (e) => {
|
||||
const cardEl = e.target.closest(".card");
|
||||
if (!state) return;
|
||||
if (!cardEl || state.combat.phase !== "player_turn") return;
|
||||
const index = Number(cardEl.dataset.index);
|
||||
|
||||
if (state.combat.selectedCard === index) {
|
||||
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
||||
render(state, revealed);
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -128,114 +32,79 @@ function bindEvents() {
|
|||
const card = getCard(cardId);
|
||||
|
||||
if (card.type === "skill") {
|
||||
// auto-play skills (they target self)
|
||||
const result = playCard(state, index);
|
||||
if (result === null) {
|
||||
// not enough energy
|
||||
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
||||
render(state, revealed);
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
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, revealed);
|
||||
render(state);
|
||||
});
|
||||
|
||||
document.getElementById("enemy-zone").addEventListener("click", () => {
|
||||
if (!state) return;
|
||||
if (state.combat.selectedCard === null) return;
|
||||
if (state.combat.phase !== "player_turn") return;
|
||||
|
||||
const result = playCard(state, state.combat.selectedCard);
|
||||
if (result === null) {
|
||||
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
||||
render(state, revealed);
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
.getElementById("end-turn-btn")
|
||||
.addEventListener("click", async () => {
|
||||
if (!state) return;
|
||||
if (state.combat.phase !== "player_turn") return;
|
||||
|
||||
state = endTurn(state);
|
||||
render(state, revealed);
|
||||
render(state);
|
||||
|
||||
await delay(800);
|
||||
state = resolveEnemyTurn(state);
|
||||
|
||||
if (!checkEnd()) {
|
||||
state = startTurn(state);
|
||||
render(state, revealed);
|
||||
const end = checkCombatEnd(state);
|
||||
if (end) {
|
||||
state = {
|
||||
...state,
|
||||
combat: { ...state.combat, phase: "ended", result: end },
|
||||
};
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
|
||||
state = startTurn(state);
|
||||
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;
|
||||
}
|
||||
// restart on act complete overlay click
|
||||
if (
|
||||
state.combat.phase === "ended" &&
|
||||
state.combat.result === "act_complete"
|
||||
) {
|
||||
startNewRun();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
|
|
|
|||
58
src/map.js
58
src/map.js
|
|
@ -1,58 +0,0 @@
|
|||
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
126
src/map.test.js
|
|
@ -1,126 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
106
src/render.js
106
src/render.js
|
|
@ -1,11 +1,11 @@
|
|||
import { getCard } from "./cards.js";
|
||||
import { resolveEnemyAction } from "./enemies.js";
|
||||
|
||||
export function render(state, revealed) {
|
||||
export function render(state) {
|
||||
renderEnemy(state);
|
||||
renderInfoBar(state);
|
||||
renderHand(state);
|
||||
renderOverlay(state, revealed);
|
||||
renderOverlay(state);
|
||||
}
|
||||
|
||||
function renderEnemy(state) {
|
||||
|
|
@ -23,7 +23,6 @@ function renderEnemy(state) {
|
|||
if (enemy.vulnerable > 0) tokens.push(`vuln ${enemy.vulnerable}`);
|
||||
if (enemy.weak > 0) tokens.push(`weak ${enemy.weak}`);
|
||||
if (enemy.strength > 0) tokens.push(`str ${enemy.strength}`);
|
||||
if (enemy.poison > 0) tokens.push(`psn ${enemy.poison}`);
|
||||
statusEl.textContent = tokens.join(" | ");
|
||||
|
||||
const intentEl = document.getElementById("enemy-intent");
|
||||
|
|
@ -52,7 +51,6 @@ function formatIntent(action, enemy) {
|
|||
if (e.type === "strength") return `str +${e.value}`;
|
||||
return e.type;
|
||||
});
|
||||
if (parts.length === 0) return "zzz";
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
|
|
@ -105,104 +103,16 @@ function renderHand(state) {
|
|||
});
|
||||
}
|
||||
|
||||
export function renderMap(map) {
|
||||
const container = document.getElementById("map-nodes");
|
||||
container.innerHTML = "";
|
||||
|
||||
map.nodes.forEach((node, index) => {
|
||||
const div = document.createElement("div");
|
||||
let label = node.type;
|
||||
if (index === map.currentNode) label = `> ${label}`;
|
||||
if (node.cleared) label = `${label} ✓`;
|
||||
if (index === map.currentNode) {
|
||||
const b = document.createElement("b");
|
||||
b.textContent = label;
|
||||
div.appendChild(b);
|
||||
} else {
|
||||
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) {
|
||||
function renderOverlay(state) {
|
||||
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) {
|
||||
const result = state.combat.phase === "ended" ? state.combat.result : null;
|
||||
if (result === "victory") {
|
||||
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.textContent = "victory";
|
||||
} else if (result === "defeat") {
|
||||
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;
|
||||
overlay.textContent = "defeat";
|
||||
} else {
|
||||
overlay.hidden = true;
|
||||
rewardCards.innerHTML = "";
|
||||
skipBtn.hidden = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
src/run.js
86
src/run.js
|
|
@ -1,86 +0,0 @@
|
|||
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
213
src/run.test.js
|
|
@ -1,213 +0,0 @@
|
|||
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
|
||||
});
|
||||
});
|
||||
94
src/state.js
94
src/state.js
|
|
@ -14,7 +14,6 @@ function makePlayer(character, index) {
|
|||
strength: 0,
|
||||
vulnerable: 0,
|
||||
weak: 0,
|
||||
poison: 0,
|
||||
drawPile: shuffle([...getStarterDeck(character)]),
|
||||
hand: [],
|
||||
discardPile: [],
|
||||
|
|
@ -35,7 +34,6 @@ function makeEnemy(enemyId, index) {
|
|||
strength: 0,
|
||||
vulnerable: 0,
|
||||
weak: 0,
|
||||
poison: 0,
|
||||
actionType: enemy.actionType,
|
||||
actions: enemy.actions,
|
||||
actionTrack: enemy.actionTrack || null,
|
||||
|
|
@ -75,49 +73,6 @@ export function createCombatState(characterOrChars, enemyIdOrIds) {
|
|||
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) {
|
||||
// drawCards(state, count) — old single-player form
|
||||
// drawCards(state, playerIndex, count) — new indexed form
|
||||
|
|
@ -165,21 +120,14 @@ export function playCard(state, handIndex, opts = {}) {
|
|||
const player = state.players[playerIndex];
|
||||
const cardId = player.hand[handIndex];
|
||||
const card = getCard(cardId);
|
||||
if (card.keywords?.includes("unplayable")) return null;
|
||||
if (player.energy < card.cost) return null;
|
||||
|
||||
const hand = [...player.hand];
|
||||
hand.splice(handIndex, 1);
|
||||
const exhausted = card.keywords?.includes("exhaust");
|
||||
const discardPile = exhausted
|
||||
? [...player.discardPile]
|
||||
: [...player.discardPile, cardId];
|
||||
const exhaustPile = exhausted
|
||||
? [...player.exhaustPile, cardId]
|
||||
: [...player.exhaustPile];
|
||||
const discardPile = [...player.discardPile, cardId];
|
||||
const energy = player.energy - card.cost;
|
||||
|
||||
const updatedPlayer = { ...player, hand, discardPile, exhaustPile, energy };
|
||||
const updatedPlayer = { ...player, hand, discardPile, energy };
|
||||
const players = state.players.map((p, i) =>
|
||||
i === playerIndex ? updatedPlayer : p,
|
||||
);
|
||||
|
|
@ -204,24 +152,10 @@ export function endTurn(state, playerIndex) {
|
|||
// endTurn(state, playerIndex) — new form: marks one player done
|
||||
if (playerIndex === undefined) {
|
||||
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 = {
|
||||
...player,
|
||||
hand: retained,
|
||||
discardPile: [...player.discardPile, ...discarded],
|
||||
exhaustPile: [...player.exhaustPile, ...exhausted],
|
||||
hand: [],
|
||||
discardPile: [...player.discardPile, ...player.hand],
|
||||
};
|
||||
const players = state.players.map((p, i) => (i === 0 ? updatedPlayer : p));
|
||||
return {
|
||||
|
|
@ -235,24 +169,10 @@ export function endTurn(state, playerIndex) {
|
|||
if (state.combat.playersReady.includes(playerIndex)) return state;
|
||||
|
||||
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 = {
|
||||
...player,
|
||||
hand: retained,
|
||||
discardPile: [...player.discardPile, ...discarded],
|
||||
exhaustPile: [...player.exhaustPile, ...exhausted],
|
||||
hand: [],
|
||||
discardPile: [...player.discardPile, ...player.hand],
|
||||
};
|
||||
const players = state.players.map((p, i) =>
|
||||
i === playerIndex ? updatedPlayer : p,
|
||||
|
|
@ -272,7 +192,7 @@ export function endTurn(state, playerIndex) {
|
|||
};
|
||||
}
|
||||
|
||||
export function shuffle(arr) {
|
||||
function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { initCards } from "./cards.js";
|
||||
import { initEnemies } from "./enemies.js";
|
||||
import { createRunState } from "./run.js";
|
||||
import {
|
||||
createCombatFromRun,
|
||||
createCombatState,
|
||||
drawCards,
|
||||
endTurn,
|
||||
playCard,
|
||||
} from "./state.js";
|
||||
import { createCombatState, drawCards, endTurn, playCard } from "./state.js";
|
||||
|
||||
beforeAll(async () => {
|
||||
await Promise.all([initCards(), initEnemies()]);
|
||||
|
|
@ -285,104 +278,3 @@ describe("backward compat - single string args", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
22
style.css
22
style.css
|
|
@ -12,10 +12,6 @@
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#game[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#enemy-zone {
|
||||
flex: 4;
|
||||
display: flex;
|
||||
|
|
@ -60,10 +56,8 @@
|
|||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
|
@ -71,19 +65,3 @@
|
|||
#overlay[hidden] {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue