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">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="game">
|
<div id="game" hidden>
|
||||||
<section id="enemy-zone">
|
<section id="enemy-zone">
|
||||||
<div id="enemy-info">
|
<div id="enemy-info">
|
||||||
<span id="enemy-name"></span>
|
<span id="enemy-name"></span>
|
||||||
|
|
@ -34,7 +34,27 @@
|
||||||
<section id="hand"></section>
|
<section id="hand"></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="overlay" hidden></div>
|
<section id="map-screen" hidden>
|
||||||
|
<h2>act 1</h2>
|
||||||
|
<div id="map-nodes"></div>
|
||||||
|
<button type="button" id="map-proceed-btn">proceed</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="campfire-screen" hidden>
|
||||||
|
<h2>campfire</h2>
|
||||||
|
<p id="campfire-hp"></p>
|
||||||
|
<div id="campfire-choices">
|
||||||
|
<button type="button" id="campfire-rest-btn">rest (heal 3 HP)</button>
|
||||||
|
<button type="button" id="campfire-smith-btn">smith (upgrade a card)</button>
|
||||||
|
</div>
|
||||||
|
<div id="smith-cards" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="overlay" hidden>
|
||||||
|
<div id="overlay-text"></div>
|
||||||
|
<div id="reward-cards"></div>
|
||||||
|
<button type="button" id="skip-btn" hidden>skip</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="src/main.js"></script>
|
<script type="module" src="src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,7 @@ export function getStarterDeck(character) {
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllCards() {
|
||||||
|
return Object.values(cardDb);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { getCard, getStarterDeck, initCards } from "./cards.js";
|
import { getAllCards, getCard, getStarterDeck, initCards } from "./cards.js";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initCards();
|
await initCards();
|
||||||
|
|
@ -26,3 +26,18 @@ describe("cards", () => {
|
||||||
expect(deck.filter((id) => id === "bash")).toHaveLength(1);
|
expect(deck.filter((id) => id === "bash")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getAllCards", () => {
|
||||||
|
test("returns array with more than 100 cards", () => {
|
||||||
|
const cards = getAllCards();
|
||||||
|
expect(cards.length).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each card has id and name properties", () => {
|
||||||
|
const cards = getAllCards();
|
||||||
|
for (const card of cards) {
|
||||||
|
expect(card.id).toBeDefined();
|
||||||
|
expect(card.name).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,24 @@ export function startTurn(state) {
|
||||||
export function resolveEnemyTurn(state) {
|
export function resolveEnemyTurn(state) {
|
||||||
// reset block on all enemies before they act
|
// reset block on all enemies before they act
|
||||||
const enemies = state.enemies.map((e) => ({ ...e, block: 0 }));
|
const enemies = state.enemies.map((e) => ({ ...e, block: 0 }));
|
||||||
let next = { ...state, enemies, enemy: enemies[0] };
|
|
||||||
|
// poison tick — damage and decrement before enemies act
|
||||||
|
const afterPoison = enemies.map((e) => {
|
||||||
|
if (e.poison > 0) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
hp: Math.max(0, e.hp - e.poison),
|
||||||
|
poison: e.poison - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
});
|
||||||
|
let next = { ...state, enemies: afterPoison, enemy: afterPoison[0] };
|
||||||
|
|
||||||
// each enemy acts; enemies target player 0 by default
|
// each enemy acts; enemies target player 0 by default
|
||||||
for (let i = 0; i < next.enemies.length; i++) {
|
for (let i = 0; i < next.enemies.length; i++) {
|
||||||
const enemy = next.enemies[i];
|
const enemy = next.enemies[i];
|
||||||
|
if (enemy.hp <= 0) continue; // dead enemies don't act
|
||||||
const action = resolveEnemyAction(
|
const action = resolveEnemyAction(
|
||||||
enemy,
|
enemy,
|
||||||
next.combat.dieResult,
|
next.combat.dieResult,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { initCards } from "./cards.js";
|
import { initCards } from "./cards.js";
|
||||||
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
|
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
|
||||||
|
import { resolveEffects } from "./effects.js";
|
||||||
import { initEnemies } from "./enemies.js";
|
import { initEnemies } from "./enemies.js";
|
||||||
import { createCombatState } from "./state.js";
|
import { createCombatState } from "./state.js";
|
||||||
|
|
||||||
|
|
@ -144,3 +145,112 @@ describe("checkCombatEnd - multiplayer", () => {
|
||||||
expect(checkCombatEnd(state)).toBe("defeat");
|
expect(checkCombatEnd(state)).toBe("defeat");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("poison effect", () => {
|
||||||
|
test("applying poison adds poison to target enemy", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
enemies: [{ ...state.enemies[0], poison: 0 }],
|
||||||
|
enemy: { ...state.enemy, poison: 0 },
|
||||||
|
};
|
||||||
|
const next = resolveEffects(
|
||||||
|
state,
|
||||||
|
[{ type: "poison", value: 3 }],
|
||||||
|
{ type: "player", index: 0 },
|
||||||
|
{ type: "enemy", index: 0 },
|
||||||
|
);
|
||||||
|
expect(next.enemies[0].poison).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("poison stacks additively up to 30", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
enemies: [{ ...state.enemies[0], poison: 0 }],
|
||||||
|
enemy: { ...state.enemy, poison: 0 },
|
||||||
|
};
|
||||||
|
let next = resolveEffects(
|
||||||
|
state,
|
||||||
|
[{ type: "poison", value: 20 }],
|
||||||
|
{ type: "player", index: 0 },
|
||||||
|
{ type: "enemy", index: 0 },
|
||||||
|
);
|
||||||
|
next = resolveEffects(
|
||||||
|
next,
|
||||||
|
[{ type: "poison", value: 15 }],
|
||||||
|
{ type: "player", index: 0 },
|
||||||
|
{ type: "enemy", index: 0 },
|
||||||
|
);
|
||||||
|
expect(next.enemies[0].poison).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("poison tick", () => {
|
||||||
|
test("poison damages enemy and decrements at start of enemy turn", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
const poisonedEnemy = { ...state.enemies[0], poison: 5, hp: 10 };
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
enemies: [poisonedEnemy],
|
||||||
|
enemy: poisonedEnemy,
|
||||||
|
combat: { ...state.combat, dieResult: 1 },
|
||||||
|
};
|
||||||
|
const next = resolveEnemyTurn(state);
|
||||||
|
expect(next.enemies[0].hp).toBe(5);
|
||||||
|
expect(next.enemies[0].poison).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enemy dies from poison", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
const poisonedEnemy = { ...state.enemies[0], poison: 10, hp: 3 };
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
enemies: [poisonedEnemy],
|
||||||
|
enemy: poisonedEnemy,
|
||||||
|
combat: { ...state.combat, dieResult: 1 },
|
||||||
|
};
|
||||||
|
const next = resolveEnemyTurn(state);
|
||||||
|
expect(next.enemies[0].hp).toBe(0);
|
||||||
|
expect(next.enemies[0].poison).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero poison does not tick", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
state = { ...state, combat: { ...state.combat, dieResult: 1 } };
|
||||||
|
const next = resolveEnemyTurn(state);
|
||||||
|
expect(next.enemies[0].poison).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enemy killed by poison does not act", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
const poisonedEnemy = { ...state.enemies[0], poison: 10, hp: 3 };
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
enemies: [poisonedEnemy],
|
||||||
|
enemy: poisonedEnemy,
|
||||||
|
players: [{ ...state.players[0], hp: 11, block: 0 }],
|
||||||
|
player: { ...state.players[0], hp: 11, block: 0 },
|
||||||
|
combat: { ...state.combat, dieResult: 1 },
|
||||||
|
};
|
||||||
|
const next = resolveEnemyTurn(state);
|
||||||
|
expect(next.enemies[0].hp).toBe(0);
|
||||||
|
expect(next.players[0].hp).toBe(11); // enemy died before acting
|
||||||
|
});
|
||||||
|
|
||||||
|
test("poison ignores enemy block", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
const blockedEnemy = { ...state.enemies[0], poison: 3, hp: 10, block: 5 };
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
enemies: [blockedEnemy],
|
||||||
|
enemy: blockedEnemy,
|
||||||
|
combat: { ...state.combat, dieResult: 1 },
|
||||||
|
};
|
||||||
|
const next = resolveEnemyTurn(state);
|
||||||
|
expect(next.enemies[0].hp).toBe(7); // 10 - 3, block didn't absorb poison
|
||||||
|
// block resets at turn start; enemy may regain some via action — not 5 from before
|
||||||
|
expect(next.enemies[0].block).toBeLessThan(5);
|
||||||
|
expect(next.enemies[0].poison).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
2
src/effects.js
vendored
2
src/effects.js
vendored
|
|
@ -71,6 +71,8 @@ function resolveSingleEffect(state, effect, source, target) {
|
||||||
return applyStatus(state, target, "vulnerable", effect.value, 3);
|
return applyStatus(state, target, "vulnerable", effect.value, 3);
|
||||||
case "weak":
|
case "weak":
|
||||||
return applyStatus(state, target, "weak", effect.value, 3);
|
return applyStatus(state, target, "weak", effect.value, 3);
|
||||||
|
case "poison":
|
||||||
|
return applyStatus(state, target, "poison", effect.value, 30);
|
||||||
case "strength":
|
case "strength":
|
||||||
return applyStatus(state, source, "strength", effect.value, 8);
|
return applyStatus(state, source, "strength", effect.value, 8);
|
||||||
case "draw": {
|
case "draw": {
|
||||||
|
|
|
||||||
106
src/integration.test.js
Normal file
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 { getCard, initCards } from "./cards.js";
|
||||||
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
|
import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js";
|
||||||
import { initEnemies } from "./enemies.js";
|
import { initEnemies } from "./enemies.js";
|
||||||
import { render } from "./render.js";
|
import { advanceMap, getCurrentNode, getNodeEnemy } from "./map.js";
|
||||||
import { createCombatState, endTurn, playCard } from "./state.js";
|
import {
|
||||||
|
hideCampfire,
|
||||||
|
render,
|
||||||
|
renderCampfire,
|
||||||
|
renderMap,
|
||||||
|
showGame,
|
||||||
|
} from "./render.js";
|
||||||
|
import {
|
||||||
|
campfireRest,
|
||||||
|
campfireSmith,
|
||||||
|
createRunState,
|
||||||
|
endCombat,
|
||||||
|
getUpgradableCards,
|
||||||
|
pickReward,
|
||||||
|
revealRewards,
|
||||||
|
skipRewards,
|
||||||
|
} from "./run.js";
|
||||||
|
import { createCombatFromRun, endTurn, playCard } from "./state.js";
|
||||||
|
|
||||||
let state = null;
|
let state = null;
|
||||||
|
let run = null;
|
||||||
|
let revealed = null;
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
await Promise.all([initCards(), initEnemies()]);
|
await Promise.all([initCards(), initEnemies()]);
|
||||||
state = createCombatState("ironclad", "jaw_worm");
|
startNewRun();
|
||||||
state = startTurn(state);
|
|
||||||
render(state);
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startNewRun() {
|
||||||
|
run = createRunState("ironclad");
|
||||||
|
revealed = null;
|
||||||
|
showMapScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMapScreen() {
|
||||||
|
renderMap(run.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCampfire() {
|
||||||
|
renderCampfire(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
function proceedFromMap() {
|
||||||
|
const node = getCurrentNode(run.map);
|
||||||
|
if (node.type === "campfire") {
|
||||||
|
showCampfire();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const enemyId = getNodeEnemy(node.type);
|
||||||
|
startNextCombat(enemyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNextCombat(enemyId) {
|
||||||
|
showGame();
|
||||||
|
state = createCombatFromRun(run, enemyId);
|
||||||
|
state = startTurn(state);
|
||||||
|
revealed = null;
|
||||||
|
render(state, revealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVictory() {
|
||||||
|
const clearedNode = getCurrentNode(run.map);
|
||||||
|
run = endCombat(run, state.players[0].hp);
|
||||||
|
run = { ...run, map: advanceMap(run.map) };
|
||||||
|
|
||||||
|
if (clearedNode.type === "boss") {
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
combat: { ...state.combat, phase: "ended", result: "act_complete" },
|
||||||
|
};
|
||||||
|
render(state, revealed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = revealRewards(run);
|
||||||
|
revealed = result.revealed;
|
||||||
|
run = result.run;
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
combat: { ...state.combat, phase: "rewards" },
|
||||||
|
};
|
||||||
|
render(state, revealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDefeat() {
|
||||||
|
run = { ...run, hp: state.players[0].hp };
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
combat: { ...state.combat, phase: "ended", result: "defeat" },
|
||||||
|
};
|
||||||
|
render(state, revealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkEnd() {
|
||||||
|
const end = checkCombatEnd(state);
|
||||||
|
if (end === "victory") {
|
||||||
|
handleVictory();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (end === "defeat") {
|
||||||
|
handleDefeat();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
document.getElementById("hand").addEventListener("click", (e) => {
|
document.getElementById("hand").addEventListener("click", (e) => {
|
||||||
const cardEl = e.target.closest(".card");
|
const cardEl = e.target.closest(".card");
|
||||||
|
if (!state) return;
|
||||||
if (!cardEl || state.combat.phase !== "player_turn") return;
|
if (!cardEl || state.combat.phase !== "player_turn") return;
|
||||||
const index = Number(cardEl.dataset.index);
|
const index = Number(cardEl.dataset.index);
|
||||||
|
|
||||||
if (state.combat.selectedCard === index) {
|
if (state.combat.selectedCard === index) {
|
||||||
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
||||||
render(state);
|
render(state, revealed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,78 +128,113 @@ function bindEvents() {
|
||||||
const card = getCard(cardId);
|
const card = getCard(cardId);
|
||||||
|
|
||||||
if (card.type === "skill") {
|
if (card.type === "skill") {
|
||||||
// auto-play skills (they target self)
|
|
||||||
const result = playCard(state, index);
|
const result = playCard(state, index);
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
// not enough energy
|
|
||||||
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
||||||
render(state);
|
render(state, revealed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = { ...result, combat: { ...result.combat, selectedCard: null } };
|
state = { ...result, combat: { ...result.combat, selectedCard: null } };
|
||||||
const end = checkCombatEnd(state);
|
if (!checkEnd()) render(state, revealed);
|
||||||
if (end) {
|
|
||||||
state = {
|
|
||||||
...state,
|
|
||||||
combat: { ...state.combat, phase: "ended", result: end },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
render(state);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(state);
|
render(state, revealed);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("enemy-zone").addEventListener("click", () => {
|
document.getElementById("enemy-zone").addEventListener("click", () => {
|
||||||
|
if (!state) return;
|
||||||
if (state.combat.selectedCard === null) return;
|
if (state.combat.selectedCard === null) return;
|
||||||
if (state.combat.phase !== "player_turn") return;
|
if (state.combat.phase !== "player_turn") return;
|
||||||
|
|
||||||
const result = playCard(state, state.combat.selectedCard);
|
const result = playCard(state, state.combat.selectedCard);
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
state = { ...state, combat: { ...state.combat, selectedCard: null } };
|
||||||
render(state);
|
render(state, revealed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = { ...result, combat: { ...result.combat, selectedCard: null } };
|
state = { ...result, combat: { ...result.combat, selectedCard: null } };
|
||||||
|
if (!checkEnd()) render(state, revealed);
|
||||||
const end = checkCombatEnd(state);
|
|
||||||
if (end) {
|
|
||||||
state = {
|
|
||||||
...state,
|
|
||||||
combat: { ...state.combat, phase: "ended", result: end },
|
|
||||||
};
|
|
||||||
render(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
render(state);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("end-turn-btn")
|
.getElementById("end-turn-btn")
|
||||||
.addEventListener("click", async () => {
|
.addEventListener("click", async () => {
|
||||||
|
if (!state) return;
|
||||||
if (state.combat.phase !== "player_turn") return;
|
if (state.combat.phase !== "player_turn") return;
|
||||||
|
|
||||||
state = endTurn(state);
|
state = endTurn(state);
|
||||||
render(state);
|
render(state, revealed);
|
||||||
|
|
||||||
await delay(800);
|
await delay(800);
|
||||||
state = resolveEnemyTurn(state);
|
state = resolveEnemyTurn(state);
|
||||||
|
|
||||||
const end = checkCombatEnd(state);
|
if (!checkEnd()) {
|
||||||
if (end) {
|
state = startTurn(state);
|
||||||
state = {
|
render(state, revealed);
|
||||||
...state,
|
}
|
||||||
combat: { ...state.combat, phase: "ended", result: end },
|
});
|
||||||
};
|
|
||||||
render(state);
|
document.getElementById("reward-cards").addEventListener("click", (e) => {
|
||||||
|
const img = e.target.closest(".reward-card");
|
||||||
|
if (!img || !revealed) return;
|
||||||
|
const cardId = img.dataset.cardId;
|
||||||
|
const index = revealed.indexOf(cardId);
|
||||||
|
if (index === -1) return;
|
||||||
|
run = pickReward(run, revealed, index);
|
||||||
|
showMapScreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("skip-btn").addEventListener("click", () => {
|
||||||
|
if (!revealed) return;
|
||||||
|
run = skipRewards(run, revealed);
|
||||||
|
showMapScreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("map-proceed-btn")
|
||||||
|
.addEventListener("click", proceedFromMap);
|
||||||
|
|
||||||
|
document.getElementById("campfire-rest-btn").addEventListener("click", () => {
|
||||||
|
run = campfireRest(run);
|
||||||
|
run = { ...run, map: advanceMap(run.map) };
|
||||||
|
hideCampfire();
|
||||||
|
showMapScreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("campfire-smith-btn")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
const upgradable = getUpgradableCards(run);
|
||||||
|
renderCampfire(run, upgradable);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("smith-cards").addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest(".smith-card-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
const cardId = btn.dataset.cardId;
|
||||||
|
run = campfireSmith(run, cardId);
|
||||||
|
run = { ...run, map: advanceMap(run.map) };
|
||||||
|
hideCampfire();
|
||||||
|
showMapScreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("overlay").addEventListener("click", (e) => {
|
||||||
|
if (e.target.closest("#reward-cards") || e.target.closest("#skip-btn"))
|
||||||
|
return;
|
||||||
|
// restart on defeat overlay click
|
||||||
|
if (state.combat.phase === "ended" && state.combat.result === "defeat") {
|
||||||
|
startNewRun();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// restart on act complete overlay click
|
||||||
state = startTurn(state);
|
if (
|
||||||
render(state);
|
state.combat.phase === "ended" &&
|
||||||
|
state.combat.result === "act_complete"
|
||||||
|
) {
|
||||||
|
startNewRun();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
58
src/map.js
Normal file
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 { getCard } from "./cards.js";
|
||||||
import { resolveEnemyAction } from "./enemies.js";
|
import { resolveEnemyAction } from "./enemies.js";
|
||||||
|
|
||||||
export function render(state) {
|
export function render(state, revealed) {
|
||||||
renderEnemy(state);
|
renderEnemy(state);
|
||||||
renderInfoBar(state);
|
renderInfoBar(state);
|
||||||
renderHand(state);
|
renderHand(state);
|
||||||
renderOverlay(state);
|
renderOverlay(state, revealed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEnemy(state) {
|
function renderEnemy(state) {
|
||||||
|
|
@ -23,6 +23,7 @@ function renderEnemy(state) {
|
||||||
if (enemy.vulnerable > 0) tokens.push(`vuln ${enemy.vulnerable}`);
|
if (enemy.vulnerable > 0) tokens.push(`vuln ${enemy.vulnerable}`);
|
||||||
if (enemy.weak > 0) tokens.push(`weak ${enemy.weak}`);
|
if (enemy.weak > 0) tokens.push(`weak ${enemy.weak}`);
|
||||||
if (enemy.strength > 0) tokens.push(`str ${enemy.strength}`);
|
if (enemy.strength > 0) tokens.push(`str ${enemy.strength}`);
|
||||||
|
if (enemy.poison > 0) tokens.push(`psn ${enemy.poison}`);
|
||||||
statusEl.textContent = tokens.join(" | ");
|
statusEl.textContent = tokens.join(" | ");
|
||||||
|
|
||||||
const intentEl = document.getElementById("enemy-intent");
|
const intentEl = document.getElementById("enemy-intent");
|
||||||
|
|
@ -51,6 +52,7 @@ function formatIntent(action, enemy) {
|
||||||
if (e.type === "strength") return `str +${e.value}`;
|
if (e.type === "strength") return `str +${e.value}`;
|
||||||
return e.type;
|
return e.type;
|
||||||
});
|
});
|
||||||
|
if (parts.length === 0) return "zzz";
|
||||||
return parts.join(", ");
|
return parts.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,16 +105,104 @@ function renderHand(state) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOverlay(state) {
|
export function renderMap(map) {
|
||||||
|
const 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 overlay = document.getElementById("overlay");
|
||||||
const result = state.combat.phase === "ended" ? state.combat.result : null;
|
const overlayText = document.getElementById("overlay-text");
|
||||||
if (result === "victory") {
|
const rewardCards = document.getElementById("reward-cards");
|
||||||
|
const skipBtn = document.getElementById("skip-btn");
|
||||||
|
|
||||||
|
if (state.combat.phase === "rewards" && revealed) {
|
||||||
overlay.hidden = false;
|
overlay.hidden = false;
|
||||||
overlay.textContent = "victory";
|
overlayText.textContent = "card reward";
|
||||||
} else if (result === "defeat") {
|
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.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 {
|
} else {
|
||||||
overlay.hidden = true;
|
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,
|
strength: 0,
|
||||||
vulnerable: 0,
|
vulnerable: 0,
|
||||||
weak: 0,
|
weak: 0,
|
||||||
|
poison: 0,
|
||||||
drawPile: shuffle([...getStarterDeck(character)]),
|
drawPile: shuffle([...getStarterDeck(character)]),
|
||||||
hand: [],
|
hand: [],
|
||||||
discardPile: [],
|
discardPile: [],
|
||||||
|
|
@ -34,6 +35,7 @@ function makeEnemy(enemyId, index) {
|
||||||
strength: 0,
|
strength: 0,
|
||||||
vulnerable: 0,
|
vulnerable: 0,
|
||||||
weak: 0,
|
weak: 0,
|
||||||
|
poison: 0,
|
||||||
actionType: enemy.actionType,
|
actionType: enemy.actionType,
|
||||||
actions: enemy.actions,
|
actions: enemy.actions,
|
||||||
actionTrack: enemy.actionTrack || null,
|
actionTrack: enemy.actionTrack || null,
|
||||||
|
|
@ -73,6 +75,49 @@ export function createCombatState(characterOrChars, enemyIdOrIds) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCombatFromRun(run, enemyIdOrIds) {
|
||||||
|
const enemyIds = Array.isArray(enemyIdOrIds) ? enemyIdOrIds : [enemyIdOrIds];
|
||||||
|
const enemies = enemyIds.map((id, i) => makeEnemy(id, i));
|
||||||
|
|
||||||
|
const player = {
|
||||||
|
id: "player_0",
|
||||||
|
character: run.character,
|
||||||
|
hp: run.hp,
|
||||||
|
maxHp: run.maxHp,
|
||||||
|
energy: 3,
|
||||||
|
maxEnergy: 3,
|
||||||
|
block: 0,
|
||||||
|
strength: 0,
|
||||||
|
vulnerable: 0,
|
||||||
|
weak: 0,
|
||||||
|
poison: 0,
|
||||||
|
drawPile: shuffle([...run.deck]),
|
||||||
|
hand: [],
|
||||||
|
discardPile: [],
|
||||||
|
exhaustPile: [],
|
||||||
|
powers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
players: [player],
|
||||||
|
enemies,
|
||||||
|
combat: {
|
||||||
|
turn: 1,
|
||||||
|
phase: "player_turn",
|
||||||
|
dieResult: null,
|
||||||
|
selectedCard: null,
|
||||||
|
log: [],
|
||||||
|
playerCount: 1,
|
||||||
|
activePlayerIndex: null,
|
||||||
|
playersReady: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
state.player = player;
|
||||||
|
state.enemy = enemies[0];
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
export function drawCards(state, playerIndexOrCount, count) {
|
export function drawCards(state, playerIndexOrCount, count) {
|
||||||
// drawCards(state, count) — old single-player form
|
// drawCards(state, count) — old single-player form
|
||||||
// drawCards(state, playerIndex, count) — new indexed form
|
// drawCards(state, playerIndex, count) — new indexed form
|
||||||
|
|
@ -120,14 +165,21 @@ export function playCard(state, handIndex, opts = {}) {
|
||||||
const player = state.players[playerIndex];
|
const player = state.players[playerIndex];
|
||||||
const cardId = player.hand[handIndex];
|
const cardId = player.hand[handIndex];
|
||||||
const card = getCard(cardId);
|
const card = getCard(cardId);
|
||||||
|
if (card.keywords?.includes("unplayable")) return null;
|
||||||
if (player.energy < card.cost) return null;
|
if (player.energy < card.cost) return null;
|
||||||
|
|
||||||
const hand = [...player.hand];
|
const hand = [...player.hand];
|
||||||
hand.splice(handIndex, 1);
|
hand.splice(handIndex, 1);
|
||||||
const discardPile = [...player.discardPile, cardId];
|
const exhausted = card.keywords?.includes("exhaust");
|
||||||
|
const discardPile = exhausted
|
||||||
|
? [...player.discardPile]
|
||||||
|
: [...player.discardPile, cardId];
|
||||||
|
const exhaustPile = exhausted
|
||||||
|
? [...player.exhaustPile, cardId]
|
||||||
|
: [...player.exhaustPile];
|
||||||
const energy = player.energy - card.cost;
|
const energy = player.energy - card.cost;
|
||||||
|
|
||||||
const updatedPlayer = { ...player, hand, discardPile, energy };
|
const updatedPlayer = { ...player, hand, discardPile, exhaustPile, energy };
|
||||||
const players = state.players.map((p, i) =>
|
const players = state.players.map((p, i) =>
|
||||||
i === playerIndex ? updatedPlayer : p,
|
i === playerIndex ? updatedPlayer : p,
|
||||||
);
|
);
|
||||||
|
|
@ -152,10 +204,24 @@ export function endTurn(state, playerIndex) {
|
||||||
// endTurn(state, playerIndex) — new form: marks one player done
|
// endTurn(state, playerIndex) — new form: marks one player done
|
||||||
if (playerIndex === undefined) {
|
if (playerIndex === undefined) {
|
||||||
const player = state.players[0];
|
const player = state.players[0];
|
||||||
|
const retained = [];
|
||||||
|
const exhausted = [];
|
||||||
|
const discarded = [];
|
||||||
|
for (const cardId of player.hand) {
|
||||||
|
const card = getCard(cardId);
|
||||||
|
if (card.keywords?.includes("retain")) {
|
||||||
|
retained.push(cardId);
|
||||||
|
} else if (card.keywords?.includes("ethereal")) {
|
||||||
|
exhausted.push(cardId);
|
||||||
|
} else {
|
||||||
|
discarded.push(cardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
const updatedPlayer = {
|
const updatedPlayer = {
|
||||||
...player,
|
...player,
|
||||||
hand: [],
|
hand: retained,
|
||||||
discardPile: [...player.discardPile, ...player.hand],
|
discardPile: [...player.discardPile, ...discarded],
|
||||||
|
exhaustPile: [...player.exhaustPile, ...exhausted],
|
||||||
};
|
};
|
||||||
const players = state.players.map((p, i) => (i === 0 ? updatedPlayer : p));
|
const players = state.players.map((p, i) => (i === 0 ? updatedPlayer : p));
|
||||||
return {
|
return {
|
||||||
|
|
@ -169,10 +235,24 @@ export function endTurn(state, playerIndex) {
|
||||||
if (state.combat.playersReady.includes(playerIndex)) return state;
|
if (state.combat.playersReady.includes(playerIndex)) return state;
|
||||||
|
|
||||||
const player = state.players[playerIndex];
|
const player = state.players[playerIndex];
|
||||||
|
const retained = [];
|
||||||
|
const exhausted = [];
|
||||||
|
const discarded = [];
|
||||||
|
for (const cardId of player.hand) {
|
||||||
|
const card = getCard(cardId);
|
||||||
|
if (card.keywords?.includes("retain")) {
|
||||||
|
retained.push(cardId);
|
||||||
|
} else if (card.keywords?.includes("ethereal")) {
|
||||||
|
exhausted.push(cardId);
|
||||||
|
} else {
|
||||||
|
discarded.push(cardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
const updatedPlayer = {
|
const updatedPlayer = {
|
||||||
...player,
|
...player,
|
||||||
hand: [],
|
hand: retained,
|
||||||
discardPile: [...player.discardPile, ...player.hand],
|
discardPile: [...player.discardPile, ...discarded],
|
||||||
|
exhaustPile: [...player.exhaustPile, ...exhausted],
|
||||||
};
|
};
|
||||||
const players = state.players.map((p, i) =>
|
const players = state.players.map((p, i) =>
|
||||||
i === playerIndex ? updatedPlayer : p,
|
i === playerIndex ? updatedPlayer : p,
|
||||||
|
|
@ -192,7 +272,7 @@ export function endTurn(state, playerIndex) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shuffle(arr) {
|
export function shuffle(arr) {
|
||||||
const a = [...arr];
|
const a = [...arr];
|
||||||
for (let i = a.length - 1; i > 0; i--) {
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { initCards } from "./cards.js";
|
import { initCards } from "./cards.js";
|
||||||
import { initEnemies } from "./enemies.js";
|
import { initEnemies } from "./enemies.js";
|
||||||
import { createCombatState, drawCards, endTurn, playCard } from "./state.js";
|
import { createRunState } from "./run.js";
|
||||||
|
import {
|
||||||
|
createCombatFromRun,
|
||||||
|
createCombatState,
|
||||||
|
drawCards,
|
||||||
|
endTurn,
|
||||||
|
playCard,
|
||||||
|
} from "./state.js";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await Promise.all([initCards(), initEnemies()]);
|
await Promise.all([initCards(), initEnemies()]);
|
||||||
|
|
@ -278,3 +285,104 @@ describe("backward compat - single string args", () => {
|
||||||
expect(next.player.hand).toHaveLength(0);
|
expect(next.player.hand).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createCombatFromRun", () => {
|
||||||
|
test("uses run deck for draw pile", () => {
|
||||||
|
const run = createRunState("ironclad");
|
||||||
|
// simulate picking a reward card
|
||||||
|
const modified = { ...run, deck: [...run.deck, "pommel_strike"] };
|
||||||
|
const state = createCombatFromRun(modified, "jaw_worm");
|
||||||
|
const allCards = [
|
||||||
|
...state.players[0].drawPile,
|
||||||
|
...state.players[0].hand,
|
||||||
|
...state.players[0].discardPile,
|
||||||
|
];
|
||||||
|
expect(allCards).toHaveLength(11);
|
||||||
|
expect(allCards.filter((id) => id === "pommel_strike")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses run HP instead of max", () => {
|
||||||
|
const run = createRunState("ironclad");
|
||||||
|
run.hp = 7;
|
||||||
|
const state = createCombatFromRun(run, "jaw_worm");
|
||||||
|
expect(state.players[0].hp).toBe(7);
|
||||||
|
expect(state.players[0].maxHp).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("backward-compat aliases work", () => {
|
||||||
|
const run = createRunState("ironclad");
|
||||||
|
const state = createCombatFromRun(run, "jaw_worm");
|
||||||
|
expect(state.player).toBe(state.players[0]);
|
||||||
|
expect(state.enemy).toBe(state.enemies[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("playCard - exhaust", () => {
|
||||||
|
test("exhaust card goes to exhaustPile instead of discardPile", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
state = drawCards(state, 5);
|
||||||
|
// manually put true_grit in hand
|
||||||
|
const player = {
|
||||||
|
...state.player,
|
||||||
|
hand: [...state.player.hand, "true_grit"],
|
||||||
|
};
|
||||||
|
state = { ...state, player, players: [player] };
|
||||||
|
const handIndex = state.player.hand.indexOf("true_grit");
|
||||||
|
const discardBefore = [...state.player.discardPile];
|
||||||
|
const next = playCard(state, handIndex);
|
||||||
|
expect(next.player.hand).toHaveLength(state.player.hand.length - 1);
|
||||||
|
expect(next.player.exhaustPile).toContain("true_grit");
|
||||||
|
expect(next.player.discardPile).toEqual(discardBefore);
|
||||||
|
expect(next.player.energy).toBe(state.player.energy - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("endTurn - ethereal", () => {
|
||||||
|
test("ethereal cards in hand exhaust at end of turn", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
const player = {
|
||||||
|
...state.player,
|
||||||
|
hand: ["strike_r", "carnage", "defend_r"],
|
||||||
|
};
|
||||||
|
state = { ...state, player, players: [player] };
|
||||||
|
const next = endTurn(state);
|
||||||
|
expect(next.player.exhaustPile).toContain("carnage");
|
||||||
|
expect(next.player.discardPile).toContain("strike_r");
|
||||||
|
expect(next.player.discardPile).toContain("defend_r");
|
||||||
|
expect(next.player.hand).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("endTurn - retain", () => {
|
||||||
|
test("retained cards stay in hand at end of turn", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
const player = {
|
||||||
|
...state.player,
|
||||||
|
hand: ["strike_r", "equilibrium", "defend_r"],
|
||||||
|
};
|
||||||
|
state = { ...state, player, players: [player] };
|
||||||
|
const next = endTurn(state);
|
||||||
|
expect(next.player.hand).toContain("equilibrium");
|
||||||
|
expect(next.player.discardPile).toContain("strike_r");
|
||||||
|
expect(next.player.discardPile).toContain("defend_r");
|
||||||
|
expect(next.player.hand).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("playCard - unplayable", () => {
|
||||||
|
test("unplayable card cannot be played", () => {
|
||||||
|
let state = createCombatState("ironclad", "jaw_worm");
|
||||||
|
state = drawCards(state, 5);
|
||||||
|
// manually put tactician in hand
|
||||||
|
const player = {
|
||||||
|
...state.player,
|
||||||
|
hand: [...state.player.hand, "tactician"],
|
||||||
|
};
|
||||||
|
state = { ...state, player, players: [player] };
|
||||||
|
const handIndex = state.player.hand.indexOf("tactician");
|
||||||
|
const handBefore = [...state.player.hand];
|
||||||
|
const result = playCard(state, handIndex);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(state.player.hand).toEqual(handBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
22
style.css
22
style.css
|
|
@ -12,6 +12,10 @@
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#game[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
#enemy-zone {
|
#enemy-zone {
|
||||||
flex: 4;
|
flex: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -56,8 +60,10 @@
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
@ -65,3 +71,19 @@
|
||||||
#overlay[hidden] {
|
#overlay[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#reward-cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reward-cards .reward-card {
|
||||||
|
width: 120px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reward-cards .reward-card:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue