Compare commits
23 commits
970796f85a
...
90c2aca72e
| Author | SHA1 | Date | |
|---|---|---|---|
| 90c2aca72e | |||
| 8ae3252a9c | |||
| cd42652087 | |||
| 7280af2075 | |||
| 51dce7eba1 | |||
| 91d32a71ee | |||
| febb05764b | |||
| 4d0692cf44 | |||
| 7dfcc416d4 | |||
| a7c5cbc56a | |||
| 4f91396e3a | |||
| ac45cb8758 | |||
| dd343be64a | |||
| 02f9e1ec2c | |||
| 62a1ef051c | |||
| 8a6a2f7662 | |||
| e05663d1c7 | |||
| fe95c03529 | |||
| 77f65ace98 | |||
| 4e457b80af | |||
| 46bd6e6d2b | |||
| 11e12ee906 | |||
| 28e54d502f |
18 changed files with 3395 additions and 67 deletions
1976
docs/plans/2026-02-24-act1-single-player-plan.md
Normal file
1976
docs/plans/2026-02-24-act1-single-player-plan.md
Normal file
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">
|
||||
<div id="game" hidden>
|
||||
<section id="enemy-zone">
|
||||
<div id="enemy-info">
|
||||
<span id="enemy-name"></span>
|
||||
|
|
@ -34,7 +34,27 @@
|
|||
<section id="hand"></section>
|
||||
</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>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -17,3 +17,7 @@ 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 { getCard, getStarterDeck, initCards } from "./cards.js";
|
||||
import { getAllCards, getCard, getStarterDeck, initCards } from "./cards.js";
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCards();
|
||||
|
|
@ -26,3 +26,18 @@ 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,11 +38,24 @@ 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 }));
|
||||
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
|
||||
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,6 +1,7 @@
|
|||
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";
|
||||
|
||||
|
|
@ -144,3 +145,112 @@ 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,6 +71,8 @@ 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": {
|
||||
|
|
|
|||
106
src/integration.test.js
Normal file
106
src/integration.test.js
Normal 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
168
src/keywords.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
215
src/main.js
215
src/main.js
|
|
@ -1,28 +1,124 @@
|
|||
import { getCard, initCards } from "./cards.js";
|
||||
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
|
||||
import { initEnemies } from "./enemies.js";
|
||||
import { render } from "./render.js";
|
||||
import { createCombatState, endTurn, playCard } from "./state.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";
|
||||
|
||||
let state = null;
|
||||
let run = null;
|
||||
let revealed = null;
|
||||
|
||||
async function init() {
|
||||
await Promise.all([initCards(), initEnemies()]);
|
||||
state = createCombatState("ironclad", "jaw_worm");
|
||||
state = startTurn(state);
|
||||
render(state);
|
||||
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 = 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() {
|
||||
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);
|
||||
render(state, revealed);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -32,78 +128,113 @@ 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);
|
||||
render(state, revealed);
|
||||
return;
|
||||
}
|
||||
state = { ...result, combat: { ...result.combat, selectedCard: null } };
|
||||
const end = checkCombatEnd(state);
|
||||
if (end) {
|
||||
state = {
|
||||
...state,
|
||||
combat: { ...state.combat, phase: "ended", result: end },
|
||||
};
|
||||
}
|
||||
render(state);
|
||||
if (!checkEnd()) render(state, revealed);
|
||||
return;
|
||||
}
|
||||
|
||||
render(state);
|
||||
render(state, revealed);
|
||||
});
|
||||
|
||||
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);
|
||||
render(state, revealed);
|
||||
return;
|
||||
}
|
||||
|
||||
state = { ...result, combat: { ...result.combat, selectedCard: null } };
|
||||
|
||||
const end = checkCombatEnd(state);
|
||||
if (end) {
|
||||
state = {
|
||||
...state,
|
||||
combat: { ...state.combat, phase: "ended", result: end },
|
||||
};
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
|
||||
render(state);
|
||||
if (!checkEnd()) render(state, revealed);
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("end-turn-btn")
|
||||
.addEventListener("click", async () => {
|
||||
if (!state) return;
|
||||
if (state.combat.phase !== "player_turn") return;
|
||||
|
||||
state = endTurn(state);
|
||||
render(state);
|
||||
render(state, revealed);
|
||||
|
||||
await delay(800);
|
||||
state = resolveEnemyTurn(state);
|
||||
|
||||
const end = checkCombatEnd(state);
|
||||
if (end) {
|
||||
state = {
|
||||
...state,
|
||||
combat: { ...state.combat, phase: "ended", result: end },
|
||||
};
|
||||
render(state);
|
||||
if (!checkEnd()) {
|
||||
state = startTurn(state);
|
||||
render(state, revealed);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
state = startTurn(state);
|
||||
render(state);
|
||||
// restart on act complete overlay click
|
||||
if (
|
||||
state.combat.phase === "ended" &&
|
||||
state.combat.result === "act_complete"
|
||||
) {
|
||||
startNewRun();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
58
src/map.js
Normal file
58
src/map.js
Normal 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
126
src/map.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
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) {
|
||||
export function render(state, revealed) {
|
||||
renderEnemy(state);
|
||||
renderInfoBar(state);
|
||||
renderHand(state);
|
||||
renderOverlay(state);
|
||||
renderOverlay(state, revealed);
|
||||
}
|
||||
|
||||
function renderEnemy(state) {
|
||||
|
|
@ -23,6 +23,7 @@ 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");
|
||||
|
|
@ -51,6 +52,7 @@ function formatIntent(action, enemy) {
|
|||
if (e.type === "strength") return `str +${e.value}`;
|
||||
return e.type;
|
||||
});
|
||||
if (parts.length === 0) return "zzz";
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
|
|
@ -103,16 +105,104 @@ function renderHand(state) {
|
|||
});
|
||||
}
|
||||
|
||||
function renderOverlay(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) {
|
||||
const overlay = document.getElementById("overlay");
|
||||
const result = state.combat.phase === "ended" ? state.combat.result : null;
|
||||
if (result === "victory") {
|
||||
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;
|
||||
overlay.textContent = "victory";
|
||||
} else if (result === "defeat") {
|
||||
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;
|
||||
overlay.textContent = "defeat";
|
||||
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
86
src/run.js
Normal 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
213
src/run.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
94
src/state.js
94
src/state.js
|
|
@ -14,6 +14,7 @@ function makePlayer(character, index) {
|
|||
strength: 0,
|
||||
vulnerable: 0,
|
||||
weak: 0,
|
||||
poison: 0,
|
||||
drawPile: shuffle([...getStarterDeck(character)]),
|
||||
hand: [],
|
||||
discardPile: [],
|
||||
|
|
@ -34,6 +35,7 @@ function makeEnemy(enemyId, index) {
|
|||
strength: 0,
|
||||
vulnerable: 0,
|
||||
weak: 0,
|
||||
poison: 0,
|
||||
actionType: enemy.actionType,
|
||||
actions: enemy.actions,
|
||||
actionTrack: enemy.actionTrack || null,
|
||||
|
|
@ -73,6 +75,49 @@ 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
|
||||
|
|
@ -120,14 +165,21 @@ 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 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 updatedPlayer = { ...player, hand, discardPile, energy };
|
||||
const updatedPlayer = { ...player, hand, discardPile, exhaustPile, energy };
|
||||
const players = state.players.map((p, i) =>
|
||||
i === playerIndex ? updatedPlayer : p,
|
||||
);
|
||||
|
|
@ -152,10 +204,24 @@ 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: [],
|
||||
discardPile: [...player.discardPile, ...player.hand],
|
||||
hand: retained,
|
||||
discardPile: [...player.discardPile, ...discarded],
|
||||
exhaustPile: [...player.exhaustPile, ...exhausted],
|
||||
};
|
||||
const players = state.players.map((p, i) => (i === 0 ? updatedPlayer : p));
|
||||
return {
|
||||
|
|
@ -169,10 +235,24 @@ 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: [],
|
||||
discardPile: [...player.discardPile, ...player.hand],
|
||||
hand: retained,
|
||||
discardPile: [...player.discardPile, ...discarded],
|
||||
exhaustPile: [...player.exhaustPile, ...exhausted],
|
||||
};
|
||||
const players = state.players.map((p, i) =>
|
||||
i === playerIndex ? updatedPlayer : p,
|
||||
|
|
@ -192,7 +272,7 @@ export function endTurn(state, playerIndex) {
|
|||
};
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
export function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { initCards } from "./cards.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 () => {
|
||||
await Promise.all([initCards(), initEnemies()]);
|
||||
|
|
@ -278,3 +285,104 @@ 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,6 +12,10 @@
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#game[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#enemy-zone {
|
||||
flex: 4;
|
||||
display: flex;
|
||||
|
|
@ -56,8 +60,10 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -65,3 +71,19 @@
|
|||
#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