feat: v0.6.0 balance pass, quest-gated bosses, and achievement fixes

This commit is contained in:
2026-07-17 01:02:12 -05:00
parent 9bb1d01d2b
commit 5928eb73d8
49 changed files with 1836 additions and 528 deletions
+61 -37
View File
@@ -1,6 +1,21 @@
/**
* @file Tests for the about route, covering release fetching, caching, and the
* fallback to pending releases when the upstream request fails.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pendingReleases } from "../../src/data/pendingReleases.js";
const makeApp = async(): Promise<Hono> => {
const { aboutRouter } = await import("../../src/routes/about.js");
const app = new Hono();
app.route("/about", aboutRouter);
return app;
};
describe("about route", () => {
const mockFetch = vi.fn();
@@ -15,59 +30,68 @@ describe("about route", () => {
mockFetch.mockReset();
});
const makeApp = async () => {
const { aboutRouter } = await import("../../src/routes/about.js");
const app = new Hono();
app.route("/about", aboutRouter);
return app;
};
it("returns releases from a successful fetch", async () => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
it("returns pending and fetched releases on a successful fetch", async() => {
expect.assertions(2);
const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ];
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve(releases);
}, ok: true });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual(releases);
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
const body: { releases: Array<unknown> } = await response.json();
expect(body.releases, "should merge pending and fetched releases").
toStrictEqual([ ...pendingReleases, ...releases ]);
});
it("returns empty releases when fetch is not ok", async () => {
it("returns only pending releases when fetch is not ok", async() => {
expect.assertions(2);
mockFetch.mockResolvedValueOnce({ ok: false });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual([]);
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
const body: { releases: Array<unknown> } = await response.json();
expect(body.releases, "should fall back to pending releases").
toStrictEqual(pendingReleases);
});
it("returns empty releases when fetch throws", async () => {
it("returns only pending releases when fetch throws", async() => {
expect.assertions(2);
mockFetch.mockRejectedValueOnce(new Error("Network error"));
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { releases: unknown[] };
expect(body.releases).toEqual([]);
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
const body: { releases: Array<unknown> } = await response.json();
expect(body.releases, "should fall back to pending releases").
toStrictEqual(pendingReleases);
});
it("returns cached releases on second call within TTL", async () => {
const releases = [{ id: 1, name: "v1.0.0", body: "notes" }];
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(releases) });
it("returns cached releases on second call within TTL", async() => {
expect.assertions(2);
const releases = [ { body: "notes", id: 1, name: "v1.0.0" } ];
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve(releases);
}, ok: true });
const app = await makeApp();
// First call populates cache
await app.fetch(new Request("http://localhost/about"));
// Second call should use cache, not call fetch again
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(1);
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
expect(mockFetch, "should not re-fetch within the TTL").
toHaveBeenCalledTimes(1);
});
it("includes apiVersion in response", async () => {
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
it("includes apiVersion in response", async() => {
expect.assertions(2);
mockFetch.mockResolvedValueOnce({ json: () => {
return Promise.resolve([]);
}, ok: true });
const app = await makeApp();
const res = await app.fetch(new Request("http://localhost/about"));
expect(res.status).toBe(200);
const body = await res.json() as { apiVersion: string };
expect(typeof body.apiVersion).toBe("string");
const response = await app.fetch(new Request("http://localhost/about"));
expect(response.status, "should respond with 200").toBe(200);
const body: { apiVersion: string } = await response.json();
expect(typeof body.apiVersion, "should expose apiVersion as a string").
toBe("string");
});
});
+127
View File
@@ -121,6 +121,133 @@ describe("boss route", () => {
expect(res.status).toBe(403);
});
it("returns 403 when boss's own unlock quest is not completed", async () => {
const state = makeState({
bosses: [makeBoss({ unlockQuestId: "gate_quest" })] as GameState["bosses"],
quests: [{ id: "gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(403);
});
it("allows challenge when boss's own unlock quest is completed", async () => {
const state = makeState({
bosses: [makeBoss({
unlockQuestId: "gate_quest",
currentHp: 100,
maxHp: 100,
})] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
quests: [{ id: "gate_quest", status: "completed" }] as GameState["quests"],
zones: [],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const body = await res.json() as { won: boolean };
expect(body.won).toBe(true);
});
it("does not unlock next zone boss when its own unlock quest is not completed", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0, unlockQuestId: "next_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
quests: [{ id: "next_gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedNextBoss = savedState?.bosses.find((b) => b.id === "next_boss");
expect(savedNextBoss?.status).toBe("locked");
});
it("unlocks next zone boss when its own unlock quest is completed", async () => {
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0, unlockQuestId: "next_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
nextBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [],
quests: [{ id: "next_gate_quest", status: "completed" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedNextBoss = savedState?.bosses.find((b) => b.id === "next_boss");
expect(savedNextBoss?.status).toBe("available");
});
it("does not unlock first zone boss when its own unlock quest is not completed", async () => {
const firstZoneBoss = makeBoss({ id: "first_zone_boss", zoneId: "new_zone", status: "locked", prestigeRequirement: 0, unlockQuestId: "zone_boss_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
firstZoneBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "new_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [{ id: "zone_boss_gate_quest", status: "active" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedZone = savedState?.zones.find((z) => z.id === "new_zone");
expect(savedZone?.status).toBe("unlocked");
const savedFirstZoneBoss = savedState?.bosses.find((b) => b.id === "first_zone_boss");
expect(savedFirstZoneBoss?.status).toBe("locked");
});
it("unlocks first zone boss when its own unlock quest is completed", async () => {
const firstZoneBoss = makeBoss({ id: "first_zone_boss", zoneId: "new_zone", status: "locked", prestigeRequirement: 0, unlockQuestId: "zone_boss_gate_quest" });
const state = makeState({
bosses: [
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
firstZoneBoss,
] as GameState["bosses"],
adventurers: [makeAdventurer()] as GameState["adventurers"],
zones: [{ id: "new_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
quests: [{ id: "zone_boss_gate_quest", status: "completed" }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
let savedState: GameState | undefined;
vi.mocked(prisma.gameState.update).mockImplementationOnce(async (args) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Test assertion */
savedState = (args as { data: { state: GameState } }).data.state;
return {} as never;
});
const res = await challenge({ bossId: "test_boss" });
expect(res.status).toBe(200);
const savedFirstZoneBoss = savedState?.bosses.find((b) => b.id === "first_zone_boss");
expect(savedFirstZoneBoss?.status).toBe("available");
});
it("returns 400 when party has no adventurers", async () => {
const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
+43 -1
View File
@@ -312,6 +312,34 @@ describe("debug route", () => {
expect(body.bossesUnlocked).toBe(1);
});
it("does not unlock boss when its own unlock quest is not completed", async () => {
const state = makeState({
bosses: [{ id: "absolute_herald", status: "locked" }] as GameState["bosses"],
prestige: { count: 17, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [{ id: "final_paradox", status: "active" }] as GameState["quests"],
zones: [{ id: "the_absolute", status: "unlocked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(0);
});
it("unlocks boss when its own unlock quest is completed", async () => {
const state = makeState({
bosses: [{ id: "absolute_herald", status: "locked" }] as GameState["bosses"],
prestige: { count: 17, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
quests: [{ id: "final_paradox", status: "completed" }] as GameState["quests"],
zones: [{ id: "the_absolute", status: "unlocked" }] as GameState["zones"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await forceUnlocks();
const body = await res.json() as { bossesUnlocked: number };
expect(body.bossesUnlocked).toBe(1);
});
it("returns explorationUnlocked=0 when exploration is undefined", async () => {
const state = makeState({
exploration: undefined as unknown as GameState["exploration"],
@@ -830,7 +858,7 @@ describe("debug route", () => {
it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 900, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 150, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
@@ -905,6 +933,20 @@ describe("debug route", () => {
expect(body.bossesPatched).toBe(1);
});
it("patches boss stats when only unlockQuestId has changed (exercises all earlier OR conditions)", async () => {
const state = makeState({
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: ["click_2"], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 5, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 1, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head.", unlockQuestId: "some_ghost_quest" }] as GameState["bosses"],
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
const res = await syncNewContent();
expect(res.status).toBe(200);
const body = await res.json() as { bossesPatched: number; state: GameState };
expect(body.bossesPatched).toBe(1);
const boss = body.state.bosses.find((b) => b.id === "troll_king");
expect(boss?.unlockQuestId).toBeNull();
});
it("skips boss stat patching for bosses not in defaults", async () => {
const state = makeState({
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
+46
View File
@@ -150,6 +150,52 @@ describe("prestige route", () => {
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything());
});
it("skips webhook when the new prestige count is not a multiple of 5", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: {} } as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(1);
expect(postMilestoneWebhook).not.toHaveBeenCalled();
});
it("posts webhook when the new prestige count is a multiple of 5", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 100_000_000, totalClicks: 0, characterName: "T" },
prestige: { count: 4, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: {} } as never);
const res = await post("");
expect(res.status).toBe(200);
const body = await res.json() as { newPrestigeCount: number };
expect(body.newPrestigeCount).toBe(5);
expect(postMilestoneWebhook).toHaveBeenCalledWith(DISCORD_ID, "prestige", expect.objectContaining({ prestige: 5 }));
});
it("skips webhook on a multiple of 5 when enablePrestigeAnnouncements is false", async () => {
const { postMilestoneWebhook } = await import("../../src/services/webhook.js");
const state = makeState({
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 100_000_000, totalClicks: 0, characterName: "T" },
prestige: { count: 4, runestones: 100, productionMultiplier: 1, purchasedUpgradeIds: [] },
});
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never);
const res = await post("");
expect(res.status).toBe(200);
expect(postMilestoneWebhook).not.toHaveBeenCalled();
});
});
describe("POST /buy-upgrade", () => {
+2 -2
View File
@@ -157,7 +157,7 @@ describe("profile route", () => {
expect(body.profileSettings.showTotalGold).toBe(false);
});
it("falls back to suffix numberFormat in GET when stored profileSettings has invalid format", async () => {
it("falls back to scientific numberFormat in GET when stored profileSettings has invalid format", async () => {
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
makePlayer({ profileSettings: { numberFormat: "invalid_format" } }) as never,
);
@@ -165,7 +165,7 @@ describe("profile route", () => {
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as { profileSettings: { numberFormat: string } };
expect(body.profileSettings.numberFormat).toBe("suffix");
expect(body.profileSettings.numberFormat).toBe("scientific");
});
it("maps known and unknown unlocked title IDs to name and fallback id", async () => {