1 Commits

Author SHA1 Message Date
naomi 66c2f7e8e9 wip: paperdoll 2026-03-19 21:06:13 -07:00
20 changed files with 390 additions and 588 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.2.1", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+13 -15
View File
@@ -141,7 +141,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Shadow Marshes ──────────────────────────────────────────────────────── // ── Shadow Marshes ────────────────────────────────────────────────────────
{ {
combatPowerRequired: 5_000_000, combatPowerRequired: 5000,
description: description:
"A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.", "A cursed lake shrouded in permanent twilight. Strange energies pulse beneath its surface.",
durationSeconds: 45 * 60, durationSeconds: 45 * 60,
@@ -156,7 +156,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
combatPowerRequired: 20_000_000, combatPowerRequired: 20_000,
description: description:
"Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.", "Deep in the marshes, a coven of swamp witches performs rites that twist the very land. Their power must be broken.",
durationSeconds: 90 * 60, durationSeconds: 90 * 60,
@@ -171,7 +171,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
combatPowerRequired: 80_000_000, combatPowerRequired: 80_000,
description: description:
"An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.", "An ancient temple half-submerged in black water, its altars still humming with the power of a god long since departed.",
durationSeconds: 2 * 60 * 60, durationSeconds: 2 * 60 * 60,
@@ -180,7 +180,6 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [ "witch_coven" ], prerequisiteIds: [ "witch_coven" ],
rewards: [ rewards: [
{ amount: 2_000_000, type: "gold" }, { amount: 2_000_000, type: "gold" },
{ amount: 1500, type: "essence" },
{ amount: 75, type: "crystals" }, { amount: 75, type: "crystals" },
{ targetId: "knight_1", type: "upgrade" }, { targetId: "knight_1", type: "upgrade" },
], ],
@@ -188,7 +187,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "shadow_marshes", zoneId: "shadow_marshes",
}, },
{ {
combatPowerRequired: 300_000_000, combatPowerRequired: 300_000,
description: description:
"A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.", "A city that died overnight, its streets still thick with something no healer can identify. Treasures lie unclaimed among the bones.",
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
@@ -254,7 +253,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Volcanic Depths ─────────────────────────────────────────────────────── // ── Volcanic Depths ───────────────────────────────────────────────────────
{ {
combatPowerRequired: 1_200_000_000, combatPowerRequired: 2_000_000,
description: description:
"A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.", "A river of molten rock that flows without end through the volcanic tunnels. Something valuable gleams in the depths.",
durationSeconds: 3 * 60 * 60, durationSeconds: 3 * 60 * 60,
@@ -264,13 +263,12 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 15_000_000, type: "gold" }, { amount: 15_000_000, type: "gold" },
{ amount: 4000, type: "essence" }, { amount: 4000, type: "essence" },
{ targetId: "void_walker", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 4_800_000_000, combatPowerRequired: 8_000_000,
description: description:
"A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.", "A vast shrine where fire elementals perform rituals that shake the mountains. Whatever they worship, it has answered.",
durationSeconds: 5 * 60 * 60, durationSeconds: 5 * 60 * 60,
@@ -278,15 +276,15 @@ export const defaultQuests: Array<Quest> = [
name: "The Temple of the Flame", name: "The Temple of the Flame",
prerequisiteIds: [ "lava_flows" ], prerequisiteIds: [ "lava_flows" ],
rewards: [ rewards: [
{ amount: 40_000_000, type: "gold" },
{ amount: 12_000, type: "essence" }, { amount: 12_000, type: "essence" },
{ amount: 300, type: "crystals" }, { amount: 300, type: "crystals" },
{ targetId: "void_walker", type: "adventurer" },
], ],
status: "locked", status: "locked",
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 18_000_000_000, combatPowerRequired: 30_000_000,
description: description:
"Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.", "Kilometres of tunnels filled with rivers of fire and creatures born from the earth's core. The heat alone should kill you. Somehow, it won't.",
durationSeconds: 7 * 60 * 60, durationSeconds: 7 * 60 * 60,
@@ -302,7 +300,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "volcanic_depths", zoneId: "volcanic_depths",
}, },
{ {
combatPowerRequired: 72_000_000_000, combatPowerRequired: 120_000_000,
description: description:
"The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.", "The oldest forge in existence, where the fire elementals crafted weapons for gods. Its secrets could revolutionise your guild's arsenal.",
durationSeconds: 10 * 60 * 60, durationSeconds: 10 * 60 * 60,
@@ -319,7 +317,7 @@ export const defaultQuests: Array<Quest> = [
}, },
// ── Astral Void ─────────────────────────────────────────────────────────── // ── Astral Void ───────────────────────────────────────────────────────────
{ {
combatPowerRequired: 300_000_000_000, combatPowerRequired: 50_000_000,
description: description:
"A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.", "A tear in reality itself. What lies beyond defies description — but the power within is unlike anything of this world.",
durationSeconds: 4 * 60 * 60, durationSeconds: 4 * 60 * 60,
@@ -334,7 +332,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 1_200_000_000_000, combatPowerRequired: 200_000_000,
description: description:
"A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.", "A field of dead stars, each one larger than a planet, each one cold and silent where once they burned with the light of creation.",
durationSeconds: 8 * 60 * 60, durationSeconds: 8 * 60 * 60,
@@ -350,7 +348,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 4_800_000_000_000, combatPowerRequired: 800_000_000,
description: description:
"The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.", "The space between realities, where the rules that govern your world do not apply. Time is meaningless here. Power is everything.",
durationSeconds: 12 * 60 * 60, durationSeconds: 12 * 60 * 60,
@@ -366,7 +364,7 @@ export const defaultQuests: Array<Quest> = [
zoneId: "astral_void", zoneId: "astral_void",
}, },
{ {
combatPowerRequired: 18_000_000_000_000, combatPowerRequired: 3_000_000_000,
description: description:
"There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.", "There is nothing beyond this point. Only the greatest guild in the history of all existence could reach here — and you have.",
durationSeconds: 24 * 60 * 60, durationSeconds: 24 * 60 * 60,
+4 -211
View File
@@ -7,11 +7,6 @@
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */ /* eslint-disable max-lines -- Multiple route handlers and helper functions in one file */
import { createHmac } from "node:crypto"; import { createHmac } from "node:crypto";
import {
STORY_CHAPTERS,
isStoryChapterUnlocked,
type GameState,
} from "@elysium/types";
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultBosses } from "../data/bosses.js"; import { defaultBosses } from "../data/bosses.js";
import { defaultExplorations } from "../data/explorations.js"; import { defaultExplorations } from "../data/explorations.js";
@@ -23,6 +18,7 @@ import { prisma } from "../db/client.js";
import { authMiddleware } from "../middleware/auth.js"; import { authMiddleware } from "../middleware/auth.js";
import { logger } from "../services/logger.js"; import { logger } from "../services/logger.js";
import type { HonoEnvironment } from "../types/hono.js"; import type { HonoEnvironment } from "../types/hono.js";
import type { GameState } from "@elysium/types";
/** /**
* Computes the HMAC-SHA256 of data using the given secret. * Computes the HMAC-SHA256 of data using the given secret.
@@ -261,180 +257,6 @@ const applyBossUnlocks = (state: GameState): number => {
return count; return count;
}; };
/**
* Unlocks any adventurer tiers that were granted as rewards for completed quests
* but are still locked in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of adventurer tiers that were unlocked.
*/
const applyAdventurerUnlocks = (state: GameState): number => {
let count = 0;
const completedQuestIds = new Set(
state.quests.
filter((q) => {
return q.status === "completed";
}).
map((q) => {
return q.id;
}),
);
const earnedAdventurerIds = new Set<string>();
for (const questDefinition of defaultQuests) {
if (!completedQuestIds.has(questDefinition.id)) {
continue;
}
for (const reward of questDefinition.rewards) {
if (reward.type === "adventurer" && reward.targetId !== undefined) {
earnedAdventurerIds.add(reward.targetId);
}
}
}
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked && earnedAdventurerIds.has(adventurer.id)) {
adventurer.unlocked = true;
count = count + 1;
}
}
return count;
};
/**
* Collects all upgrade IDs the player has legitimately earned via boss defeats
* and completed quest rewards, sourcing reward data from game definitions.
* @param state - The player's current game state.
* @returns A set of earned upgrade IDs.
*/
const collectEarnedUpgradeIds = (state: GameState): Set<string> => {
const earnedIds = new Set<string>();
const defeatedBossIds = new Set(
state.bosses.
filter((b) => {
return b.status === "defeated";
}).
map((b) => {
return b.id;
}),
);
const completedQuestIds = new Set(
state.quests.
filter((q) => {
return q.status === "completed";
}).
map((q) => {
return q.id;
}),
);
for (const bossDefinition of defaultBosses) {
if (!defeatedBossIds.has(bossDefinition.id)) {
continue;
}
for (const upgradeId of bossDefinition.upgradeRewards) {
earnedIds.add(upgradeId);
}
}
for (const questDefinition of defaultQuests) {
if (!completedQuestIds.has(questDefinition.id)) {
continue;
}
for (const reward of questDefinition.rewards) {
if (reward.type === "upgrade" && reward.targetId !== undefined) {
earnedIds.add(reward.targetId);
}
}
}
return earnedIds;
};
/**
* Unlocks any upgrades that were granted as rewards for defeated bosses or
* completed quests but are still locked in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of upgrades that were unlocked.
*/
const applyUpgradeUnlocks = (state: GameState): number => {
let count = 0;
const earnedUpgradeIds = collectEarnedUpgradeIds(state);
for (const upgrade of state.upgrades) {
if (!upgrade.unlocked && earnedUpgradeIds.has(upgrade.id)) {
upgrade.unlocked = true;
count = count + 1;
}
}
return count;
};
/**
* Marks as owned any equipment that was granted as a reward for defeated bosses
* but is still unowned in the player's state.
* @param state - The player's current game state (mutated directly).
* @returns The number of equipment items that were marked as owned.
*/
const applyEquipmentUnlocks = (state: GameState): number => {
let count = 0;
const defeatedBossIds = new Set(
state.bosses.
filter((b) => {
return b.status === "defeated";
}).
map((b) => {
return b.id;
}),
);
const earnedEquipmentIds = new Set<string>();
for (const bossDefinition of defaultBosses) {
if (!defeatedBossIds.has(bossDefinition.id)) {
continue;
}
for (const equipmentId of bossDefinition.equipmentRewards) {
earnedEquipmentIds.add(equipmentId);
}
}
for (const item of state.equipment) {
if (!item.owned && earnedEquipmentIds.has(item.id)) {
item.owned = true;
count = count + 1;
}
}
return count;
};
/**
* Unlocks any story chapters whose conditions are met by the current game state
* but are still absent from the player's unlockedChapterIds list.
* @param state - The player's current game state (mutated directly).
* @returns The number of story chapters that were unlocked.
*/
const applyStoryUnlocks = (state: GameState): number => {
if (state.story === undefined) {
return 0;
}
let count = 0;
const alreadyUnlocked = new Set(state.story.unlockedChapterIds);
for (const chapter of STORY_CHAPTERS) {
if (alreadyUnlocked.has(chapter.id)) {
continue;
}
if (isStoryChapterUnlocked(chapter, state)) {
state.story.unlockedChapterIds.push(chapter.id);
count = count + 1;
}
}
return count;
};
/** /**
* Makes available any exploration areas whose parent zone is now unlocked. * Makes available any exploration areas whose parent zone is now unlocked.
* @param state - The player's current game state (mutated directly). * @param state - The player's current game state (mutated directly).
@@ -479,33 +301,16 @@ const applyExplorationUnlocks = (state: GameState): number => {
const applyForceUnlocks = ( const applyForceUnlocks = (
state: GameState, state: GameState,
): { ): {
adventurersUnlocked: number;
bossesUnlocked: number; bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number; explorationUnlocked: number;
questsUnlocked: number; questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number; zonesUnlocked: number;
} => { } => {
const zonesUnlocked = applyZoneUnlocks(state); const zonesUnlocked = applyZoneUnlocks(state);
const questsUnlocked = applyQuestUnlocks(state); const questsUnlocked = applyQuestUnlocks(state);
const bossesUnlocked = applyBossUnlocks(state); const bossesUnlocked = applyBossUnlocks(state);
const explorationUnlocked = applyExplorationUnlocks(state); const explorationUnlocked = applyExplorationUnlocks(state);
const adventurersUnlocked = applyAdventurerUnlocks(state); return { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked };
const upgradesUnlocked = applyUpgradeUnlocks(state);
const equipmentUnlocked = applyEquipmentUnlocks(state);
const storyUnlocked = applyStoryUnlocks(state);
return {
adventurersUnlocked,
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked,
};
}; };
const debugRouter = new Hono<HonoEnvironment>(); const debugRouter = new Hono<HonoEnvironment>();
@@ -525,16 +330,8 @@ debugRouter.post("/force-unlocks", async(context) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
const state = gameStateRecord.state as unknown as GameState; const state = gameStateRecord.state as unknown as GameState;
const { const { bossesUnlocked, explorationUnlocked, questsUnlocked, zonesUnlocked }
adventurersUnlocked, = applyForceUnlocks(state);
bossesUnlocked,
equipmentUnlocked,
explorationUnlocked,
questsUnlocked,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked,
} = applyForceUnlocks(state);
const updatedAt = Date.now(); const updatedAt = Date.now();
await prisma.gameState.update({ await prisma.gameState.update({
@@ -550,15 +347,11 @@ debugRouter.post("/force-unlocks", async(context) => {
: computeHmac(JSON.stringify(state), secret); : computeHmac(JSON.stringify(state), secret);
return context.json({ return context.json({
adventurersUnlocked,
bossesUnlocked, bossesUnlocked,
equipmentUnlocked,
explorationUnlocked, explorationUnlocked,
questsUnlocked, questsUnlocked,
signature, signature,
state, state,
storyUnlocked,
upgradesUnlocked,
zonesUnlocked, zonesUnlocked,
}); });
} catch (error) { } catch (error) {
-155
View File
@@ -366,161 +366,6 @@ describe("debug route", () => {
expect(body.explorationUnlocked).toBe(0); expect(body.explorationUnlocked).toBe(0);
}); });
it("unlocks adventurer tier when its quest has been completed", async () => {
const state = makeState({
adventurers: [ { id: "scout", unlocked: false } ] as GameState["adventurers"],
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
});
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 { adventurersUnlocked: number };
expect(body.adventurersUnlocked).toBe(1);
});
it("does not unlock adventurer tier when it is already unlocked", async () => {
const state = makeState({
adventurers: [ { id: "scout", unlocked: true } ] as GameState["adventurers"],
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
});
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 { adventurersUnlocked: number };
expect(body.adventurersUnlocked).toBe(0);
});
it("unlocks upgrade when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
});
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 { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(1);
});
it("does not unlock upgrade when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
});
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 { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(0);
});
it("does not unlock upgrade when it is already unlocked", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
upgrades: [ { id: "click_2", unlocked: true } ] as GameState["upgrades"],
});
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 { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(0);
});
it("unlocks upgrade granted as a quest reward", async () => {
const state = makeState({
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
upgrades: [ { id: "global_1", unlocked: false } ] as GameState["upgrades"],
});
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 { upgradesUnlocked: number };
expect(body.upgradesUnlocked).toBe(1);
});
it("marks equipment as owned when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
});
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 { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(1);
});
it("does not mark equipment as owned when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
});
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 { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(0);
});
it("does not mark equipment as owned when it is already owned", async () => {
const state = makeState({
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
equipment: [ { id: "iron_sword", owned: true } ] as GameState["equipment"],
});
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 { equipmentUnlocked: number };
expect(body.equipmentUnlocked).toBe(0);
});
it("returns storyUnlocked=0 when story is undefined", async () => {
const state = makeState({
story: undefined as unknown as GameState["story"],
});
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 { storyUnlocked: number };
expect(body.storyUnlocked).toBe(0);
});
it("unlocks story chapter when its boss has been defeated", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [] },
});
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 { storyUnlocked: number };
expect(body.storyUnlocked).toBe(1);
});
it("does not unlock story chapter when boss is not defeated", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "available" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [] },
});
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 { storyUnlocked: number };
expect(body.storyUnlocked).toBe(0);
});
it("does not unlock story chapter when it is already unlocked", async () => {
const state = makeState({
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
story: { completedChapters: [], unlockedChapterIds: [ "story_ch_01" ] },
});
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 { storyUnlocked: number };
expect(body.storyUnlocked).toBe(0);
});
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => { it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
process.env.ANTI_CHEAT_SECRET = "test_secret"; process.env.ANTI_CHEAT_SECRET = "test_secret";
const state = makeState(); const state = makeState();
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.2.1", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -158,6 +158,15 @@ const howToPlay = [
+ " is visible on your public profile page.", + " is visible on your public profile page.",
title: "📋 Character Sheet", title: "📋 Character Sheet",
}, },
{
body:
"Customise your adventurer's appearance from the Character tab. Choose"
+ " your skin tone, hair style, hair colour, outfit, and accessory."
+ " Your paper doll is displayed in the sidebar for you to see at all"
+ " times. Appearance settings are purely cosmetic and persist through"
+ " prestige and transcendence resets.",
title: "🎨 Paper Doll",
},
{ {
body: body:
"Earn Titles by reaching milestones — defeating bosses, completing" "Earn Titles by reaching milestones — defeating bosses, completing"
@@ -143,10 +143,6 @@ const AdventurerCard = ({
{" essence/s each"} {" essence/s each"}
</p> </p>
} }
<p>
{formatNumber(adventurer.combatPower)}
{" combat power each"}
</p>
</div> </div>
<div className="adventurer-count"> <div className="adventurer-count">
{"×"} {"×"}
@@ -175,7 +171,7 @@ const AdventurerCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const AdventurerPanel = (): JSX.Element => { const AdventurerPanel = (): JSX.Element => {
const { state, formatNumber, toggleAutoAdventurer } = useGame(); const { state, formatNumber } = useGame();
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
const [ batchSize, setBatchSize ] = useState<BatchSize>(() => { const [ batchSize, setBatchSize ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_batch_size")); return parseBatchSize(localStorage.getItem("elysium_batch_size"));
@@ -207,11 +203,6 @@ const AdventurerPanel = (): JSX.Element => {
} }
} }
const autoAdventurerUnlocked = state.prestige.purchasedUpgradeIds.includes(
"auto_adventurer",
);
const autoAdventurerOn = state.autoAdventurer === true;
function handleToggle(): void { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
return !current; return !current;
@@ -222,35 +213,12 @@ const AdventurerPanel = (): JSX.Element => {
<section className="panel adventurer-panel"> <section className="panel adventurer-panel">
<div className="panel-header"> <div className="panel-header">
<h2>{"Adventurers"}</h2> <h2>{"Adventurers"}</h2>
<div className="panel-header-controls">
{autoAdventurerUnlocked
? <button
className={`auto-toggle-btn ${
autoAdventurerOn
? "auto-toggle-on"
: "auto-toggle-off"
}`}
onClick={toggleAutoAdventurer}
title={
"Automatically purchase the highest-tier"
+ " affordable adventurer"
}
type="button"
>
{"🤖 Auto: "}
{autoAdventurerOn
? "ON"
: "OFF"}
</button>
: null
}
<LockToggle <LockToggle
lockedCount={locked.length} lockedCount={locked.length}
onToggle={handleToggle} onToggle={handleToggle}
showLocked={showLocked} showLocked={showLocked}
/> />
</div> </div>
</div>
<div className="batch-selector"> <div className="batch-selector">
{batchOptions.map((option) => { {batchOptions.map((option) => {
function handleBatchSelect(): void { function handleBatchSelect(): void {
@@ -267,23 +267,6 @@ const BossPanel = (): JSX.Element => {
} }
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state; const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
const zoneBosses = bosses.filter((boss) => { const zoneBosses = bosses.filter((boss) => {
return boss.zoneId === activeZoneId; return boss.zoneId === activeZoneId;
}); });
@@ -410,27 +393,6 @@ const BossPanel = (): JSX.Element => {
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<div className="party-combat-stats"> <div className="party-combat-stats">
<div className="combat-stat"> <div className="combat-stat">
<span className="stat-label">{"⚔️ Party DPS"}</span> <span className="stat-label">{"⚔️ Party DPS"}</span>
@@ -9,8 +9,10 @@
/* eslint-disable max-statements -- Component requires many state declarations */ /* eslint-disable max-statements -- Component requires many state declarations */
/* eslint-disable max-lines -- Large component with editing and view modes */ /* eslint-disable max-lines -- Large component with editing and view modes */
import { import {
defaultAppearance,
DEFAULT_PROFILE_SETTINGS, DEFAULT_PROFILE_SETTINGS,
STORY_CHAPTERS, STORY_CHAPTERS,
type Appearance,
type EquipmentBonus, type EquipmentBonus,
type EquipmentRarity, type EquipmentRarity,
type EquipmentType, type EquipmentType,
@@ -87,7 +89,7 @@ const formatBonus = (bonus: EquipmentBonus): string => {
* @returns The JSX element. * @returns The JSX element.
*/ */
const CharacterSheetPanel = (): JSX.Element => { const CharacterSheetPanel = (): JSX.Element => {
const { state, loginStreak } = useGame(); const { state, loginStreak, updateAppearance } = useGame();
const player = state?.player; const player = state?.player;
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet); const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
@@ -276,6 +278,35 @@ const CharacterSheetPanel = (): JSX.Element => {
}); });
} }
const currentAppearance = state?.appearance ?? defaultAppearance;
function handleAppearanceChange(
field: keyof Appearance,
value: string,
): void {
updateAppearance({ ...currentAppearance, [field]: value });
}
function handleSkinToneChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("skinTone", event.target.value);
}
function handleHairStyleChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("hairStyle", event.target.value);
}
function handleHairColourChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("hairColour", event.target.value);
}
function handleOutfitChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("outfit", event.target.value);
}
function handleAccessoryChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("accessory", event.target.value);
}
if (loading) { if (loading) {
return ( return (
<section className="panel"> <section className="panel">
@@ -573,6 +604,116 @@ const CharacterSheetPanel = (): JSX.Element => {
} }
</div> </div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">
{"🎨 Appearance"}
</h3>
<p className="character-sheet-hint">
{"Customise your adventurer's look. Changes save automatically."}
</p>
<div className="appearance-editor">
<label
className="character-sheet-label"
htmlFor="appearance-skin-tone"
>
{"Skin Tone"}
</label>
<select
className="character-sheet-select"
id="appearance-skin-tone"
onChange={handleSkinToneChange}
value={currentAppearance.skinTone}
>
<option value="pale">{"Pale"}</option>
<option value="light">{"Light"}</option>
<option value="tan">{"Tan"}</option>
<option value="medium">{"Medium"}</option>
<option value="dark">{"Dark"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-hair-style"
>
{"Hair Style"}
</label>
<select
className="character-sheet-select"
id="appearance-hair-style"
onChange={handleHairStyleChange}
value={currentAppearance.hairStyle}
>
<option value="short">{"Short"}</option>
<option value="shoulder">{"Shoulder-length"}</option>
<option value="long">{"Long"}</option>
<option value="ponytail">{"Ponytail"}</option>
<option value="twintails">{"Twin Tails"}</option>
<option value="bun">{"Bun"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-hair-colour"
>
{"Hair Colour"}
</label>
<select
className="character-sheet-select"
id="appearance-hair-colour"
onChange={handleHairColourChange}
value={currentAppearance.hairColour}
>
<option value="brown">{"Brown"}</option>
<option value="black">{"Black"}</option>
<option value="blonde">{"Blonde"}</option>
<option value="red">{"Red"}</option>
<option value="auburn">{"Auburn"}</option>
<option value="silver">{"Silver"}</option>
<option value="blue">{"Blue"}</option>
<option value="purple">{"Purple"}</option>
<option value="pink">{"Pink"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-outfit"
>
{"Outfit"}
</label>
<select
className="character-sheet-select"
id="appearance-outfit"
onChange={handleOutfitChange}
value={currentAppearance.outfit}
>
<option value="warrior">{"Warrior"}</option>
<option value="mage">{"Mage"}</option>
<option value="rogue">{"Rogue"}</option>
<option value="archer">{"Archer"}</option>
<option value="bard">{"Bard"}</option>
<option value="ranger">{"Ranger"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-accessory"
>
{"Accessory"}
</label>
<select
className="character-sheet-select"
id="appearance-accessory"
onChange={handleAccessoryChange}
value={currentAppearance.accessory}
>
<option value="none">{"None"}</option>
<option value="glasses">{"Glasses"}</option>
<option value="hat">{"Hat"}</option>
<option value="cape">{"Cape"}</option>
</select>
</div>
</div>
<div className="character-sheet-section"> <div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3> <h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
{sheet.equippedItems.length > 0 {sheet.equippedItems.length > 0
+23 -44
View File
@@ -12,49 +12,6 @@ import { ConfirmationModal } from "../ui/confirmationModal.js";
type ActiveModal = "force-unlocks" | "hard-reset" | null; type ActiveModal = "force-unlocks" | "hard-reset" | null;
interface ForceUnlocksResult {
adventurersUnlocked: number;
bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number;
questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number;
}
/**
* Builds a human-readable summary of what the force-unlock operation corrected.
* @param result - The counts returned by the force-unlock operation.
* @returns A message string describing what was fixed, or a confirmation that nothing needed fixing.
*/
const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
const entries: Array<[ number, string ]> = [
[ result.zonesUnlocked, "zone(s)" ],
[ result.questsUnlocked, "quest(s)" ],
[ result.bossesUnlocked, "boss(es)" ],
[ result.explorationUnlocked, "exploration area(s)" ],
[ result.adventurersUnlocked, "adventurer tier(s)" ],
[ result.upgradesUnlocked, "upgrade(s)" ],
[ result.equipmentUnlocked, "equipment item(s)" ],
[ result.storyUnlocked, "story chapter(s)" ],
];
const parts = entries.
filter(([ count ]) => {
return count > 0;
}).
map(([ count, label ]) => {
return `${String(count)} ${label}`;
});
if (parts.length === 0) {
return "Everything looks correct — no missing unlocks were found.";
}
const total = entries.reduce((sum, [ count ]) => {
return sum + count;
}, 0);
return `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
};
/** /**
* Renders the debug panel with tools for fixing stuck game state. * Renders the debug panel with tools for fixing stuck game state.
* @returns The JSX element. * @returns The JSX element.
@@ -81,7 +38,29 @@ const DebugPanel = (): JSX.Element => {
setActiveModal(null); setActiveModal(null);
void (async(): Promise<void> => { void (async(): Promise<void> => {
const result = await forceUnlocks(); const result = await forceUnlocks();
setForceUnlocksResult(buildForceUnlocksMessage(result)); const parts: Array<string> = [];
if (result.zonesUnlocked > 0) {
parts.push(`${String(result.zonesUnlocked)} zone(s)`);
}
if (result.questsUnlocked > 0) {
parts.push(`${String(result.questsUnlocked)} quest(s)`);
}
if (result.bossesUnlocked > 0) {
parts.push(`${String(result.bossesUnlocked)} boss(es)`);
}
if (result.explorationUnlocked > 0) {
parts.push(`${String(result.explorationUnlocked)} exploration area(s)`);
}
const total
= result.zonesUnlocked
+ result.questsUnlocked
+ result.bossesUnlocked
+ result.explorationUnlocked;
const message
= parts.length === 0
? "Everything looks correct — no missing unlocks were found."
: `Fixed ${String(total)} unlock(s): ${parts.join(", ")}.`;
setForceUnlocksResult(message);
})(); })();
} }
@@ -31,6 +31,7 @@ import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js"; import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js"; import { OfflineModal } from "./offlineModal.js";
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js"; import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
import { PaperDoll } from "./paperDoll.js";
import { PrestigePanel } from "./prestigePanel.js"; import { PrestigePanel } from "./prestigePanel.js";
import { QuestPanel } from "./questPanel.js"; import { QuestPanel } from "./questPanel.js";
import { QuestCompleteToast, QuestFailedToast } from "./questToast.js"; import { QuestCompleteToast, QuestFailedToast } from "./questToast.js";
@@ -193,6 +194,7 @@ const GameLayout = (): JSX.Element => {
<aside className="game-sidebar"> <aside className="game-sidebar">
<ClickArea /> <ClickArea />
<div id="tree-nation-offset-website" /> <div id="tree-nation-offset-website" />
<PaperDoll />
<p className="game-copyright">{"© NHCarrigan"}</p> <p className="game-copyright">{"© NHCarrigan"}</p>
</aside> </aside>
@@ -0,0 +1,89 @@
/**
* @file Paper doll component for displaying layered adventurer appearance.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Hikari
*/
import {
defaultAppearance,
type HairColour,
type SkinTone,
} from "@elysium/types";
import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js";
import type { JSX } from "react";
/**
* CSS filter strings for each skin tone, applied to the base body layer.
* Uses brightness + sepia + saturation to shift the neutral base skin.
*/
const skinToneFilters: Record<SkinTone, string> = {
dark: "brightness(0.55) saturate(0.55) sepia(0.5) contrast(1.1)",
light: "brightness(0.98) saturate(0.4) sepia(0.12)",
medium: "brightness(0.74) saturate(0.75) sepia(0.42)",
pale: "brightness(1.05) saturate(0.2) sepia(0.08)",
tan: "brightness(0.88) saturate(0.65) sepia(0.28)",
};
/**
* CSS filter strings for each hair colour.
* Applied to the greyscale hair layer via sepia + hue-rotate tinting.
*/
const hairColourFilters: Record<HairColour, string> = {
auburn: "sepia(1) saturate(3) hue-rotate(350deg)",
black: "brightness(0.15)",
blonde: "sepia(1) saturate(3) hue-rotate(5deg) brightness(1.6)",
blue: "sepia(1) saturate(5) hue-rotate(190deg)",
brown: "sepia(1) saturate(2) hue-rotate(0deg)",
pink: "sepia(1) saturate(5) hue-rotate(305deg)",
purple: "sepia(1) saturate(5) hue-rotate(245deg)",
red: "sepia(1) saturate(4) hue-rotate(345deg)",
silver: "grayscale(1) brightness(1.9) contrast(0.8)",
};
/**
* Renders the paper doll — a layered composite of body, outfit, hair, and
* accessory images that together represent the player's adventurer appearance.
* All layers use mix-blend-mode: multiply so white backgrounds become
* transparent, allowing the layers to composite cleanly.
* @returns The JSX element.
*/
const PaperDoll = (): JSX.Element => {
const { state } = useGame();
const appearance = state?.appearance ?? defaultAppearance;
const { skinTone, hairStyle, hairColour, outfit, accessory } = appearance;
return (
<div className="paper-doll">
{/* Base body — skin-toneable */}
<img
alt=""
className="paper-doll-layer paper-doll-body"
src={cdnImage("paper-doll", "body")}
style={{ filter: skinToneFilters[skinTone] }}
/>
{/* Outfit layer */}
<img
alt=""
className="paper-doll-layer paper-doll-outfit"
src={cdnImage("paper-doll", `outfit-${outfit}`)}
/>
{/* Hair layer — colour-tintable greyscale */}
<img
alt=""
className="paper-doll-layer paper-doll-hair"
src={cdnImage("paper-doll", `hair-${hairStyle}`)}
style={{ filter: hairColourFilters[hairColour] }}
/>
{accessory === "none"
? null
: <img
alt=""
className="paper-doll-layer paper-doll-accessory"
src={cdnImage("paper-doll", `accessory-${accessory}`)}
/>}
</div>
);
};
export { PaperDoll };
+3 -45
View File
@@ -4,7 +4,6 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */ /* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */ /* eslint-disable complexity -- Many conditional render paths */
@@ -149,7 +148,8 @@ const QuestCard = ({
&& <p className="quest-failure-chance"> && <p className="quest-failure-chance">
{"🎲 "} {"🎲 "}
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))} {String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
{"% failure chance"} {"% failure chance — if failed, the quest resets"}
{" and must be retried."}
</p> </p>
} }
{quest.status === "available" && quest.lastFailedAt !== undefined {quest.status === "available" && quest.lastFailedAt !== undefined
@@ -208,24 +208,7 @@ const QuestPanel = (): JSX.Element => {
); );
} }
const { adventurers, autoQuest, bosses, quests, zones } = state; const { adventurers, autoQuest, quests, zones } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
let partyCombatPower = 0; let partyCombatPower = 0;
for (const adventurer of adventurers) { for (const adventurer of adventurers) {
const contribution = adventurer.combatPower * adventurer.count; const contribution = adventurer.combatPower * adventurer.count;
@@ -324,31 +307,6 @@ const QuestPanel = (): JSX.Element => {
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<p className="quest-failure-note">
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
</p>
<div className="quest-list"> <div className="quest-list">
{visibleQuests.map((quest) => { {visibleQuests.map((quest) => {
return ( return (
+29 -17
View File
@@ -14,6 +14,7 @@
import { import {
STORY_CHAPTERS, STORY_CHAPTERS,
type Achievement, type Achievement,
type Appearance,
type ApotheosisResponse, type ApotheosisResponse,
type BossChallengeResponse, type BossChallengeResponse,
type ExploreCollectResponse, type ExploreCollectResponse,
@@ -452,6 +453,12 @@ interface GameContextValue {
*/ */
toggleAutoAdventurer: ()=> void; toggleAutoAdventurer: ()=> void;
/**
* Update the player's paper doll appearance customisation.
* @param appearance - The new appearance settings.
*/
updateAppearance: (appearance: Appearance)=> void;
/** /**
* Queue of newly unlocked codex entry IDs (for toast notifications). * Queue of newly unlocked codex entry IDs (for toast notifications).
*/ */
@@ -558,13 +565,9 @@ interface GameContextValue {
* @returns Counts of what was corrected. * @returns Counts of what was corrected.
*/ */
forceUnlocks: ()=> Promise<{ forceUnlocks: ()=> Promise<{
adventurersUnlocked: number;
bossesUnlocked: number; bossesUnlocked: number;
equipmentUnlocked: number;
explorationUnlocked: number; explorationUnlocked: number;
questsUnlocked: number; questsUnlocked: number;
storyUnlocked: number;
upgradesUnlocked: number;
zonesUnlocked: number; zonesUnlocked: number;
}>; }>;
@@ -1148,6 +1151,14 @@ export const GameProvider = ({
}, },
); );
// Quest failure — turn off auto-quest so the player can reassess
if (
newlyFailedQuestsReference.current.length > 0
&& next.autoQuest === true
) {
next = { ...next, autoQuest: false };
}
return next; return next;
}); });
@@ -1312,13 +1323,11 @@ export const GameProvider = ({
/* /*
* "Boss is not currently available" is an expected race condition * "Boss is not currently available" is an expected race condition
* when the client is ahead of the server save — silently skip and * in the tick loop — suppress telemetry for this case only
* let the next tick retry rather than halting automation.
*/ */
if (message === "Boss is not currently available") { if (message !== "Boss is not currently available") {
return;
}
logError("auto_boss", error_); logError("auto_boss", error_);
}
setAutoBossError(message); setAutoBossError(message);
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -1908,6 +1917,15 @@ export const GameProvider = ({
}); });
}, []); }, []);
const updateAppearance = useCallback((appearance: Appearance) => {
setState((previous) => {
if (previous === null) {
return previous;
}
return { ...previous, appearance };
});
}, []);
const setActiveCompanion = useCallback((companionId: string | null) => { const setActiveCompanion = useCallback((companionId: string | null) => {
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -2108,13 +2126,9 @@ export const GameProvider = ({
localStorage.setItem("elysium_save_signature", data.signature); localStorage.setItem("elysium_save_signature", data.signature);
} }
return { return {
adventurersUnlocked: data.adventurersUnlocked,
bossesUnlocked: data.bossesUnlocked, bossesUnlocked: data.bossesUnlocked,
equipmentUnlocked: data.equipmentUnlocked,
explorationUnlocked: data.explorationUnlocked, explorationUnlocked: data.explorationUnlocked,
questsUnlocked: data.questsUnlocked, questsUnlocked: data.questsUnlocked,
storyUnlocked: data.storyUnlocked,
upgradesUnlocked: data.upgradesUnlocked,
zonesUnlocked: data.zonesUnlocked, zonesUnlocked: data.zonesUnlocked,
}; };
} catch (error_: unknown) { } catch (error_: unknown) {
@@ -2124,13 +2138,9 @@ export const GameProvider = ({
: "Failed to force unlocks", : "Failed to force unlocks",
); );
return { return {
adventurersUnlocked: 0,
bossesUnlocked: 0, bossesUnlocked: 0,
equipmentUnlocked: 0,
explorationUnlocked: 0, explorationUnlocked: 0,
questsUnlocked: 0, questsUnlocked: 0,
storyUnlocked: 0,
upgradesUnlocked: 0,
zonesUnlocked: 0, zonesUnlocked: 0,
}; };
} }
@@ -2245,6 +2255,7 @@ export const GameProvider = ({
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
updateAppearance,
}; };
}, [ }, [
apotheosis, apotheosis,
@@ -2317,6 +2328,7 @@ export const GameProvider = ({
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
updateAppearance,
]); ]);
return ( return (
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.2.1", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.2.1", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+9
View File
@@ -5,6 +5,15 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
export type { ApotheosisData } from "./interfaces/apotheosis.js"; export type { ApotheosisData } from "./interfaces/apotheosis.js";
export type {
Accessory,
Appearance,
HairColour,
HairStyle,
Outfit,
SkinTone,
} from "./interfaces/appearance.js";
export { defaultAppearance } from "./interfaces/appearance.js";
export type { export type {
Companion, Companion,
CompanionBonus, CompanionBonus,
-20
View File
@@ -425,26 +425,6 @@ interface ForceUnlocksResponse {
*/ */
explorationUnlocked: number; explorationUnlocked: number;
/**
* Number of adventurer tiers that were unlocked by this operation.
*/
adventurersUnlocked: number;
/**
* Number of upgrades that were unlocked by this operation.
*/
upgradesUnlocked: number;
/**
* Number of equipment items that were marked as owned by this operation.
*/
equipmentUnlocked: number;
/**
* Number of story chapters that were unlocked by this operation.
*/
storyUnlocked: number;
/** /**
* HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity. * HMAC-SHA256 signature of the corrected state for anti-cheat chain continuity.
*/ */
@@ -0,0 +1,50 @@
/**
* @file Appearance type for the paper doll customisation system.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Hikari
*/
type SkinTone = "pale" | "light" | "tan" | "medium" | "dark";
type HairStyle =
| "short"
| "shoulder"
| "long"
| "ponytail"
| "twintails"
| "bun";
type HairColour =
| "brown"
| "black"
| "blonde"
| "red"
| "auburn"
| "silver"
| "blue"
| "purple"
| "pink";
type Outfit = "warrior" | "mage" | "rogue" | "archer" | "bard" | "ranger";
type Accessory = "none" | "glasses" | "hat" | "cape";
interface Appearance {
skinTone: SkinTone;
hairStyle: HairStyle;
hairColour: HairColour;
outfit: Outfit;
accessory: Accessory;
}
const defaultAppearance: Appearance = {
accessory: "none",
hairColour: "brown",
hairStyle: "short",
outfit: "warrior",
skinTone: "pale",
};
export type { Accessory, Appearance, HairColour, HairStyle, Outfit, SkinTone };
export { defaultAppearance };
@@ -7,6 +7,7 @@
import type { Achievement } from "./achievement.js"; import type { Achievement } from "./achievement.js";
import type { Adventurer } from "./adventurer.js"; import type { Adventurer } from "./adventurer.js";
import type { ApotheosisData } from "./apotheosis.js"; import type { ApotheosisData } from "./apotheosis.js";
import type { Appearance } from "./appearance.js";
import type { Boss } from "./boss.js"; import type { Boss } from "./boss.js";
import type { CodexState } from "./codex.js"; import type { CodexState } from "./codex.js";
import type { CompanionState } from "./companion.js"; import type { CompanionState } from "./companion.js";
@@ -98,6 +99,12 @@ interface GameState {
* Schema version — used to detect saves from older game versions. * Schema version — used to detect saves from older game versions.
*/ */
schemaVersion?: number; schemaVersion?: number;
/**
* Paper doll appearance customisation — optional for backwards compatibility.
* Persists across prestige and transcendence resets.
*/
appearance?: Appearance;
} }
export type { GameState }; export type { GameState };