generated from nhcarrigan/template
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
66c2f7e8e9
|
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
@@ -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,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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user