feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
35 changed files with 4722 additions and 94 deletions
Showing only changes of commit 6ddf8e0b43 - Show all commits
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1,8 +1,9 @@
import type { ApotheosisData, GameState, Player, PrestigeData, TranscendenceData } from "@elysium/types";
import type { ApotheosisData, ExplorationState, GameState, Player, PrestigeData, TranscendenceData } from "@elysium/types";
import { DEFAULT_ACHIEVEMENTS } from "./achievements.js";
import { DEFAULT_ADVENTURERS } from "./adventurers.js";
import { DEFAULT_BOSSES } from "./bosses.js";
import { DEFAULT_EQUIPMENT } from "./equipment.js";
import { DEFAULT_EXPLORATIONS } from "./explorations.js";
import { DEFAULT_QUESTS } from "./quests.js";
import { DEFAULT_UPGRADES } from "./upgrades.js";
import { DEFAULT_ZONES } from "./zones.js";
@@ -29,6 +30,19 @@ export const INITIAL_APOTHEOSIS: ApotheosisData = {
count: 0,
};
export const INITIAL_EXPLORATION: ExplorationState = {
areas: DEFAULT_EXPLORATIONS.map((area) => ({
id: area.id,
status: area.zoneId === "verdant_vale" ? "available" as const : "locked" as const,
})),
materials: [],
craftedRecipeIds: [],
craftedGoldMultiplier: 1,
craftedEssenceMultiplier: 1,
craftedClickMultiplier: 1,
craftedCombatMultiplier: 1,
};
export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameState => ({
player: {
...player,
@@ -54,4 +68,5 @@ export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameS
lastTickAt: Date.now(),
transcendence: { ...INITIAL_TRANSCENDENCE },
apotheosis: { ...INITIAL_APOTHEOSIS },
exploration: structuredClone(INITIAL_EXPLORATION),
});
+93
View File
@@ -0,0 +1,93 @@
import type { Material } from "@elysium/types";
export const DEFAULT_MATERIALS: Material[] = [
// Zone 1: verdant_vale
{ id: "verdant_sap", name: "Verdant Sap", description: "Sticky resin tapped from ancient heartwood trees. Smells faintly of spring rain and something older beneath.", zoneId: "verdant_vale", rarity: "common" },
{ id: "forest_crystal", name: "Forest Crystal", description: "A translucent gem found buried near the roots of old-growth trees. Pulses with gentle life energy when held.", zoneId: "verdant_vale", rarity: "uncommon" },
{ id: "elder_bark", name: "Elder Bark", description: "Bark from a tree that has stood since before the kingdom. Harder than iron and warmer to the touch than it has any right to be.", zoneId: "verdant_vale", rarity: "rare" },
// Zone 2: shattered_ruins
{ id: "ruin_dust", name: "Ruin Dust", description: "Fine powder ground from fallen masonry. Still carries traces of the civilisation it once was — if you know how to read the patterns.", zoneId: "shattered_ruins", rarity: "common" },
{ id: "cursed_fragment", name: "Cursed Fragment", description: "A shard of enchanted stonework. The enchantment is broken, but something lingers in the grain of the stone, waiting.", zoneId: "shattered_ruins", rarity: "uncommon" },
{ id: "dragonscale_chip", name: "Dragonscale Chip", description: "A fragment of scale shed during the elder dragon's long reign over the ruins. Resistant to fire and magic alike.", zoneId: "shattered_ruins", rarity: "rare" },
// Zone 3: frozen_peaks
{ id: "glacial_ice", name: "Glacial Ice", description: "Ice from a glacier that has not moved in ten thousand years. Impossibly cold and perfectly clear, with something almost visible within.", zoneId: "frozen_peaks", rarity: "common" },
{ id: "frost_crystal", name: "Frost Crystal", description: "A crystal that formed inside the glacier itself over millennia. It never melts, not even near fire.", zoneId: "frozen_peaks", rarity: "uncommon" },
{ id: "void_shard", name: "Void Shard", description: "A fragment of the reality tear. It hums with wrongness that the fingers instinctively recognise before the mind does.", zoneId: "frozen_peaks", rarity: "rare" },
// Zone 4: shadow_marshes
{ id: "marsh_root", name: "Marsh Root", description: "Roots from the strangler plants that thrive in the fog-choked depths. Toxic without extensive preparation. Worth it, usually.", zoneId: "shadow_marshes", rarity: "common" },
{ id: "shadow_essence", name: "Shadow Essence", description: "Distilled darkness, caught in a vial before it could dissipate. Heavy and cold, and absolutely lightless.", zoneId: "shadow_marshes", rarity: "uncommon" },
{ id: "cursed_bone", name: "Cursed Bone", description: "Bone from something that died in the marsh so long ago it has become part of it. The curse runs deep through the marrow.", zoneId: "shadow_marshes", rarity: "rare" },
// Zone 5: volcanic_depths
{ id: "magma_stone", name: "Magma Stone", description: "Cooled lava that retained its internal heat. Warm to the touch even centuries after solidifying from whatever it once was.", zoneId: "volcanic_depths", rarity: "common" },
{ id: "ember_crystal", name: "Ember Crystal", description: "A crystal grown in the heart of a cooling magma chamber. Burns without being consumed, endlessly.", zoneId: "volcanic_depths", rarity: "uncommon" },
{ id: "legendary_ore", name: "Legendary Ore", description: "Ore from a seam that the fire elementals guard jealously. What it forges into is extraordinary by any measure.", zoneId: "volcanic_depths", rarity: "rare" },
// Zone 6: astral_void
{ id: "stardust", name: "Stardust", description: "Particulate matter from dying stars, collected from the void between worlds. Glitters even in total darkness.", zoneId: "astral_void", rarity: "common" },
{ id: "astral_thread", name: "Astral Thread", description: "Filaments of solidified probability. Handle with care — they remember every possible future they passed through.", zoneId: "astral_void", rarity: "uncommon" },
{ id: "void_crystal", name: "Void Crystal", description: "A crystal that formed in the spaces between spaces. Technically exists in several places simultaneously. Don't think too hard about it.", zoneId: "astral_void", rarity: "rare" },
// Zone 7: celestial_reaches
{ id: "celestial_dust", name: "Celestial Dust", description: "Residue from the celestial host's passing. Warm as sunlight and infinitely patient, as if waiting for something to happen.", zoneId: "celestial_reaches", rarity: "common" },
{ id: "divine_fragment", name: "Divine Fragment", description: "A chip of something the celestials discarded as imperfect. By mortal standards, it is extraordinary beyond measure.", zoneId: "celestial_reaches", rarity: "uncommon" },
{ id: "choir_shard", name: "Choir Shard", description: "A crystallised harmonic from the celestial choir. Resonates with a sound felt in the chest rather than heard with the ears.", zoneId: "celestial_reaches", rarity: "rare" },
// Zone 8: abyssal_trench
{ id: "trench_coral", name: "Trench Coral", description: "Coral from the deepest trenches where no light reaches and no warmth remains. Black as the water around it.", zoneId: "abyssal_trench", rarity: "common" },
{ id: "pressure_gem", name: "Pressure Gem", description: "A gem compressed by aeons of unimaginable pressure at the bottom of all things. Impossibly dense for its size.", zoneId: "abyssal_trench", rarity: "uncommon" },
{ id: "ancient_tooth", name: "Ancient Tooth", description: "A tooth from whatever has been waiting in the trench since before your world was made. It is very large.", zoneId: "abyssal_trench", rarity: "rare" },
// Zone 9: infernal_court
{ id: "brimstone_flake", name: "Brimstone Flake", description: "Sulphur residue from the court's perpetual fires. The smell never fully fades, no matter how carefully it is stored.", zoneId: "infernal_court", rarity: "common" },
{ id: "demon_ichor", name: "Demon Ichor", description: "Extracted from the court's refuse. Corrosive, powerful, and deeply unpleasant in every measurable way.", zoneId: "infernal_court", rarity: "uncommon" },
{ id: "soul_residue", name: "Soul Residue", description: "What remains after a soul has been fully processed by the court. Carries faint echoes of what it was before.", zoneId: "infernal_court", rarity: "rare" },
// Zone 10: crystalline_spire
{ id: "prism_dust", name: "Prism Dust", description: "Ground from the spire's outer facets. Each particle contains a compressed possibility that has not yet resolved.", zoneId: "crystalline_spire", rarity: "common" },
{ id: "calculation_shard", name: "Calculation Shard", description: "A fragment of the spire's core intelligence. Still running calculations on something that may or may not have an answer.", zoneId: "crystalline_spire", rarity: "uncommon" },
{ id: "possibility_crystal", name: "Possibility Crystal", description: "A crystal that contains a future that never happened. Treat carefully. The future remembers being possible.", zoneId: "crystalline_spire", rarity: "rare" },
// Zone 11: void_sanctum
{ id: "null_matter", name: "Null Matter", description: "Matter that exists in the space between spaces. Lacks most standard properties in ways that should not be possible.", zoneId: "void_sanctum", rarity: "common" },
{ id: "resonance_fragment", name: "Resonance Fragment", description: "A shard of the call that drew your guild here. Still resonant, still reaching toward something none of you can name.", zoneId: "void_sanctum", rarity: "uncommon" },
{ id: "sanctum_core", name: "Sanctum Core", description: "From the heart of the sanctum itself. What it does is undefined. What it is cannot be satisfactorily described.", zoneId: "void_sanctum", rarity: "rare" },
// Zone 12: eternal_throne
{ id: "throne_dust", name: "Throne Dust", description: "Residue from the base of the eternal throne. Old beyond any measurement that applies to things your guild understands.", zoneId: "eternal_throne", rarity: "common" },
{ id: "crown_fragment", name: "Crown Fragment", description: "A chip from one of the throne's crown-like spires. Authority made into something your hands can hold.", zoneId: "eternal_throne", rarity: "uncommon" },
{ id: "eternity_splinter", name: "Eternity Splinter", description: "From the throne's arm. Carries the weight of every decision ever made here, compressed into splinter-form.", zoneId: "eternal_throne", rarity: "rare" },
// Zone 13: primordial_chaos
{ id: "chaos_fragment", name: "Chaos Fragment", description: "A solidified moment of chaos. Still undecided about its own properties, which change depending on how you look at it.", zoneId: "primordial_chaos", rarity: "common" },
{ id: "creation_shard", name: "Creation Shard", description: "A fragment from when something was being made here. What was being made is unclear. Something important, probably.", zoneId: "primordial_chaos", rarity: "uncommon" },
{ id: "primordial_essence", name: "Primordial Essence", description: "The raw stuff of creation, before it became anything specific. Handle with care. It wants to become things.", zoneId: "primordial_chaos", rarity: "rare" },
// Zone 14: infinite_expanse
{ id: "expanse_dust", name: "Expanse Dust", description: "Gathered from somewhere in the expanse. Direction is uncertain. Distance from the collection point is uncertain.", zoneId: "infinite_expanse", rarity: "common" },
{ id: "distance_crystal", name: "Distance Crystal", description: "A crystal that contains compressed distance. It weighs more than its size suggests. Much more. Do not drop it.", zoneId: "infinite_expanse", rarity: "uncommon" },
{ id: "infinity_shard", name: "Infinity Shard", description: "A fragment of the expanse's edge, which the expanse does not technically have. This should not exist. It does anyway.", zoneId: "infinite_expanse", rarity: "rare" },
// Zone 15: reality_forge
{ id: "forge_ash", name: "Forge Ash", description: "Ash from the forge's fires. Contains fragments of unrealised realities that never quite made it to existence.", zoneId: "reality_forge", rarity: "common" },
{ id: "creation_tool", name: "Creation Tool", description: "A worn tool left by whatever worked here before your universe existed. Still functional in ways that are difficult to explain.", zoneId: "reality_forge", rarity: "uncommon" },
{ id: "reality_shard", name: "Reality Shard", description: "A flawed reality, discarded by the forge as below standard. Still contains everything a universe needs.", zoneId: "reality_forge", rarity: "rare" },
// Zone 16: cosmic_maelstrom
{ id: "maelstrom_debris", name: "Maelstrom Debris", description: "Debris from a galaxy that got too close to the maelstrom. Compressed to a size your guild can actually carry.", zoneId: "cosmic_maelstrom", rarity: "common" },
{ id: "force_crystal", name: "Force Crystal", description: "A crystal that formed at the intersection of several fundamental forces that should never have been in the same place.", zoneId: "cosmic_maelstrom", rarity: "uncommon" },
{ id: "cosmic_fragment", name: "Cosmic Fragment", description: "A fragment from the maelstrom's eye. Impossibly calm. Whatever is at the centre has been there since the beginning.", zoneId: "cosmic_maelstrom", rarity: "rare" },
// Zone 17: primeval_sanctum
{ id: "ancient_dust", name: "Ancient Dust", description: "Dust from the oldest place. Has been here since before the concept of 'here' had been invented.", zoneId: "primeval_sanctum", rarity: "common" },
{ id: "memory_shard", name: "Memory Shard", description: "A shard of something that remembers the moment before the first moment. The memory is in the material itself.", zoneId: "primeval_sanctum", rarity: "uncommon" },
{ id: "primeval_relic", name: "Primeval Relic", description: "An artefact from the first thing to exist in this place. What it did is unknown. That it mattered is beyond doubt.", zoneId: "primeval_sanctum", rarity: "rare" },
// Zone 18: the_absolute
{ id: "absolute_fragment", name: "Absolute Fragment", description: "A fragment of the final truth. It is difficult to look at directly, and impossible to look away from once you start.", zoneId: "the_absolute", rarity: "common" },
{ id: "boundary_shard", name: "Boundary Shard", description: "From the edge of everything. On one side: everything. On the other: nothing. This is from the very boundary between them.", zoneId: "the_absolute", rarity: "uncommon" },
{ id: "omega_crystal", name: "Omega Crystal", description: "The last crystal. After this, there are no more. It knows this. You can tell from the way it sits in your hand.", zoneId: "the_absolute", rarity: "rare" },
];
+327
View File
@@ -0,0 +1,327 @@
import type { CraftingRecipe } from "@elysium/types";
export const DEFAULT_RECIPES: CraftingRecipe[] = [
// Zone 1: verdant_vale
{
id: "heartwood_tincture",
name: "Heartwood Tincture",
description: "Sap from ancient heartwood trees, refined and bound with forest crystal. The resulting tincture accelerates the flow of wealth through your guild in ways the alchemists cannot fully explain.",
zoneId: "verdant_vale",
requiredMaterials: [{ materialId: "verdant_sap", quantity: 5 }, { materialId: "forest_crystal", quantity: 3 }],
bonus: { type: "gold_income", value: 1.05 },
},
{
id: "elder_bark_shield",
name: "Elder Bark Shield",
description: "A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
zoneId: "verdant_vale",
requiredMaterials: [{ materialId: "elder_bark", quantity: 2 }, { materialId: "verdant_sap", quantity: 8 }],
bonus: { type: "combat_power", value: 1.08 },
},
// Zone 2: shattered_ruins
{
id: "runic_binding",
name: "Runic Binding",
description: "The ruin dust and cursed fragments, carefully worked into a binding that borrows the essence-drawing power of the fallen civilisation's final enchantments.",
zoneId: "shattered_ruins",
requiredMaterials: [{ materialId: "ruin_dust", quantity: 8 }, { materialId: "cursed_fragment", quantity: 4 }],
bonus: { type: "essence_income", value: 1.05 },
},
{
id: "dragon_scale_charm",
name: "Dragon Scale Charm",
description: "A charm set with a chip of the elder dragon's scale. The dragon would be furious if he knew. He would also be impressed.",
zoneId: "shattered_ruins",
requiredMaterials: [{ materialId: "dragonscale_chip", quantity: 2 }, { materialId: "ruin_dust", quantity: 10 }],
bonus: { type: "gold_income", value: 1.08 },
},
// Zone 3: frozen_peaks
{
id: "glacial_lens",
name: "Glacial Lens",
description: "Glacial ice ground and shaped into a lens that clarifies and focuses. Holding it, your guild's actions become sharper, more precise, more effective per motion.",
zoneId: "frozen_peaks",
requiredMaterials: [{ materialId: "glacial_ice", quantity: 8 }, { materialId: "frost_crystal", quantity: 4 }],
bonus: { type: "click_power", value: 1.08 },
},
{
id: "void_fragment_amulet",
name: "Void Fragment Amulet",
description: "The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
zoneId: "frozen_peaks",
requiredMaterials: [{ materialId: "void_shard", quantity: 2 }, { materialId: "frost_crystal", quantity: 6 }],
bonus: { type: "gold_income", value: 1.10 },
},
// Zone 4: shadow_marshes
{
id: "shadow_extract",
name: "Shadow Extract",
description: "Marsh roots processed with shadow essence into a refined compound that somehow makes the essence of things flow more freely toward your guild hall.",
zoneId: "shadow_marshes",
requiredMaterials: [{ materialId: "marsh_root", quantity: 8 }, { materialId: "shadow_essence", quantity: 4 }],
bonus: { type: "essence_income", value: 1.08 },
},
{
id: "cursed_focus",
name: "Cursed Focus",
description: "The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
zoneId: "shadow_marshes",
requiredMaterials: [{ materialId: "cursed_bone", quantity: 2 }, { materialId: "shadow_essence", quantity: 6 }],
bonus: { type: "combat_power", value: 1.10 },
},
// Zone 5: volcanic_depths
{
id: "magma_core_seal",
name: "Magma Core Seal",
description: "A seal forged in the volcanic depths, using the eternal heat of the magma stone and ember crystal to create something that burns wealth into existence continuously.",
zoneId: "volcanic_depths",
requiredMaterials: [{ materialId: "magma_stone", quantity: 8 }, { materialId: "ember_crystal", quantity: 4 }],
bonus: { type: "gold_income", value: 1.10 },
},
{
id: "elemental_ore_ingot",
name: "Elemental Ore Ingot",
description: "The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
zoneId: "volcanic_depths",
requiredMaterials: [{ materialId: "legendary_ore", quantity: 2 }, { materialId: "magma_stone", quantity: 10 }],
bonus: { type: "combat_power", value: 1.12 },
},
// Zone 6: astral_void
{
id: "star_chart",
name: "Star Chart",
description: "Stardust arranged along astral threads into a map of the void that somehow, impossibly, shows your guild where to press and how to press it for maximum effect.",
zoneId: "astral_void",
requiredMaterials: [{ materialId: "stardust", quantity: 10 }, { materialId: "astral_thread", quantity: 4 }],
bonus: { type: "click_power", value: 1.12 },
},
{
id: "void_crystal_matrix",
name: "Void Crystal Matrix",
description: "A void crystal suspended in a matrix of stardust — something that exists in several places simultaneously and draws gold from all of them at once.",
zoneId: "astral_void",
requiredMaterials: [{ materialId: "void_crystal", quantity: 2 }, { materialId: "stardust", quantity: 12 }],
bonus: { type: "gold_income", value: 1.12 },
},
// Zone 7: celestial_reaches
{
id: "celestial_lens",
name: "Celestial Lens",
description: "Celestial dust and divine fragments ground into a lens that sees the essence in all things and draws a portion of it — gently, as the celestials would prefer.",
zoneId: "celestial_reaches",
requiredMaterials: [{ materialId: "celestial_dust", quantity: 10 }, { materialId: "divine_fragment", quantity: 4 }],
bonus: { type: "essence_income", value: 1.12 },
},
{
id: "choir_resonator",
name: "Choir Resonator",
description: "A choir shard set in divine fragments, still humming with the celestial harmonic. The resonance makes gold flow in its direction — not compelled, simply invited.",
zoneId: "celestial_reaches",
requiredMaterials: [{ materialId: "choir_shard", quantity: 2 }, { materialId: "divine_fragment", quantity: 6 }],
bonus: { type: "gold_income", value: 1.15 },
},
// Zone 8: abyssal_trench
{
id: "pressure_forged_core",
name: "Pressure-Forged Core",
description: "Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
zoneId: "abyssal_trench",
requiredMaterials: [{ materialId: "trench_coral", quantity: 10 }, { materialId: "pressure_gem", quantity: 4 }],
bonus: { type: "combat_power", value: 1.15 },
},
{
id: "ancient_fang_talisman",
name: "Ancient Fang Talisman",
description: "A talisman set with the ancient tooth, suspended in trench coral carvings. Your party fights differently with this at their chest. More deliberately. More completely.",
zoneId: "abyssal_trench",
requiredMaterials: [{ materialId: "ancient_tooth", quantity: 2 }, { materialId: "trench_coral", quantity: 12 }],
bonus: { type: "click_power", value: 1.15 },
},
// Zone 9: infernal_court
{
id: "court_seal",
name: "Court Seal",
description: "A seal of infernal court authority, forged from brimstone and ichor. The court doesn't know you have this. It's better that way. It does make trade extremely efficient.",
zoneId: "infernal_court",
requiredMaterials: [{ materialId: "brimstone_flake", quantity: 10 }, { materialId: "demon_ichor", quantity: 5 }],
bonus: { type: "gold_income", value: 1.15 },
},
{
id: "soul_bound_catalyst",
name: "Soul-Bound Catalyst",
description: "Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
zoneId: "infernal_court",
requiredMaterials: [{ materialId: "soul_residue", quantity: 2 }, { materialId: "demon_ichor", quantity: 8 }],
bonus: { type: "essence_income", value: 1.15 },
},
// Zone 10: crystalline_spire
{
id: "prism_array",
name: "Prism Array",
description: "Prism dust and calculation shards assembled into an array that the spire's intelligence would call elegant, if it had aesthetic preferences, which it might.",
zoneId: "crystalline_spire",
requiredMaterials: [{ materialId: "prism_dust", quantity: 10 }, { materialId: "calculation_shard", quantity: 4 }],
bonus: { type: "click_power", value: 1.18 },
},
{
id: "possibility_engine",
name: "Possibility Engine",
description: "A possibility crystal contained within a calculation shard framework. It runs through every possible outcome of every guild action and finds the one with the highest gold yield.",
zoneId: "crystalline_spire",
requiredMaterials: [{ materialId: "possibility_crystal", quantity: 2 }, { materialId: "calculation_shard", quantity: 6 }],
bonus: { type: "gold_income", value: 1.18 },
},
// Zone 11: void_sanctum
{
id: "null_field_generator",
name: "Null Field Generator",
description: "Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
zoneId: "void_sanctum",
requiredMaterials: [{ materialId: "null_matter", quantity: 10 }, { materialId: "resonance_fragment", quantity: 4 }],
bonus: { type: "combat_power", value: 1.18 },
},
{
id: "sanctum_key",
name: "Sanctum Key",
description: "A sanctum core and resonance fragments shaped into a key to something. The essence flows through it like it was designed to carry essence, which it may have been.",
zoneId: "void_sanctum",
requiredMaterials: [{ materialId: "sanctum_core", quantity: 2 }, { materialId: "resonance_fragment", quantity: 6 }],
bonus: { type: "essence_income", value: 1.18 },
},
// Zone 12: eternal_throne
{
id: "crown_circlet",
name: "Crown Circlet",
description: "Throne dust pressed into throne dust-lacquered crown fragments, shaped into a circlet. Wearing it — metaphorically — makes gold accumulate with the inevitability of authority.",
zoneId: "eternal_throne",
requiredMaterials: [{ materialId: "throne_dust", quantity: 10 }, { materialId: "crown_fragment", quantity: 4 }],
bonus: { type: "gold_income", value: 1.20 },
},
{
id: "eternity_bound_ring",
name: "Eternity-Bound Ring",
description: "An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
zoneId: "eternal_throne",
requiredMaterials: [{ materialId: "eternity_splinter", quantity: 2 }, { materialId: "crown_fragment", quantity: 6 }],
bonus: { type: "combat_power", value: 1.20 },
},
// Zone 13: primordial_chaos
{
id: "chaos_lens",
name: "Chaos Lens",
description: "Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
zoneId: "primordial_chaos",
requiredMaterials: [{ materialId: "chaos_fragment", quantity: 10 }, { materialId: "creation_shard", quantity: 4 }],
bonus: { type: "click_power", value: 1.20 },
},
{
id: "creation_core",
name: "Creation Core",
description: "Primordial essence held in a creation shard framework. It hums constantly. Gold flows toward it with the enthusiasm of something that wants to become something.",
zoneId: "primordial_chaos",
requiredMaterials: [{ materialId: "primordial_essence", quantity: 2 }, { materialId: "creation_shard", quantity: 6 }],
bonus: { type: "gold_income", value: 1.22 },
},
// Zone 14: infinite_expanse
{
id: "distance_coil",
name: "Distance Coil",
description: "Expanse dust wound around distance crystals into a coil that draws essence from distances too vast to measure, compressing it into something your guild can actually use.",
zoneId: "infinite_expanse",
requiredMaterials: [{ materialId: "expanse_dust", quantity: 10 }, { materialId: "distance_crystal", quantity: 4 }],
bonus: { type: "essence_income", value: 1.20 },
},
{
id: "infinity_prism",
name: "Infinity Prism",
description: "An infinity shard mounted in a distance crystal frame. The prism reflects gold from an infinite number of directions simultaneously. The math works out favourably.",
zoneId: "infinite_expanse",
requiredMaterials: [{ materialId: "infinity_shard", quantity: 2 }, { materialId: "distance_crystal", quantity: 6 }],
bonus: { type: "gold_income", value: 1.22 },
},
// Zone 15: reality_forge
{
id: "reality_ingot",
name: "Reality Ingot",
description: "Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
zoneId: "reality_forge",
requiredMaterials: [{ materialId: "forge_ash", quantity: 10 }, { materialId: "creation_tool", quantity: 4 }],
bonus: { type: "combat_power", value: 1.22 },
},
{
id: "universe_seed",
name: "Universe Seed",
description: "A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
zoneId: "reality_forge",
requiredMaterials: [{ materialId: "reality_shard", quantity: 2 }, { materialId: "creation_tool", quantity: 6 }],
bonus: { type: "click_power", value: 1.22 },
},
// Zone 16: cosmic_maelstrom
{
id: "force_lens",
name: "Force Lens",
description: "Maelstrom debris and force crystals ground into a lens at the intersection of fundamental forces. Gold flows toward it with the same inevitability that galaxies flow toward gravity.",
zoneId: "cosmic_maelstrom",
requiredMaterials: [{ materialId: "maelstrom_debris", quantity: 10 }, { materialId: "force_crystal", quantity: 4 }],
bonus: { type: "gold_income", value: 1.25 },
},
{
id: "maelstrom_eye",
name: "Maelstrom Eye",
description: "A cosmic fragment suspended in a force crystal matrix — a piece of the maelstrom's impossible calm, holding the eye of the storm. Essence accumulates in its vicinity.",
zoneId: "cosmic_maelstrom",
requiredMaterials: [{ materialId: "cosmic_fragment", quantity: 2 }, { materialId: "force_crystal", quantity: 6 }],
bonus: { type: "essence_income", value: 1.22 },
},
// Zone 17: primeval_sanctum
{
id: "ancient_memory_array",
name: "Ancient Memory Array",
description: "Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
zoneId: "primeval_sanctum",
requiredMaterials: [{ materialId: "ancient_dust", quantity: 10 }, { materialId: "memory_shard", quantity: 4 }],
bonus: { type: "combat_power", value: 1.25 },
},
{
id: "first_artefact",
name: "First Artefact",
description: "The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
zoneId: "primeval_sanctum",
requiredMaterials: [{ materialId: "primeval_relic", quantity: 2 }, { materialId: "memory_shard", quantity: 6 }],
bonus: { type: "click_power", value: 1.25 },
},
// Zone 18: the_absolute
{
id: "final_truth_lens",
name: "Final Truth Lens",
description: "Absolute fragments and boundary shards ground into a lens that sees to the end of all things — and in seeing, draws the wealth inherent in finality toward your guild.",
zoneId: "the_absolute",
requiredMaterials: [{ materialId: "absolute_fragment", quantity: 10 }, { materialId: "boundary_shard", quantity: 4 }],
bonus: { type: "gold_income", value: 1.30 },
},
{
id: "omega_convergence",
name: "Omega Convergence",
description: "The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
zoneId: "the_absolute",
requiredMaterials: [{ materialId: "omega_crystal", quantity: 2 }, { materialId: "boundary_shard", quantity: 6 }],
bonus: { type: "combat_power", value: 1.30 },
},
];
+4
View File
@@ -6,6 +6,8 @@ import { aboutRouter } from "./routes/about.js";
import { apotheosisRouter } from "./routes/apotheosis.js";
import { authRouter } from "./routes/auth.js";
import { bossRouter } from "./routes/boss.js";
import { craftRouter } from "./routes/craft.js";
import { exploreRouter } from "./routes/explore.js";
import { gameRouter } from "./routes/game.js";
import { prestigeRouter } from "./routes/prestige.js";
import { transcendenceRouter } from "./routes/transcendence.js";
@@ -27,6 +29,8 @@ app.route("/about", aboutRouter);
app.route("/auth", authRouter);
app.route("/game", gameRouter);
app.route("/boss", bossRouter);
app.route("/explore", exploreRouter);
app.route("/craft", craftRouter);
app.route("/prestige", prestigeRouter);
app.route("/transcendence", transcendenceRouter);
app.route("/apotheosis", apotheosisRouter);
+3 -9
View File
@@ -1,4 +1,4 @@
import type { ApotheosisRequest, GameState } from "@elysium/types";
import type { GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
@@ -14,12 +14,6 @@ apotheosisRouter.use("*", authMiddleware);
apotheosisRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<ApotheosisRequest>();
const characterName = body.characterName?.trim();
if (!characterName) {
return context.json({ error: "characterName is required" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
@@ -41,7 +35,7 @@ apotheosisRouter.post("/", async (context) => {
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => sum + a.count, 0);
const runAchievementsUnlocked = (state.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
const { newState, newApotheosisData } = buildPostApotheosisState(state, characterName);
const { newState, newApotheosisData } = buildPostApotheosisState(state, state.player.characterName);
const now = Date.now();
await prisma.gameState.update({
@@ -52,7 +46,7 @@ apotheosisRouter.post("/", async (context) => {
await prisma.player.update({
where: { discordId },
data: {
characterName,
characterName: state.player.characterName,
// Reset current-run counters
totalGoldEarned: 0,
totalClicks: 0,
+2 -1
View File
@@ -60,7 +60,8 @@ const calculatePartyStats = (
}
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier;
const craftedCombatMultiplier = state.exploration?.craftedCombatMultiplier ?? 1;
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier * craftedCombatMultiplier;
return { partyDPS, partyMaxHp };
};
+103
View File
@@ -0,0 +1,103 @@
import type { CraftRecipeRequest, CraftRecipeResponse, GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { DEFAULT_RECIPES } from "../data/recipes.js";
import { authMiddleware } from "../middleware/auth.js";
export const craftRouter = new Hono<HonoEnv>();
craftRouter.use("*", authMiddleware);
const recomputeCraftedMultipliers = (
craftedRecipeIds: string[],
): {
craftedGoldMultiplier: number;
craftedEssenceMultiplier: number;
craftedClickMultiplier: number;
craftedCombatMultiplier: number;
} => ({
craftedGoldMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "gold_income")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedEssenceMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "essence_income")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedClickMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "click_power")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedCombatMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "combat_power")
.reduce((mult, r) => mult * r.bonus.value, 1),
});
craftRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<CraftRecipeRequest>();
const { recipeId } = body;
if (!recipeId) {
return context.json({ error: "recipeId is required" }, 400);
}
const recipe = DEFAULT_RECIPES.find((r) => r.id === recipeId);
if (!recipe) {
return context.json({ error: "Unknown recipe" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
if (state.exploration.craftedRecipeIds.includes(recipeId)) {
return context.json({ error: "Recipe already crafted" }, 400);
}
// Verify the player has all required materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => m.materialId === requirement.materialId);
const quantity = material?.quantity ?? 0;
if (quantity < requirement.quantity) {
return context.json(
{ error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})` },
400,
);
}
}
// Deduct materials
for (const requirement of recipe.requiredMaterials) {
const material = state.exploration.materials.find((m) => m.materialId === requirement.materialId);
if (material) {
material.quantity -= requirement.quantity;
}
}
// Add recipe and recompute all multipliers from scratch
state.exploration.craftedRecipeIds.push(recipeId);
const newMultipliers = recomputeCraftedMultipliers(state.exploration.craftedRecipeIds);
state.exploration.craftedGoldMultiplier = newMultipliers.craftedGoldMultiplier;
state.exploration.craftedEssenceMultiplier = newMultipliers.craftedEssenceMultiplier;
state.exploration.craftedClickMultiplier = newMultipliers.craftedClickMultiplier;
state.exploration.craftedCombatMultiplier = newMultipliers.craftedCombatMultiplier;
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: Date.now() },
});
const response: CraftRecipeResponse = {
recipeId,
bonusType: recipe.bonus.type,
bonusValue: recipe.bonus.value,
...newMultipliers,
};
return context.json(response);
});
+263
View File
@@ -0,0 +1,263 @@
import type {
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
GameState,
} from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { DEFAULT_EXPLORATIONS } from "../data/explorations.js";
import { INITIAL_EXPLORATION } from "../data/initialState.js";
import { authMiddleware } from "../middleware/auth.js";
export const exploreRouter = new Hono<HonoEnv>();
exploreRouter.use("*", authMiddleware);
const NOTHING_PROBABILITY = 0.2;
const NOTHING_MESSAGES = [
"Your scouts searched thoroughly but found nothing of value.",
"The area yielded nothing remarkable this time.",
"Your scouts returned empty-handed.",
"A wasted journey — the area proved barren.",
"Nothing to show for the effort. Perhaps next time.",
];
const pickNothingMessage = (): string =>
NOTHING_MESSAGES[Math.floor(Math.random() * NOTHING_MESSAGES.length)] ?? NOTHING_MESSAGES[0]!;
exploreRouter.post("/start", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<ExploreStartRequest>();
const { areaId } = body;
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = DEFAULT_EXPLORATIONS.find((a) => a.id === areaId);
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
// Backfill exploration state for old saves that predate this feature
if (!state.exploration) {
state.exploration = structuredClone(INITIAL_EXPLORATION);
// Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) {
const areaData = DEFAULT_EXPLORATIONS.find((e) => e.id === area.id);
if (!areaData) continue;
const zone = state.zones.find((z) => z.id === areaData.zoneId);
if (zone?.status === "unlocked") {
area.status = "available";
}
}
}
const zone = state.zones.find((z) => z.id === explorationArea.zoneId);
if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400);
}
const area = state.exploration.areas.find((a) => a.id === areaId);
if (!area) {
return context.json({ error: "Exploration area not found in state" }, 404);
}
if (area.status === "in_progress") {
return context.json({ error: "Exploration already in progress" }, 400);
}
if (area.status === "locked") {
return context.json({ error: "Exploration area is locked" }, 400);
}
const now = Date.now();
area.status = "in_progress";
area.startedAt = now;
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const response: ExploreStartResponse = {
areaId,
endsAt: now + explorationArea.durationSeconds * 1000,
};
return context.json(response);
});
exploreRouter.post("/collect", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<ExploreCollectRequest>();
const { areaId } = body;
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = DEFAULT_EXPLORATIONS.find((a) => a.id === areaId);
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
const area = state.exploration.areas.find((a) => a.id === areaId);
if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
if (area.status !== "in_progress") {
return context.json({ error: "Exploration is not in progress" }, 400);
}
const now = Date.now();
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
if (now < startedAt + durationMs) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
area.status = "available";
area.completedOnce = true;
// 20% chance of finding nothing
if (Math.random() < NOTHING_PROBABILITY) {
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const response: ExploreCollectResponse = {
foundNothing: true,
nothingMessage: pickNothingMessage(),
materialsFound: [],
event: null,
};
return context.json(response);
}
// Pick a random event
const event = explorationArea.events[Math.floor(Math.random() * explorationArea.events.length)]!;
// Apply event effects and build the result summary
let goldChange = 0;
let essenceChange = 0;
let materialGained: { materialId: string; quantity: number } | null = null;
if (event.effect.type === "gold_gain") {
const amount = event.effect.amount ?? 0;
state.resources.gold += amount;
state.player.totalGoldEarned += amount;
goldChange = amount;
} else if (event.effect.type === "gold_loss") {
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
state.resources.gold -= amount;
goldChange = -amount;
} else if (event.effect.type === "essence_gain") {
const amount = event.effect.amount ?? 0;
state.resources.essence += amount;
essenceChange = amount;
} else if (event.effect.type === "material_gain") {
const materialId = event.effect.materialId;
const quantity = event.effect.quantity ?? 1;
if (materialId) {
const existing = state.exploration.materials.find((m) => m.materialId === materialId);
if (existing) {
existing.quantity += quantity;
} else {
state.exploration.materials.push({ materialId, quantity });
}
materialGained = { materialId, quantity };
}
} else if (event.effect.type === "adventurer_loss") {
const fraction = event.effect.fraction ?? 0.05;
let totalLost = 0;
for (const adventurer of state.adventurers) {
const lost = Math.floor(adventurer.count * fraction);
if (lost > 0) {
adventurer.count = Math.max(0, adventurer.count - lost);
totalLost += lost;
}
}
// adventurerLostCount captured below
}
const adventurerLostCount =
event.effect.type === "adventurer_loss"
? state.adventurers.reduce((sum, a) => {
const fraction = event.effect.fraction ?? 0.05;
return sum + Math.floor(a.count * fraction);
}, 0)
: 0;
const eventResult: ExploreCollectEventResult = {
text: event.text,
goldChange,
essenceChange,
materialGained,
adventurerLostCount,
};
// Roll for material drops from possibleMaterials (weighted random selection)
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
if (explorationArea.possibleMaterials.length > 0) {
const totalWeight = explorationArea.possibleMaterials.reduce((sum, m) => sum + m.weight, 0);
let roll = Math.random() * totalWeight;
for (const possible of explorationArea.possibleMaterials) {
roll -= possible.weight;
if (roll <= 0) {
const quantity =
Math.floor(Math.random() * (possible.maxQuantity - possible.minQuantity + 1)) +
possible.minQuantity;
const existing = state.exploration.materials.find((m) => m.materialId === possible.materialId);
if (existing) {
existing.quantity += quantity;
} else {
state.exploration.materials.push({ materialId: possible.materialId, quantity });
}
materialsFound.push({ materialId: possible.materialId, quantity });
break;
}
}
}
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const response: ExploreCollectResponse = {
foundNothing: false,
materialsFound,
event: eventResult,
};
return context.json(response);
});
+67 -3
View File
@@ -9,6 +9,8 @@ import { DEFAULT_ADVENTURERS } from "../data/adventurers.js";
import { DEFAULT_BOSSES } from "../data/bosses.js";
import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { DEFAULT_EXPLORATIONS } from "../data/explorations.js";
import { INITIAL_EXPLORATION } from "../data/initialState.js";
import { DEFAULT_QUESTS } from "../data/quests.js";
import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
@@ -51,6 +53,8 @@ const computeMaxPassiveIncome = (
const setGoldMultiplier = computeSetBonuses(equippedItemIds, DEFAULT_EQUIPMENT_SETS).goldMultiplier;
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1;
let goldPerSecond = 0;
let essencePerSecond = 0;
@@ -76,14 +80,16 @@ const computeMaxPassiveIncome = (
prestige *
runestonesIncome *
equipmentGoldMultiplier *
setGoldMultiplier;
setGoldMultiplier *
craftedGoldMultiplier;
essencePerSecond +=
adventurer.essencePerSecond *
adventurer.count *
upgradeMultiplier *
prestige *
runestonesEssence;
runestonesEssence *
craftedEssenceMultiplier;
}
return { goldPerSecond, essencePerSecond };
@@ -309,7 +315,38 @@ const validateAndSanitize = (incoming: GameState, previous: GameState): GameStat
? { apotheosis: previous.apotheosis }
: {};
return { ...incoming, resources, bosses, quests, achievements, prestige, ...transcendenceSpread, ...apotheosisSpread };
// Exploration: materials and crafted recipes can only be added server-side.
// Cap material quantities and crafted recipe IDs at the previous DB values to block inflation.
// Crafted multipliers are always derived from the previous state (only /craft can change them).
const explorationSpread = (() => {
const prevExploration = previous.exploration;
if (!incoming.exploration) {
return prevExploration ? { exploration: prevExploration } : {};
}
if (!prevExploration) {
return { exploration: incoming.exploration };
}
const materials = incoming.exploration.materials.map((m) => {
const prev = prevExploration.materials.find((p) => p.materialId === m.materialId);
return { ...m, quantity: Math.min(m.quantity, prev?.quantity ?? 0) };
});
const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter((id) =>
prevExploration.craftedRecipeIds.includes(id),
);
return {
exploration: {
...incoming.exploration,
materials,
craftedRecipeIds,
craftedGoldMultiplier: prevExploration.craftedGoldMultiplier,
craftedEssenceMultiplier: prevExploration.craftedEssenceMultiplier,
craftedClickMultiplier: prevExploration.craftedClickMultiplier,
craftedCombatMultiplier: prevExploration.craftedCombatMultiplier,
},
};
})();
return { ...incoming, resources, bosses, quests, achievements, prestige, ...transcendenceSpread, ...apotheosisSpread, ...explorationSpread };
};
export const gameRouter = new Hono<HonoEnv>();
@@ -559,6 +596,33 @@ gameRouter.get("/load", async (context) => {
}
}
// Backfill exploration state on saves that predate the feature
if (!state.exploration) {
state.exploration = structuredClone(INITIAL_EXPLORATION);
// Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) {
const areaData = DEFAULT_EXPLORATIONS.find((e) => e.id === area.id);
if (!areaData) continue;
const zone = state.zones.find((z) => z.id === areaData.zoneId);
if (zone?.status === "unlocked") {
area.status = "available";
}
}
needsBackfill = true;
} else {
// Merge any new exploration areas added since this save was created
for (const defaultArea of DEFAULT_EXPLORATIONS) {
if (!state.exploration.areas.some((a) => a.id === defaultArea.id)) {
const zone = state.zones.find((z) => z.id === defaultArea.zoneId);
state.exploration.areas.push({
id: defaultArea.id,
status: zone?.status === "unlocked" ? "available" : "locked",
});
needsBackfill = true;
}
}
}
const now = Date.now();
const { offlineGold, offlineEssence, offlineSeconds } = calculateOfflineEarnings(state, now);
+3 -9
View File
@@ -1,4 +1,4 @@
import type { BuyPrestigeUpgradeRequest, GameState, PrestigeRequest } from "@elysium/types";
import type { BuyPrestigeUpgradeRequest, GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
@@ -17,12 +17,6 @@ prestigeRouter.use("*", authMiddleware);
prestigeRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<PrestigeRequest>();
const characterName = body.characterName?.trim();
if (!characterName) {
return context.json({ error: "characterName is required" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
@@ -50,7 +44,7 @@ prestigeRouter.post("/", async (context) => {
const { newState, newPrestigeData, runestonesEarned, milestoneRunestones } = buildPostPrestigeState(
state,
characterName,
state.player.characterName,
);
// Preserve daily challenges across the prestige reset and apply any crystal rewards
@@ -78,7 +72,7 @@ prestigeRouter.post("/", async (context) => {
await prisma.player.update({
where: { discordId },
data: {
characterName,
characterName: state.player.characterName,
// Reset current-run counters
totalGoldEarned: 0,
totalClicks: 0,
+3 -9
View File
@@ -1,4 +1,4 @@
import type { BuyEchoUpgradeRequest, GameState, TranscendenceRequest } from "@elysium/types";
import type { BuyEchoUpgradeRequest, GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
@@ -16,12 +16,6 @@ transcendenceRouter.use("*", authMiddleware);
transcendenceRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<TranscendenceRequest>();
const characterName = body.characterName?.trim();
if (!characterName) {
return context.json({ error: "characterName is required" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
@@ -39,7 +33,7 @@ transcendenceRouter.post("/", async (context) => {
const { newState, newTranscendenceData, echoesEarned } = buildPostTranscendenceState(
state,
characterName,
state.player.characterName,
);
// Capture current-run stats before the nuclear reset
@@ -57,7 +51,7 @@ transcendenceRouter.post("/", async (context) => {
await prisma.player.update({
where: { discordId },
data: {
characterName,
characterName: state.player.characterName,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
totalClicks: 0,
+30
View File
@@ -9,6 +9,12 @@ import type {
BuyEchoUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
CraftRecipeRequest,
CraftRecipeResponse,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
LoadResponse,
PrestigeRequest,
PrestigeResponse,
@@ -117,6 +123,30 @@ export const achieveApotheosis = async (body: ApotheosisRequest): Promise<Apothe
body: JSON.stringify(body),
});
export const startExploration = async (
body: ExploreStartRequest,
): Promise<ExploreStartResponse> =>
request<ExploreStartResponse>("/explore/start", {
method: "POST",
body: JSON.stringify(body),
});
export const collectExploration = async (
body: ExploreCollectRequest,
): Promise<ExploreCollectResponse> =>
request<ExploreCollectResponse>("/explore/collect", {
method: "POST",
body: JSON.stringify(body),
});
export const craftRecipe = async (
body: CraftRecipeRequest,
): Promise<CraftRecipeResponse> =>
request<CraftRecipeResponse>("/craft", {
method: "POST",
body: JSON.stringify(body),
});
export const getPublicProfile = async (
discordId: string,
): Promise<PublicProfileResponse> =>
+10 -2
View File
@@ -33,7 +33,7 @@ const HOW_TO_PLAY = [
},
{
title: "⭐ Prestige",
body: "When you've progressed far enough, you can prestige to earn runestones — a permanent currency that persists across all runs. Prestige resets your current run but grants a production multiplier that stacks with every prestige. Name your prestige character to commemorate the run!",
body: "When you've progressed far enough, you can prestige to earn runestones — a permanent currency that persists across all runs. Prestige resets your current run but grants a production multiplier that stacks with every prestige.",
},
{
title: "🔮 Runestones & Prestige Upgrades",
@@ -51,9 +51,17 @@ const HOW_TO_PLAY = [
title: "📅 Daily Challenges",
body: "Complete daily challenges for bonus rewards including gold, essence, crystals, and runestones. Challenges reset each day and vary in difficulty. Completing all daily challenges gives an extra bonus reward.",
},
{
title: "🗺️ Exploration",
body: "Send scouts to explore areas within each zone. Explorations run in real-time and reward gold, essence, and crafting materials when collected. Each area has a set duration — short explorations are faster but longer ones offer rarer finds. A 📖 icon marks areas you've collected from at least once, unlocking a Codex entry.",
},
{
title: "⚗️ Crafting",
body: "Use materials gathered from exploration to craft permanent bonuses. Each recipe provides a multiplier to gold income, essence income, click power, or combat power — all of which stack and persist across prestige runs. Check the Crafting tab to see your material inventory and available recipes per zone.",
},
{
title: "📖 Codex",
body: "Defeating bosses, completing quests, acquiring equipment, hiring adventurers, purchasing upgrades, unlocking prestige upgrades, and discovering new zones all permanently unlock lore entries in the Codex. A badge appears on the Codex tab and a toast notification pops up each time new lore is discovered. Collect all 364 entries to build a complete picture of the world of Elysium.",
body: "Defeating bosses, completing quests, acquiring equipment, hiring adventurers, purchasing upgrades, unlocking prestige upgrades, discovering new zones, collecting from exploration areas, and crafting recipes all permanently unlock lore entries in the Codex. A badge appears on the Codex tab and a toast notification pops up each time new lore is discovered. Collect all 472 entries to build a complete picture of the world of Elysium.",
},
{
title: "☁️ Cloud Saves",
@@ -6,7 +6,6 @@ const TOTAL_ECHO_UPGRADES = TRANSCENDENCE_UPGRADES.length;
export const ApotheosisPanel = (): React.JSX.Element => {
const { state, apotheosis } = useGame();
const [characterName, setCharacterName] = useState("");
const [isPending, setIsPending] = useState(false);
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -19,11 +18,10 @@ export const ApotheosisPanel = (): React.JSX.Element => {
const apotheosisCount = state.apotheosis?.count ?? 0;
const handleApotheosis = async (): Promise<void> => {
if (!characterName.trim()) return;
setIsPending(true);
setError(null);
try {
const data = await apotheosis(characterName.trim());
const data = await apotheosis();
setResult(data.newApotheosisCount);
} catch (err) {
setError(err instanceof Error ? err.message : "Apotheosis failed");
@@ -75,18 +73,10 @@ export const ApotheosisPanel = (): React.JSX.Element => {
{isEligible && (
<div className="prestige-form">
<p>This action is <strong>permanent and irreversible</strong>. Choose your name for the next cycle:</p>
<input
disabled={isPending}
maxLength={32}
onChange={(e) => { setCharacterName(e.target.value); }}
placeholder="Character name..."
type="text"
value={characterName}
/>
<p>This action is <strong>permanent and irreversible</strong>.</p>
<button
className="apotheosis-button"
disabled={isPending || !characterName.trim()}
disabled={isPending}
onClick={() => { void handleApotheosis(); }}
type="button"
>
@@ -11,6 +11,8 @@ const SOURCE_BADGE: Record<CodexEntry["sourceType"], string> = {
upgrade: "🔧",
prestige: "🔮",
zone: "🗺️",
exploration: "🧭",
recipe: "⚗️",
};
export const CodexPanel = (): React.JSX.Element => {
@@ -0,0 +1,140 @@
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { MATERIALS } from "../../data/materials.js";
import { RECIPES } from "../../data/recipes.js";
import { ZoneSelector } from "./ZoneSelector.js";
const BONUS_LABEL: Record<string, string> = {
gold_income: "🪙 Gold Income",
essence_income: "✨ Essence Income",
click_power: "👆 Click Power",
combat_power: "⚔️ Combat Power",
};
export const CraftingPanel = (): React.JSX.Element => {
const { state, craftRecipe, formatNumber } = useGame();
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
const [pendingRecipeId, setPendingRecipeId] = useState<string | null>(null);
if (!state) return <section className="panel"><p>Loading...</p></section>;
const zones = state.zones ?? [];
const explorationState = state.exploration;
const playerMaterials = explorationState?.materials ?? [];
const craftedIds = explorationState?.craftedRecipeIds ?? [];
const zoneRecipes = RECIPES.filter((r) => r.zoneId === activeZoneId);
const zoneMaterials = MATERIALS.filter((m) => m.zoneId === activeZoneId);
const getQuantity = (materialId: string): number =>
playerMaterials.find((m) => m.materialId === materialId)?.quantity ?? 0;
const canAfford = (recipeId: string): boolean => {
const recipe = RECIPES.find((r) => r.id === recipeId);
if (!recipe) return false;
return recipe.requiredMaterials.every(
(req) => getQuantity(req.materialId) >= req.quantity,
);
};
const handleCraft = async (recipeId: string): Promise<void> => {
setPendingRecipeId(recipeId);
try {
await craftRecipe(recipeId);
} finally {
setPendingRecipeId(null);
}
};
return (
<section className="panel crafting-panel">
<div className="panel-header">
<h2> Crafting</h2>
</div>
<ZoneSelector
activeZoneId={activeZoneId}
zones={zones}
onSelectZone={(id) => { setActiveZoneId(id); }}
/>
<div className="crafting-content">
<div className="materials-section">
<h3>📦 Materials</h3>
{zoneMaterials.length === 0 ? (
<p className="empty-zone">No materials in this zone.</p>
) : (
<div className="materials-list">
{zoneMaterials.map((material) => {
const qty = getQuantity(material.id);
return (
<div key={material.id} className={`material-card rarity-${material.rarity} ${qty === 0 ? "material-empty" : ""}`}>
<div className="material-info">
<span className="material-name">{material.name}</span>
<span className="material-rarity">{material.rarity}</span>
</div>
<span className="material-quantity">{formatNumber(qty)}</span>
</div>
);
})}
</div>
)}
</div>
<div className="recipes-section">
<h3>📜 Recipes</h3>
{zoneRecipes.length === 0 ? (
<p className="empty-zone">No recipes in this zone.</p>
) : (
<div className="recipes-list">
{zoneRecipes.map((recipe) => {
const crafted = craftedIds.includes(recipe.id);
const affordable = canAfford(recipe.id);
const isPending = pendingRecipeId === recipe.id;
return (
<div key={recipe.id} className={`recipe-card ${crafted ? "recipe-crafted" : ""} ${!affordable && !crafted ? "recipe-unaffordable" : ""}`}>
<div className="recipe-info">
<h4>{recipe.name}</h4>
<p className="recipe-description">{recipe.description}</p>
<div className="recipe-bonus">
<span className="bonus-label">{BONUS_LABEL[recipe.bonus.type] ?? recipe.bonus.type}</span>
<span className="bonus-value">×{recipe.bonus.value.toFixed(2)}</span>
</div>
<div className="recipe-requirements">
{recipe.requiredMaterials.map((req) => {
const have = getQuantity(req.materialId);
const enough = have >= req.quantity;
const matName = MATERIALS.find((m) => m.id === req.materialId)?.name ?? req.materialId;
return (
<span key={req.materialId} className={`req-tag ${enough ? "req-met" : "req-missing"}`}>
{matName}: {formatNumber(have)}/{formatNumber(req.quantity)}
</span>
);
})}
</div>
</div>
<div className="recipe-action">
{crafted ? (
<span className="quest-badge active"> Crafted</span>
) : (
<button
className="craft-button"
disabled={!affordable || isPending || pendingRecipeId !== null}
onClick={() => { void handleCraft(recipe.id); }}
type="button"
>
{isPending ? "Crafting..." : "⚗️ Craft"}
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</section>
);
};
@@ -0,0 +1,172 @@
import type { ExploreCollectResponse } from "@elysium/types";
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { EXPLORATION_AREAS } from "../../data/explorations.js";
import { ZoneSelector } from "./ZoneSelector.js";
const formatDuration = (seconds: number): string => {
if (seconds >= 86400) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
}
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${seconds}s`;
};
const timeRemaining = (startedAt: number, durationSeconds: number): number => {
const elapsed = (Date.now() - startedAt) / 1000;
return Math.max(0, durationSeconds - elapsed);
};
interface CollectResult {
areaId: string;
response: ExploreCollectResponse;
}
export const ExplorationPanel = (): React.JSX.Element => {
const { state, startExploration, collectExploration, formatNumber } = useGame();
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
const [pendingAreaId, setPendingAreaId] = useState<string | null>(null);
const [lastResult, setLastResult] = useState<CollectResult | null>(null);
if (!state) return <section className="panel"><p>Loading...</p></section>;
const zones = state.zones ?? [];
const explorationState = state.exploration;
const zoneAreas = EXPLORATION_AREAS.filter((a) => a.zoneId === activeZoneId);
const handleStart = async (areaId: string): Promise<void> => {
setPendingAreaId(areaId);
try {
await startExploration(areaId);
} finally {
setPendingAreaId(null);
}
};
const handleCollect = async (areaId: string): Promise<void> => {
setPendingAreaId(areaId);
try {
const result = await collectExploration(areaId);
setLastResult({ areaId, response: result });
} finally {
setPendingAreaId(null);
}
};
return (
<section className="panel exploration-panel">
<div className="panel-header">
<h2>🗺 Exploration</h2>
</div>
{lastResult && (
<div className="exploration-result">
<button
className="exploration-result-close"
onClick={() => { setLastResult(null); }}
type="button"
>
</button>
{lastResult.response.foundNothing ? (
<p className="exploration-nothing">{lastResult.response.nothingMessage}</p>
) : (
<>
{lastResult.response.event && (
<p className="exploration-event-text">{lastResult.response.event.text}</p>
)}
<div className="exploration-rewards">
{(lastResult.response.event?.goldChange ?? 0) !== 0 && (
<span className={`reward-tag ${(lastResult.response.event?.goldChange ?? 0) > 0 ? "" : "negative"}`}>
🪙 {(lastResult.response.event?.goldChange ?? 0) > 0 ? "+" : ""}{formatNumber(lastResult.response.event?.goldChange ?? 0)} gold
</span>
)}
{(lastResult.response.event?.essenceChange ?? 0) > 0 && (
<span className="reward-tag">
+{formatNumber(lastResult.response.event?.essenceChange ?? 0)} essence
</span>
)}
{lastResult.response.event?.materialGained && (
<span className="reward-tag material-tag">
📦 +{lastResult.response.event.materialGained.quantity} {lastResult.response.event.materialGained.materialId.replace(/_/g, " ")} (event)
</span>
)}
{lastResult.response.materialsFound.map((m) => (
<span key={m.materialId} className="reward-tag material-tag">
📦 +{m.quantity} {m.materialId.replace(/_/g, " ")}
</span>
))}
</div>
</>
)}
</div>
)}
<ZoneSelector
activeZoneId={activeZoneId}
zones={zones}
onSelectZone={(id) => { setActiveZoneId(id); setLastResult(null); }}
/>
<div className="exploration-list">
{zoneAreas.map((area) => {
const areaState = explorationState?.areas.find((a) => a.id === area.id);
const status = areaState?.status ?? "locked";
const startedAt = areaState?.startedAt ?? 0;
const isReady = status === "in_progress" && timeRemaining(startedAt, area.durationSeconds) <= 0;
const isPending = pendingAreaId === area.id;
return (
<div key={area.id} className={`exploration-card exploration-${status}`}>
<div className="exploration-info">
<h3>
{area.name}
{areaState?.completedOnce && <span className="exploration-discovered"> 📖</span>}
</h3>
<p>{area.description}</p>
<span className="exploration-duration"> {formatDuration(area.durationSeconds)}</span>
</div>
<div className="exploration-action">
{status === "locked" && (
<span className="quest-badge locked">🔒 Locked</span>
)}
{status === "available" && (
<button
className="start-quest-button"
disabled={isPending}
onClick={() => { void handleStart(area.id); }}
type="button"
>
{isPending ? "Departing..." : `Explore (${formatDuration(area.durationSeconds)})`}
</button>
)}
{status === "in_progress" && !isReady && (
<span className="quest-badge active">
{formatDuration(Math.ceil(timeRemaining(startedAt, area.durationSeconds)))} remaining
</span>
)}
{(status === "in_progress" && isReady) && (
<button
className="collect-button"
disabled={isPending}
onClick={() => { void handleCollect(area.id); }}
type="button"
>
{isPending ? "Collecting..." : "📦 Collect Results"}
</button>
)}
</div>
</div>
);
})}
{zoneAreas.length === 0 && (
<p className="empty-zone">No exploration areas in this zone.</p>
)}
</div>
</section>
);
};
+7 -1
View File
@@ -20,8 +20,10 @@ import { QuestPanel } from "./QuestPanel.js";
import { StatisticsPanel } from "./StatisticsPanel.js";
import { UpgradePanel } from "./UpgradePanel.js";
import { DailyChallengePanel } from "./DailyChallengePanel.js";
import { ExplorationPanel } from "./ExplorationPanel.js";
import { CraftingPanel } from "./CraftingPanel.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting";
const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" },
@@ -33,6 +35,8 @@ const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "prestige", label: "⭐ Prestige" },
{ id: "transcendence", label: "🌌 Transcendence" },
{ id: "apotheosis", label: "✨ Apotheosis" },
{ id: "exploration", label: "🗺️ Exploration" },
{ id: "crafting", label: "⚗️ Crafting" },
{ id: "statistics", label: "📊 Statistics" },
{ id: "daily", label: "📅 Daily" },
{ id: "codex", label: "📖 Codex" },
@@ -120,6 +124,8 @@ export const GameLayout = (): React.JSX.Element => {
{activeTab === "prestige" && <PrestigePanel />}
{activeTab === "transcendence" && <TranscendencePanel />}
{activeTab === "apotheosis" && <ApotheosisPanel />}
{activeTab === "exploration" && <ExplorationPanel />}
{activeTab === "crafting" && <CraftingPanel />}
{activeTab === "statistics" && <StatisticsPanel />}
{activeTab === "daily" && <DailyChallengePanel />}
{activeTab === "codex" && <CodexPanel />}
+3 -13
View File
@@ -41,7 +41,6 @@ const CATEGORY_ORDER: PrestigeUpgradeCategory[] = [
export const PrestigePanel = (): React.JSX.Element => {
const { state, reload, formatNumber, buyPrestigeUpgrade, toggleAutoPrestige } = useGame();
const [characterName, setCharacterName] = useState("");
const [isPending, setIsPending] = useState(false);
const [result, setResult] = useState<{ runestones: number; count: number; milestoneRunestones: number } | null>(null);
const [prestigeError, setPrestigeError] = useState<string | null>(null);
@@ -61,11 +60,10 @@ export const PrestigePanel = (): React.JSX.Element => {
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
const handlePrestige = async (): Promise<void> => {
if (!characterName.trim()) return;
setIsPending(true);
setPrestigeError(null);
try {
const data = await prestige({ characterName: characterName.trim() });
const data = await prestige({});
setResult({ runestones: data.runestones, count: data.newPrestigeCount, milestoneRunestones: data.milestoneRunestones });
await reload();
} catch (err) {
@@ -156,18 +154,10 @@ export const PrestigePanel = (): React.JSX.Element => {
{isEligible ? (
<div className="prestige-form">
<p>You are ready to prestige! Choose your new character name:</p>
<input
disabled={isPending}
maxLength={32}
onChange={(e) => { setCharacterName(e.target.value); }}
placeholder="Character name..."
type="text"
value={characterName}
/>
<p>You are ready to prestige!</p>
<button
className="prestige-button"
disabled={isPending || !characterName.trim()}
disabled={isPending}
onClick={() => { void handlePrestige(); }}
type="button"
>
@@ -24,7 +24,6 @@ const CATEGORY_ORDER: TranscendenceUpgradeCategory[] = [
export const TranscendencePanel = (): React.JSX.Element => {
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
const [characterName, setCharacterName] = useState("");
const [isPending, setIsPending] = useState(false);
const [result, setResult] = useState<{ echoes: number; count: number } | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -41,11 +40,10 @@ export const TranscendencePanel = (): React.JSX.Element => {
const transcendenceCount = transcendence?.count ?? 0;
const handleTranscend = async (): Promise<void> => {
if (!characterName.trim()) return;
setIsPending(true);
setError(null);
try {
const data = await transcend(characterName.trim());
const data = await transcend();
setResult({ echoes: data.echoes, count: data.newTranscendenceCount });
} catch (err) {
setError(err instanceof Error ? err.message : "Transcendence failed");
@@ -136,18 +134,9 @@ export const TranscendencePanel = (): React.JSX.Element => {
{hasDefeatedFinalBoss && (
<div className="prestige-form">
<p>You are ready to transcend. This action is <strong>irreversible</strong>.</p>
<p>Choose your new character name for the next cycle:</p>
<input
disabled={isPending}
maxLength={32}
onChange={(e) => { setCharacterName(e.target.value); }}
placeholder="Character name..."
type="text"
value={characterName}
/>
<button
className="transcendence-button"
disabled={isPending || !characterName.trim()}
disabled={isPending}
onClick={() => { void handleTranscend(); }}
type="button"
>
+142 -8
View File
@@ -1,4 +1,4 @@
import type { Achievement, BossChallengeResponse, GameState, NumberFormat } from "@elysium/types";
import type { Achievement, BossChallengeResponse, ExploreCollectResponse, GameState, NumberFormat } from "@elysium/types";
import {
createContext,
useCallback,
@@ -12,11 +12,16 @@ import {
buyEchoUpgrade as buyEchoUpgradeApi,
buyPrestigeUpgrade as buyPrestigeUpgradeApi,
challengeBoss as challengeBossApi,
collectExploration as collectExplorationApi,
craftRecipe as craftRecipeApi,
loadGame,
prestige as prestigeApi,
saveGame,
startExploration as startExplorationApi,
transcend as transcendApi,
} from "../api/client.js";
import { EXPLORATION_AREAS } from "../data/explorations.js";
import { RECIPES } from "../data/recipes.js";
import { CODEX_ENTRIES } from "../data/codex.js";
import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
@@ -85,11 +90,17 @@ interface GameContextValue {
/** Remove a codex entry ID from the notification queue */
dismissCodexEntry: (id: string) => void;
/** Perform a transcendence — nuclear reset, earning echoes */
transcend: (characterName: string) => Promise<{ echoes: number; newTranscendenceCount: number }>;
transcend: () => Promise<{ echoes: number; newTranscendenceCount: number }>;
/** Buy an echo upgrade from the transcendence shop */
buyEchoUpgrade: (upgradeId: string) => Promise<void>;
/** Achieve Apotheosis — the ultimate nuclear reset, bragging rights only */
apotheosis: (characterName: string) => Promise<{ newApotheosisCount: number }>;
apotheosis: () => Promise<{ newApotheosisCount: number }>;
/** Start an exploration in the given area */
startExploration: (areaId: string) => Promise<void>;
/** Collect results of a completed exploration */
collectExploration: (areaId: string) => Promise<ExploreCollectResponse>;
/** Craft a recipe using collected materials */
craftRecipe: (recipeId: string) => Promise<void>;
}
const GameContext = createContext<GameContextValue | null>(null);
@@ -244,6 +255,26 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
}
}
for (const area of state.exploration?.areas ?? []) {
const codexId = `explore_${area.id}`;
if (area.completedOnce && !codexProcessedRef.current.has(codexId)) {
codexProcessedRef.current.add(codexId);
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
newIds.push(codexId);
}
}
}
for (const recipeId of state.exploration?.craftedRecipeIds ?? []) {
const codexId = `recipe_${recipeId}`;
if (!codexProcessedRef.current.has(codexId)) {
codexProcessedRef.current.add(codexId);
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
newIds.push(codexId);
}
}
}
if (newIds.length > 0) {
setState((prev) => {
if (!prev) return prev;
@@ -321,7 +352,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
Math.pow(AUTO_PRESTIGE_THRESHOLD_SCALE, autoState.prestige.count)
) {
isAutoPrestigingRef.current = true;
void prestigeApi({ characterName: autoState.player.characterName })
void prestigeApi({})
.then(() => reloadRef.current())
.catch(() => { /* silently ignore — will retry next tick */ })
.finally(() => { isAutoPrestigingRef.current = false; });
@@ -540,14 +571,14 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
}
}, []);
const transcend = useCallback(async (characterName: string) => {
const result = await transcendApi({ characterName });
const transcend = useCallback(async () => {
const result = await transcendApi({});
await reload();
return result;
}, [reload]);
const apotheosis = useCallback(async (characterName: string) => {
const result = await achieveApotheosisApi({ characterName });
const apotheosis = useCallback(async () => {
const result = await achieveApotheosisApi({});
await reload();
return result;
}, [reload]);
@@ -576,6 +607,106 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
}
}, []);
const startExploration = useCallback(async (areaId: string) => {
const response = await startExplorationApi({ areaId });
const areaData = EXPLORATION_AREAS.find((a) => a.id === areaId);
if (!areaData) return;
const startedAt = response.endsAt - areaData.durationSeconds * 1000;
setState((prev) => {
if (!prev?.exploration) return prev;
return {
...prev,
exploration: {
...prev.exploration,
areas: prev.exploration.areas.map((a) =>
a.id === areaId ? { ...a, status: "in_progress" as const, startedAt } : a,
),
},
};
});
}, []);
const collectExploration = useCallback(async (areaId: string): Promise<ExploreCollectResponse> => {
const result = await collectExplorationApi({ areaId });
setState((prev) => {
if (!prev?.exploration) return prev;
let materials = [...prev.exploration.materials];
// Apply material drops from the random loot roll
for (const drop of result.materialsFound) {
const existing = materials.find((m) => m.materialId === drop.materialId);
if (existing) {
materials = materials.map((m) =>
m.materialId === drop.materialId ? { ...m, quantity: m.quantity + drop.quantity } : m,
);
} else {
materials = [...materials, { materialId: drop.materialId, quantity: drop.quantity }];
}
}
// Apply material from event (if any)
if (result.event?.materialGained) {
const { materialId, quantity } = result.event.materialGained;
const existing = materials.find((m) => m.materialId === materialId);
if (existing) {
materials = materials.map((m) =>
m.materialId === materialId ? { ...m, quantity: m.quantity + quantity } : m,
);
} else {
materials = [...materials, { materialId, quantity }];
}
}
return {
...prev,
resources: {
...prev.resources,
gold: Math.max(0, prev.resources.gold + (result.event?.goldChange ?? 0)),
essence: prev.resources.essence + (result.event?.essenceChange ?? 0),
},
player: {
...prev.player,
totalGoldEarned: prev.player.totalGoldEarned + Math.max(0, result.event?.goldChange ?? 0),
},
exploration: {
...prev.exploration,
areas: prev.exploration.areas.map((a) =>
a.id === areaId ? { ...a, status: "available" as const, completedOnce: true } : a,
),
materials,
},
};
});
return result;
}, []);
const craftRecipe = useCallback(async (recipeId: string) => {
const recipe = RECIPES.find((r) => r.id === recipeId);
if (!recipe) return;
const result = await craftRecipeApi({ recipeId });
setState((prev) => {
if (!prev?.exploration) return prev;
let materials = [...prev.exploration.materials];
for (const req of recipe.requiredMaterials) {
materials = materials.map((m) =>
m.materialId === req.materialId ? { ...m, quantity: m.quantity - req.quantity } : m,
);
}
return {
...prev,
exploration: {
...prev.exploration,
craftedRecipeIds: [...prev.exploration.craftedRecipeIds, recipeId],
materials,
craftedGoldMultiplier: result.craftedGoldMultiplier,
craftedEssenceMultiplier: result.craftedEssenceMultiplier,
craftedClickMultiplier: result.craftedClickMultiplier,
craftedCombatMultiplier: result.craftedCombatMultiplier,
},
};
});
}, []);
const toggleAutoPrestige = useCallback(() => {
setState((prev) => {
if (!prev) return prev;
@@ -761,6 +892,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
transcend,
buyEchoUpgrade,
apotheosis,
startExploration,
collectExploration,
craftRecipe,
}}
>
{children}
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
export interface ExplorationAreaSummary {
id: string;
name: string;
description: string;
zoneId: string;
durationSeconds: number;
}
export const EXPLORATION_AREAS: ExplorationAreaSummary[] = [
// Zone 1: verdant_vale
{ id: "verdant_meadow", name: "The Verdant Meadow", description: "Rolling fields of wildflowers at the edge of the guild's territory. Travellers pass through often, and occasionally leave things behind.", zoneId: "verdant_vale", durationSeconds: 3600 },
{ id: "whispering_forest", name: "The Whispering Forest", description: "Ancient trees whose canopy blocks out most of the light. The forest whispers things your scouts swear they understand, just not when they try to remember later.", zoneId: "verdant_vale", durationSeconds: 7200 },
{ id: "ancient_grove", name: "The Ancient Grove", description: "A circle of trees so old they predate the kingdom. Druids once held ceremonies here. The trees remember, and their bark holds echoes of old power.", zoneId: "verdant_vale", durationSeconds: 10800 },
{ id: "forbidden_glen", name: "The Forbidden Glen", description: "A clearing the locals will not enter after dark. Something about the bark of the trees here is different. Your scouts feel watched the entire time.", zoneId: "verdant_vale", durationSeconds: 14400 },
// Zone 2: shattered_ruins
{ id: "collapsed_outpost", name: "The Collapsed Outpost", description: "What was once a military garrison, now half-buried in rubble and wild growth. The previous occupants left in a hurry and did not take everything.", zoneId: "shattered_ruins", durationSeconds: 7200 },
{ id: "cursed_lake", name: "The Cursed Lake", description: "The water here reflects things that aren't there. Something is at the bottom that doesn't want to be found, which means your scouts want very much to find it.", zoneId: "shattered_ruins", durationSeconds: 14400 },
{ id: "runic_archive", name: "The Runic Archive", description: "Buried walls covered in script no living scholar can read. The knowledge is lost but the enchantments remain, faded but still murmuring in the stone.", zoneId: "shattered_ruins", durationSeconds: 21600 },
{ id: "dragon_throne", name: "The Dragon's Throne", description: "The chamber the elder dragon called his own before your guild deposed him. He won't be back soon. Probably. The heat of his presence lingers in the stone.", zoneId: "shattered_ruins", durationSeconds: 28800 },
// Zone 3: frozen_peaks
{ id: "glacial_cave", name: "The Glacial Cave", description: "A cave carved by a glacier over thousands of years. The ice walls are so clear you can see things preserved within them from before the kingdom existed.", zoneId: "frozen_peaks", durationSeconds: 10800 },
{ id: "frozen_tundra", name: "The Frozen Tundra", description: "Flat, white, and vast. The tundra looks featureless until you know what to look for. Under the ice, there are things that were buried with intent.", zoneId: "frozen_peaks", durationSeconds: 21600 },
{ id: "void_rift", name: "The Void Rift", description: "A tear in reality that appeared after the Void Titan's defeat, miles above the world. Something leaks through it constantly. Mostly harmless. Mostly.", zoneId: "frozen_peaks", durationSeconds: 32400 },
{ id: "summit_shrine", name: "The Summit Shrine", description: "At the absolute peak, a shrine nobody remembers building. The prayers still tied to its poles are in a language no scholar has identified. Offerings remain.", zoneId: "frozen_peaks", durationSeconds: 43200 },
// Zone 4: shadow_marshes
{ id: "fog_hollow", name: "The Fog Hollow", description: "A depression in the marsh where the fog never fully lifts. Sound behaves differently here. Your scouts can hear things they probably should not.", zoneId: "shadow_marshes", durationSeconds: 18000 },
{ id: "dark_grotto", name: "The Dark Grotto", description: "A cave system beneath the marsh floor. The water drips through the ceiling in patterns that look deliberate. Nothing down here needs eyes to find you.", zoneId: "shadow_marshes", durationSeconds: 36000 },
{ id: "cursed_barrow", name: "The Cursed Barrow", description: "A burial mound. Something was interred here that should not have been — or perhaps something interred itself, which is a different and more troubling problem.", zoneId: "shadow_marshes", durationSeconds: 54000 },
{ id: "marsh_depths", name: "The Marsh Depths", description: "The bottommost point of the Shadow Marshes, where the water is perfectly still and perfectly black. Your scouts can see the bottom. The bottom is very far down.", zoneId: "shadow_marshes", durationSeconds: 72000 },
// Zone 5: volcanic_depths
{ id: "magma_tunnel", name: "The Magma Tunnel", description: "A natural tunnel cut by ancient lava flows. Still warm. The walls glow faintly orange in some sections, which is either residual heat or something else.", zoneId: "volcanic_depths", durationSeconds: 25200 },
{ id: "forge_chamber", name: "The Forge Chamber", description: "An ancient workshop space, built into the volcano by whoever the fire elementals served before they served no one. The fires here never went out.", zoneId: "volcanic_depths", durationSeconds: 50400 },
{ id: "fire_temple", name: "The Fire Temple", description: "A place of worship for entities that have never met a god but found the general idea appealing and decided to be worshipped instead. The fire elementals receive visitors here.", zoneId: "volcanic_depths", durationSeconds: 75600 },
{ id: "core_descent", name: "The Core Descent", description: "The lowest point your guild can reach — close enough to the planet's core that the rocks bleed metal and the air shimmers with heat haze that never quite resolves into anything.", zoneId: "volcanic_depths", durationSeconds: 100800 },
// Zone 6: astral_void
{ id: "star_field", name: "The Star Field", description: "Open void between reality and whatever lies beyond it. Stars in various states of life and death drift past. Your scouts learn very quickly not to touch them.", zoneId: "astral_void", durationSeconds: 36000 },
{ id: "probability_sea", name: "The Probability Sea", description: "A region where every possible outcome is equally real and they jostle each other for space. Your scouts exist in several states simultaneously here and find it disorienting.", zoneId: "astral_void", durationSeconds: 72000 },
{ id: "void_current", name: "The Void Current", description: "A river of nothing flowing through the void. It carries things from everywhere to nowhere. Some of those things are valuable, if you know how to fish from a river of nothing.", zoneId: "astral_void", durationSeconds: 108000 },
{ id: "null_zenith", name: "The Null Zenith", description: "The highest point of the astral void, where nothing exists so thoroughly that it becomes a kind of substance. Your scouts feel, for a moment, what it is like to be absolutely alone in all of existence.", zoneId: "astral_void", durationSeconds: 144000 },
// Zone 7: celestial_reaches
{ id: "light_spire", name: "The Light Spire", description: "A tower of compressed light older than the concept of architecture. The celestial host uses it as a marker. Your guild uses it as a starting point.", zoneId: "celestial_reaches", durationSeconds: 43200 },
{ id: "choir_hall", name: "The Choir Hall", description: "Where the celestial choir rehearses, continuously, for a performance that has been ongoing since before your world had an audience. The harmonics do things to objects in the vicinity.", zoneId: "celestial_reaches", durationSeconds: 86400 },
{ id: "divine_court", name: "The Divine Court", description: "Where the celestial host adjudicates disputes that have been ongoing since before your sun was lit. The proceedings are extremely formal. Interrupting them is inadvisable.", zoneId: "celestial_reaches", durationSeconds: 129600 },
{ id: "celestial_vault", name: "The Celestial Vault", description: "Where the celestial host stores things they consider too valuable to use and too important to discard. Your guild has different ideas about what 'valuable' means.", zoneId: "celestial_reaches", durationSeconds: 172800 },
// Zone 8: abyssal_trench
{ id: "trench_entrance", name: "The Trench Entrance", description: "The lip of the trench, where the shelf drops away into depths that swallow light entirely. Your scouts can hear something breathing, very slowly, from far below.", zoneId: "abyssal_trench", durationSeconds: 50400 },
{ id: "deep_current", name: "The Deep Current", description: "An underwater river at a depth that should be impossible to survive. Your scouts have learned, by necessity, to survive it anyway.", zoneId: "abyssal_trench", durationSeconds: 100800 },
{ id: "sunless_chamber", name: "The Sunless Chamber", description: "A space at the bottom of the trench so far from light that light has no meaning here. Something has been in this chamber for so long it no longer needs to breathe.", zoneId: "abyssal_trench", durationSeconds: 151200 },
{ id: "the_waiting_place", name: "The Waiting Place", description: "The absolute bottom of the trench. Something is here. It has been here since before your world was made. It is, today, patient. Your scouts are not sure this is always the case.", zoneId: "abyssal_trench", durationSeconds: 201600 },
// Zone 9: infernal_court
{ id: "demon_market", name: "The Demon Market", description: "An open-air market in the court's outer districts. The vendors sell things that were not legally obtained, in exchange for things that should not legally exist.", zoneId: "infernal_court", durationSeconds: 57600 },
{ id: "torment_hall", name: "The Torment Hall", description: "Where the court processes those who lost their cases. Your scouts move through it quickly and look at nothing. They still hear everything.", zoneId: "infernal_court", durationSeconds: 115200 },
{ id: "soul_forge", name: "The Soul Forge", description: "The court's industrial district, where deals are processed and the residue of completed contracts is extracted. The machinery runs on something the court considers renewable.", zoneId: "infernal_court", durationSeconds: 172800 },
{ id: "lords_chamber", name: "The Lords' Chamber", description: "The inner sanctum of the infernal court, where the demon lords make decisions that echo across aeons. Your guild should not be here. Your guild is here anyway.", zoneId: "infernal_court", durationSeconds: 230400 },
// Zone 10: crystalline_spire
{ id: "facet_approach", name: "The Facet Approach", description: "The outer surface of the spire, where thousands of crystal facets reflect realities that are not the one you arrived in. Your scouts learn to focus on the ground in front of them.", zoneId: "crystalline_spire", durationSeconds: 64800 },
{ id: "calculation_chamber", name: "The Calculation Chamber", description: "A room inside the spire where the intelligence runs its oldest and most complex calculations. The numbers on the walls change too fast to read. The calculations are always correct.", zoneId: "crystalline_spire", durationSeconds: 129600 },
{ id: "mirror_hall", name: "The Mirror Hall", description: "A corridor of perfect mirrors that show not reflections but what might have been. Your scouts avoid eye contact with their alternates. The alternates do not always extend the same courtesy.", zoneId: "crystalline_spire", durationSeconds: 194400 },
{ id: "core_access", name: "The Core Access", description: "The deepest point of the spire, where the intelligence's primary substrate runs continuously. The hum of calculation is felt in the bones. Numbers that have never been numbers drift past.", zoneId: "crystalline_spire", durationSeconds: 259200 },
// Zone 11: void_sanctum
{ id: "threshold", name: "The Threshold", description: "The entrance to the void sanctum, where the rules of existence become suggestions. Your scouts describe the crossing as like stepping sideways and arriving somewhere that was always there.", zoneId: "void_sanctum", durationSeconds: 72000 },
{ id: "inner_silence", name: "The Inner Silence", description: "A place inside the sanctum where everything is perfectly quiet because nothing exists to make noise. Your scouts can hear their own thoughts very clearly here. Some of them find this unsettling.", zoneId: "void_sanctum", durationSeconds: 144000 },
{ id: "resonance_chamber", name: "The Resonance Chamber", description: "A space inside the sanctum where something is calling out, continuously, to something that has not yet answered. The call is beautiful and deeply wrong.", zoneId: "void_sanctum", durationSeconds: 216000 },
{ id: "sanctum_heart", name: "The Sanctum Heart", description: "The source of the call. Something here has been reaching out for so long it no longer remembers what it is reaching toward. Your guild's arrival is, perhaps, an answer.", zoneId: "void_sanctum", durationSeconds: 288000 },
// Zone 12: eternal_throne
{ id: "throne_approach", name: "The Throne Approach", description: "The long road to the eternal throne. Countless beings have walked it, seeking audience, seeking power, seeking something the throne has always already decided about them.", zoneId: "eternal_throne", durationSeconds: 79200 },
{ id: "dominion_hall", name: "The Dominion Hall", description: "The ante-chamber of absolute power. Records are kept here of everything that has ever been ruled and everything that has ever been lost. The records go back further than memory.", zoneId: "eternal_throne", durationSeconds: 158400 },
{ id: "eternity_vault", name: "The Eternity Vault", description: "Where things are stored that have nowhere else to go. Objects of power that cannot be used, secrets that cannot be shared, and wealth that belongs to entities that stopped existing before your world was born.", zoneId: "eternal_throne", durationSeconds: 237600 },
{ id: "the_seat", name: "The Seat", description: "The eternal throne itself. Whoever sits here has sat here since the beginning. They observe your guild's presence with neither surprise nor emotion. They have been expecting you. They have been expecting everyone.", zoneId: "eternal_throne", durationSeconds: 316800 },
// Zone 13: primordial_chaos
{ id: "creation_storm", name: "The Creation Storm", description: "A permanent storm at the edge of the chaos zone where things are constantly being made and unmade simultaneously. Your scouts move through it quickly and try not to look at what they might become.", zoneId: "primordial_chaos", durationSeconds: 86400 },
{ id: "unmaking_sea", name: "The Unmaking Sea", description: "A vast ocean of something that is exactly the opposite of matter. Your scouts cross it by not thinking too hard about what they are standing on.", zoneId: "primordial_chaos", durationSeconds: 172800 },
{ id: "probability_void", name: "The Probability Void", description: "A space where all possible outcomes already happened and none of them mattered. Your scouts find this philosophically challenging and practically navigable.", zoneId: "primordial_chaos", durationSeconds: 259200 },
{ id: "chaos_core", name: "The Chaos Core", description: "The centre of all primordial chaos. Everything is here and nothing is here and both statements are entirely accurate. Your scouts report the experience as indescribable, then describe it for three hours.", zoneId: "primordial_chaos", durationSeconds: 345600 },
// Zone 14: infinite_expanse
{ id: "first_horizon", name: "The First Horizon", description: "The first horizon you reach in the infinite expanse, which looks exactly like the starting point from behind but is provably, mathematically, somewhere else. Your scouts are sceptical but cannot argue with the math.", zoneId: "infinite_expanse", durationSeconds: 93600 },
{ id: "middle_nowhere", name: "The Middle of Nowhere", description: "There is no centre of the infinite expanse. This is the centre of the infinite expanse. Both things are true. Your scouts have stopped asking questions and started collecting samples.", zoneId: "infinite_expanse", durationSeconds: 187200 },
{ id: "edge_approach", name: "The Edge Approach", description: "The road toward the edge that the expanse does not have. Your scouts know it does not exist. They are getting closer to it anyway.", zoneId: "infinite_expanse", durationSeconds: 280800 },
{ id: "the_furthest", name: "The Furthest", description: "As far as any being has ever gone in the infinite expanse. Your scouts hold this record now. They are not entirely sure whether to be proud or frightened.", zoneId: "infinite_expanse", durationSeconds: 374400 },
// Zone 15: reality_forge
{ id: "workshop_entrance", name: "The Workshop Entrance", description: "The outer area of the reality forge, where the overflow of unrealised realities pools and cools. Things that never quite existed are everywhere here, and some of them are extremely useful.", zoneId: "reality_forge", durationSeconds: 100800 },
{ id: "creation_floor", name: "The Creation Floor", description: "Where realities are assembled from the raw components of existence. The work here is continuous and has been going on since before your universe was queued.", zoneId: "reality_forge", durationSeconds: 201600 },
{ id: "master_forge", name: "The Master Forge", description: "The primary forging station, where major realities are hammered into their final shape. The hammers are larger than planets. The anvil has never been named because no one has ever successfully described it.", zoneId: "reality_forge", durationSeconds: 302400 },
{ id: "forge_core", name: "The Forge Core", description: "The energy source that powers the entire reality forge. It has been running since before time was a meaningful concept. What powers it is not a question that has been answered by anyone who came here to ask it.", zoneId: "reality_forge", durationSeconds: 403200 },
// Zone 16: cosmic_maelstrom
{ id: "outer_current", name: "The Outer Current", description: "The outermost spiral of the cosmic maelstrom, where the forces are at their most navigable — which still means they routinely shatter planets that wander too close.", zoneId: "cosmic_maelstrom", durationSeconds: 108000 },
{ id: "debris_field", name: "The Debris Field", description: "The accumulated wreckage of everything the maelstrom has consumed, compressed into a navigable (mostly) field. Your scouts move through it quickly. Things in debris fields become part of the debris field.", zoneId: "cosmic_maelstrom", durationSeconds: 216000 },
{ id: "force_confluence", name: "The Force Confluence", description: "Where the fundamental forces of the cosmos intersect inside the maelstrom. Gravity and electromagnetism and things that do not have names yet jostle each other here with consequences that exceed polite description.", zoneId: "cosmic_maelstrom", durationSeconds: 324000 },
{ id: "eye_approach", name: "The Eye Approach", description: "The path to the maelstrom's impossible centre — the one point of absolute calm surrounded by forces that make galaxies look fragile. Your scouts have never been so far in. They are doing this anyway.", zoneId: "cosmic_maelstrom", durationSeconds: 432000 },
// Zone 17: primeval_sanctum
{ id: "first_steps", name: "The First Steps", description: "The entrance to the oldest place. The floor here was walked before walking was invented, which is philosophically impossible and physically evident.", zoneId: "primeval_sanctum", durationSeconds: 115200 },
{ id: "ancient_archive", name: "The Ancient Archive", description: "A collection of records that predate the concept of records. The information stored here concerns things that no longer exist, but the records persist because the sanctum will not let them stop.", zoneId: "primeval_sanctum", durationSeconds: 230400 },
{ id: "memory_chamber", name: "The Memory Chamber", description: "Where the sanctum stores the memory of the first moment of existence. The memory is perfect, complete, and overwhelming. Your scouts spend the minimum time here and speak little for some time after.", zoneId: "primeval_sanctum", durationSeconds: 345600 },
{ id: "the_oldest_place", name: "The Oldest Place", description: "There is nothing older than this. The sanctum's deepest point, where the very first thing that ever was still is, unchanged, because nothing in the universe has had long enough to change it.", zoneId: "primeval_sanctum", durationSeconds: 460800 },
// Zone 18: the_absolute
{ id: "edge_of_everything", name: "The Edge of Everything", description: "The boundary between existence and non-existence. On one side: everything there is. On the other: everything there isn't. The view from here is indescribable and has been described by your scouts at length.", zoneId: "the_absolute", durationSeconds: 129600 },
{ id: "truth_approach", name: "The Truth Approach", description: "The road to the final truth, which your guild has been walking toward since the first step in the Verdant Vale. It looks like every other road your guild has walked. It feels different.", zoneId: "the_absolute", durationSeconds: 259200 },
{ id: "final_antechamber", name: "The Final Antechamber", description: "One step from the absolute. The door ahead is the last door. Your guild has opened every other door. This one opens when you are ready, which is something only the absolute can determine.", zoneId: "the_absolute", durationSeconds: 388800 },
{ id: "the_absolute_heart", name: "The Absolute Heart", description: "The final truth, at the end of all things. There is nothing beyond this. Your guild stands here, at the end, and finds that the end is not empty. It has been waiting for you specifically.", zoneId: "the_absolute", durationSeconds: 518400 },
];
+93
View File
@@ -0,0 +1,93 @@
import type { Material } from "@elysium/types";
export const MATERIALS: Material[] = [
// Zone 1: verdant_vale
{ id: "verdant_sap", name: "Verdant Sap", description: "Sticky resin tapped from ancient heartwood trees. Smells faintly of spring rain and something older beneath.", zoneId: "verdant_vale", rarity: "common" },
{ id: "forest_crystal", name: "Forest Crystal", description: "A translucent gem found buried near the roots of old-growth trees. Pulses with gentle life energy when held.", zoneId: "verdant_vale", rarity: "uncommon" },
{ id: "elder_bark", name: "Elder Bark", description: "Bark from a tree that has stood since before the kingdom. Harder than iron and warmer to the touch than it has any right to be.", zoneId: "verdant_vale", rarity: "rare" },
// Zone 2: shattered_ruins
{ id: "ruin_dust", name: "Ruin Dust", description: "Fine powder ground from fallen masonry. Still carries traces of the civilisation it once was — if you know how to read the patterns.", zoneId: "shattered_ruins", rarity: "common" },
{ id: "cursed_fragment", name: "Cursed Fragment", description: "A shard of enchanted stonework. The enchantment is broken, but something lingers in the grain of the stone, waiting.", zoneId: "shattered_ruins", rarity: "uncommon" },
{ id: "dragonscale_chip", name: "Dragonscale Chip", description: "A fragment of scale shed during the elder dragon's long reign over the ruins. Resistant to fire and magic alike.", zoneId: "shattered_ruins", rarity: "rare" },
// Zone 3: frozen_peaks
{ id: "glacial_ice", name: "Glacial Ice", description: "Ice from a glacier that has not moved in ten thousand years. Impossibly cold and perfectly clear, with something almost visible within.", zoneId: "frozen_peaks", rarity: "common" },
{ id: "frost_crystal", name: "Frost Crystal", description: "A crystal that formed inside the glacier itself over millennia. It never melts, not even near fire.", zoneId: "frozen_peaks", rarity: "uncommon" },
{ id: "void_shard", name: "Void Shard", description: "A fragment of the reality tear. It hums with wrongness that the fingers instinctively recognise before the mind does.", zoneId: "frozen_peaks", rarity: "rare" },
// Zone 4: shadow_marshes
{ id: "marsh_root", name: "Marsh Root", description: "Roots from the strangler plants that thrive in the fog-choked depths. Toxic without extensive preparation. Worth it, usually.", zoneId: "shadow_marshes", rarity: "common" },
{ id: "shadow_essence", name: "Shadow Essence", description: "Distilled darkness, caught in a vial before it could dissipate. Heavy and cold, and absolutely lightless.", zoneId: "shadow_marshes", rarity: "uncommon" },
{ id: "cursed_bone", name: "Cursed Bone", description: "Bone from something that died in the marsh so long ago it has become part of it. The curse runs deep through the marrow.", zoneId: "shadow_marshes", rarity: "rare" },
// Zone 5: volcanic_depths
{ id: "magma_stone", name: "Magma Stone", description: "Cooled lava that retained its internal heat. Warm to the touch even centuries after solidifying from whatever it once was.", zoneId: "volcanic_depths", rarity: "common" },
{ id: "ember_crystal", name: "Ember Crystal", description: "A crystal grown in the heart of a cooling magma chamber. Burns without being consumed, endlessly.", zoneId: "volcanic_depths", rarity: "uncommon" },
{ id: "legendary_ore", name: "Legendary Ore", description: "Ore from a seam that the fire elementals guard jealously. What it forges into is extraordinary by any measure.", zoneId: "volcanic_depths", rarity: "rare" },
// Zone 6: astral_void
{ id: "stardust", name: "Stardust", description: "Particulate matter from dying stars, collected from the void between worlds. Glitters even in total darkness.", zoneId: "astral_void", rarity: "common" },
{ id: "astral_thread", name: "Astral Thread", description: "Filaments of solidified probability. Handle with care — they remember every possible future they passed through.", zoneId: "astral_void", rarity: "uncommon" },
{ id: "void_crystal", name: "Void Crystal", description: "A crystal that formed in the spaces between spaces. Technically exists in several places simultaneously. Don't think too hard about it.", zoneId: "astral_void", rarity: "rare" },
// Zone 7: celestial_reaches
{ id: "celestial_dust", name: "Celestial Dust", description: "Residue from the celestial host's passing. Warm as sunlight and infinitely patient, as if waiting for something to happen.", zoneId: "celestial_reaches", rarity: "common" },
{ id: "divine_fragment", name: "Divine Fragment", description: "A chip of something the celestials discarded as imperfect. By mortal standards, it is extraordinary beyond measure.", zoneId: "celestial_reaches", rarity: "uncommon" },
{ id: "choir_shard", name: "Choir Shard", description: "A crystallised harmonic from the celestial choir. Resonates with a sound felt in the chest rather than heard with the ears.", zoneId: "celestial_reaches", rarity: "rare" },
// Zone 8: abyssal_trench
{ id: "trench_coral", name: "Trench Coral", description: "Coral from the deepest trenches where no light reaches and no warmth remains. Black as the water around it.", zoneId: "abyssal_trench", rarity: "common" },
{ id: "pressure_gem", name: "Pressure Gem", description: "A gem compressed by aeons of unimaginable pressure at the bottom of all things. Impossibly dense for its size.", zoneId: "abyssal_trench", rarity: "uncommon" },
{ id: "ancient_tooth", name: "Ancient Tooth", description: "A tooth from whatever has been waiting in the trench since before your world was made. It is very large.", zoneId: "abyssal_trench", rarity: "rare" },
// Zone 9: infernal_court
{ id: "brimstone_flake", name: "Brimstone Flake", description: "Sulphur residue from the court's perpetual fires. The smell never fully fades, no matter how carefully it is stored.", zoneId: "infernal_court", rarity: "common" },
{ id: "demon_ichor", name: "Demon Ichor", description: "Extracted from the court's refuse. Corrosive, powerful, and deeply unpleasant in every measurable way.", zoneId: "infernal_court", rarity: "uncommon" },
{ id: "soul_residue", name: "Soul Residue", description: "What remains after a soul has been fully processed by the court. Carries faint echoes of what it was before.", zoneId: "infernal_court", rarity: "rare" },
// Zone 10: crystalline_spire
{ id: "prism_dust", name: "Prism Dust", description: "Ground from the spire's outer facets. Each particle contains a compressed possibility that has not yet resolved.", zoneId: "crystalline_spire", rarity: "common" },
{ id: "calculation_shard", name: "Calculation Shard", description: "A fragment of the spire's core intelligence. Still running calculations on something that may or may not have an answer.", zoneId: "crystalline_spire", rarity: "uncommon" },
{ id: "possibility_crystal", name: "Possibility Crystal", description: "A crystal that contains a future that never happened. Treat carefully. The future remembers being possible.", zoneId: "crystalline_spire", rarity: "rare" },
// Zone 11: void_sanctum
{ id: "null_matter", name: "Null Matter", description: "Matter that exists in the space between spaces. Lacks most standard properties in ways that should not be possible.", zoneId: "void_sanctum", rarity: "common" },
{ id: "resonance_fragment", name: "Resonance Fragment", description: "A shard of the call that drew your guild here. Still resonant, still reaching toward something none of you can name.", zoneId: "void_sanctum", rarity: "uncommon" },
{ id: "sanctum_core", name: "Sanctum Core", description: "From the heart of the sanctum itself. What it does is undefined. What it is cannot be satisfactorily described.", zoneId: "void_sanctum", rarity: "rare" },
// Zone 12: eternal_throne
{ id: "throne_dust", name: "Throne Dust", description: "Residue from the base of the eternal throne. Old beyond any measurement that applies to things your guild understands.", zoneId: "eternal_throne", rarity: "common" },
{ id: "crown_fragment", name: "Crown Fragment", description: "A chip from one of the throne's crown-like spires. Authority made into something your hands can hold.", zoneId: "eternal_throne", rarity: "uncommon" },
{ id: "eternity_splinter", name: "Eternity Splinter", description: "From the throne's arm. Carries the weight of every decision ever made here, compressed into splinter-form.", zoneId: "eternal_throne", rarity: "rare" },
// Zone 13: primordial_chaos
{ id: "chaos_fragment", name: "Chaos Fragment", description: "A solidified moment of chaos. Still undecided about its own properties, which change depending on how you look at it.", zoneId: "primordial_chaos", rarity: "common" },
{ id: "creation_shard", name: "Creation Shard", description: "A fragment from when something was being made here. What was being made is unclear. Something important, probably.", zoneId: "primordial_chaos", rarity: "uncommon" },
{ id: "primordial_essence", name: "Primordial Essence", description: "The raw stuff of creation, before it became anything specific. Handle with care. It wants to become things.", zoneId: "primordial_chaos", rarity: "rare" },
// Zone 14: infinite_expanse
{ id: "expanse_dust", name: "Expanse Dust", description: "Gathered from somewhere in the expanse. Direction is uncertain. Distance from the collection point is uncertain.", zoneId: "infinite_expanse", rarity: "common" },
{ id: "distance_crystal", name: "Distance Crystal", description: "A crystal that contains compressed distance. It weighs more than its size suggests. Much more. Do not drop it.", zoneId: "infinite_expanse", rarity: "uncommon" },
{ id: "infinity_shard", name: "Infinity Shard", description: "A fragment of the expanse's edge, which the expanse does not technically have. This should not exist. It does anyway.", zoneId: "infinite_expanse", rarity: "rare" },
// Zone 15: reality_forge
{ id: "forge_ash", name: "Forge Ash", description: "Ash from the forge's fires. Contains fragments of unrealised realities that never quite made it to existence.", zoneId: "reality_forge", rarity: "common" },
{ id: "creation_tool", name: "Creation Tool", description: "A worn tool left by whatever worked here before your universe existed. Still functional in ways that are difficult to explain.", zoneId: "reality_forge", rarity: "uncommon" },
{ id: "reality_shard", name: "Reality Shard", description: "A flawed reality, discarded by the forge as below standard. Still contains everything a universe needs.", zoneId: "reality_forge", rarity: "rare" },
// Zone 16: cosmic_maelstrom
{ id: "maelstrom_debris", name: "Maelstrom Debris", description: "Debris from a galaxy that got too close to the maelstrom. Compressed to a size your guild can actually carry.", zoneId: "cosmic_maelstrom", rarity: "common" },
{ id: "force_crystal", name: "Force Crystal", description: "A crystal that formed at the intersection of several fundamental forces that should never have been in the same place.", zoneId: "cosmic_maelstrom", rarity: "uncommon" },
{ id: "cosmic_fragment", name: "Cosmic Fragment", description: "A fragment from the maelstrom's eye. Impossibly calm. Whatever is at the centre has been there since the beginning.", zoneId: "cosmic_maelstrom", rarity: "rare" },
// Zone 17: primeval_sanctum
{ id: "ancient_dust", name: "Ancient Dust", description: "Dust from the oldest place. Has been here since before the concept of 'here' had been invented.", zoneId: "primeval_sanctum", rarity: "common" },
{ id: "memory_shard", name: "Memory Shard", description: "A shard of something that remembers the moment before the first moment. The memory is in the material itself.", zoneId: "primeval_sanctum", rarity: "uncommon" },
{ id: "primeval_relic", name: "Primeval Relic", description: "An artefact from the first thing to exist in this place. What it did is unknown. That it mattered is beyond doubt.", zoneId: "primeval_sanctum", rarity: "rare" },
// Zone 18: the_absolute
{ id: "absolute_fragment", name: "Absolute Fragment", description: "A fragment of the final truth. It is difficult to look at directly, and impossible to look away from once you start.", zoneId: "the_absolute", rarity: "common" },
{ id: "boundary_shard", name: "Boundary Shard", description: "From the edge of everything. On one side: everything. On the other: nothing. This is from the very boundary between them.", zoneId: "the_absolute", rarity: "uncommon" },
{ id: "omega_crystal", name: "Omega Crystal", description: "The last crystal. After this, there are no more. It knows this. You can tell from the way it sits in your hand.", zoneId: "the_absolute", rarity: "rare" },
];
+327
View File
@@ -0,0 +1,327 @@
import type { CraftingRecipe } from "@elysium/types";
export const RECIPES: CraftingRecipe[] = [
// Zone 1: verdant_vale
{
id: "heartwood_tincture",
name: "Heartwood Tincture",
description: "Sap from ancient heartwood trees, refined and bound with forest crystal. The resulting tincture accelerates the flow of wealth through your guild in ways the alchemists cannot fully explain.",
zoneId: "verdant_vale",
requiredMaterials: [{ materialId: "verdant_sap", quantity: 5 }, { materialId: "forest_crystal", quantity: 3 }],
bonus: { type: "gold_income", value: 1.05 },
},
{
id: "elder_bark_shield",
name: "Elder Bark Shield",
description: "A ward fashioned from bark older than the kingdom. Its presence on the battlefield is not merely physical — something ancient watches through it.",
zoneId: "verdant_vale",
requiredMaterials: [{ materialId: "elder_bark", quantity: 2 }, { materialId: "verdant_sap", quantity: 8 }],
bonus: { type: "combat_power", value: 1.08 },
},
// Zone 2: shattered_ruins
{
id: "runic_binding",
name: "Runic Binding",
description: "The ruin dust and cursed fragments, carefully worked into a binding that borrows the essence-drawing power of the fallen civilisation's final enchantments.",
zoneId: "shattered_ruins",
requiredMaterials: [{ materialId: "ruin_dust", quantity: 8 }, { materialId: "cursed_fragment", quantity: 4 }],
bonus: { type: "essence_income", value: 1.05 },
},
{
id: "dragon_scale_charm",
name: "Dragon Scale Charm",
description: "A charm set with a chip of the elder dragon's scale. The dragon would be furious if he knew. He would also be impressed.",
zoneId: "shattered_ruins",
requiredMaterials: [{ materialId: "dragonscale_chip", quantity: 2 }, { materialId: "ruin_dust", quantity: 10 }],
bonus: { type: "gold_income", value: 1.08 },
},
// Zone 3: frozen_peaks
{
id: "glacial_lens",
name: "Glacial Lens",
description: "Glacial ice ground and shaped into a lens that clarifies and focuses. Holding it, your guild's actions become sharper, more precise, more effective per motion.",
zoneId: "frozen_peaks",
requiredMaterials: [{ materialId: "glacial_ice", quantity: 8 }, { materialId: "frost_crystal", quantity: 4 }],
bonus: { type: "click_power", value: 1.08 },
},
{
id: "void_fragment_amulet",
name: "Void Fragment Amulet",
description: "The void shard set in frost crystal, creating something that should not be possible: a stable window into the spaces between spaces. Profitable beyond what physics suggests.",
zoneId: "frozen_peaks",
requiredMaterials: [{ materialId: "void_shard", quantity: 2 }, { materialId: "frost_crystal", quantity: 6 }],
bonus: { type: "gold_income", value: 1.10 },
},
// Zone 4: shadow_marshes
{
id: "shadow_extract",
name: "Shadow Extract",
description: "Marsh roots processed with shadow essence into a refined compound that somehow makes the essence of things flow more freely toward your guild hall.",
zoneId: "shadow_marshes",
requiredMaterials: [{ materialId: "marsh_root", quantity: 8 }, { materialId: "shadow_essence", quantity: 4 }],
bonus: { type: "essence_income", value: 1.08 },
},
{
id: "cursed_focus",
name: "Cursed Focus",
description: "The cursed bone and shadow essence combined into a focus that doesn't so much help your party fight as make things afraid of them before the battle begins.",
zoneId: "shadow_marshes",
requiredMaterials: [{ materialId: "cursed_bone", quantity: 2 }, { materialId: "shadow_essence", quantity: 6 }],
bonus: { type: "combat_power", value: 1.10 },
},
// Zone 5: volcanic_depths
{
id: "magma_core_seal",
name: "Magma Core Seal",
description: "A seal forged in the volcanic depths, using the eternal heat of the magma stone and ember crystal to create something that burns wealth into existence continuously.",
zoneId: "volcanic_depths",
requiredMaterials: [{ materialId: "magma_stone", quantity: 8 }, { materialId: "ember_crystal", quantity: 4 }],
bonus: { type: "gold_income", value: 1.10 },
},
{
id: "elemental_ore_ingot",
name: "Elemental Ore Ingot",
description: "The legendary ore, smelted in the volcanic forges with magma stone as fuel. What emerges is something the fire elementals recognise — and fear slightly.",
zoneId: "volcanic_depths",
requiredMaterials: [{ materialId: "legendary_ore", quantity: 2 }, { materialId: "magma_stone", quantity: 10 }],
bonus: { type: "combat_power", value: 1.12 },
},
// Zone 6: astral_void
{
id: "star_chart",
name: "Star Chart",
description: "Stardust arranged along astral threads into a map of the void that somehow, impossibly, shows your guild where to press and how to press it for maximum effect.",
zoneId: "astral_void",
requiredMaterials: [{ materialId: "stardust", quantity: 10 }, { materialId: "astral_thread", quantity: 4 }],
bonus: { type: "click_power", value: 1.12 },
},
{
id: "void_crystal_matrix",
name: "Void Crystal Matrix",
description: "A void crystal suspended in a matrix of stardust — something that exists in several places simultaneously and draws gold from all of them at once.",
zoneId: "astral_void",
requiredMaterials: [{ materialId: "void_crystal", quantity: 2 }, { materialId: "stardust", quantity: 12 }],
bonus: { type: "gold_income", value: 1.12 },
},
// Zone 7: celestial_reaches
{
id: "celestial_lens",
name: "Celestial Lens",
description: "Celestial dust and divine fragments ground into a lens that sees the essence in all things and draws a portion of it — gently, as the celestials would prefer.",
zoneId: "celestial_reaches",
requiredMaterials: [{ materialId: "celestial_dust", quantity: 10 }, { materialId: "divine_fragment", quantity: 4 }],
bonus: { type: "essence_income", value: 1.12 },
},
{
id: "choir_resonator",
name: "Choir Resonator",
description: "A choir shard set in divine fragments, still humming with the celestial harmonic. The resonance makes gold flow in its direction — not compelled, simply invited.",
zoneId: "celestial_reaches",
requiredMaterials: [{ materialId: "choir_shard", quantity: 2 }, { materialId: "divine_fragment", quantity: 6 }],
bonus: { type: "gold_income", value: 1.15 },
},
// Zone 8: abyssal_trench
{
id: "pressure_forged_core",
name: "Pressure-Forged Core",
description: "Trench coral and pressure gem combined under conditions that should have destroyed both. The result is something that survived conditions nothing should survive, which is ideal for combat.",
zoneId: "abyssal_trench",
requiredMaterials: [{ materialId: "trench_coral", quantity: 10 }, { materialId: "pressure_gem", quantity: 4 }],
bonus: { type: "combat_power", value: 1.15 },
},
{
id: "ancient_fang_talisman",
name: "Ancient Fang Talisman",
description: "A talisman set with the ancient tooth, suspended in trench coral carvings. Your party fights differently with this at their chest. More deliberately. More completely.",
zoneId: "abyssal_trench",
requiredMaterials: [{ materialId: "ancient_tooth", quantity: 2 }, { materialId: "trench_coral", quantity: 12 }],
bonus: { type: "click_power", value: 1.15 },
},
// Zone 9: infernal_court
{
id: "court_seal",
name: "Court Seal",
description: "A seal of infernal court authority, forged from brimstone and ichor. The court doesn't know you have this. It's better that way. It does make trade extremely efficient.",
zoneId: "infernal_court",
requiredMaterials: [{ materialId: "brimstone_flake", quantity: 10 }, { materialId: "demon_ichor", quantity: 5 }],
bonus: { type: "gold_income", value: 1.15 },
},
{
id: "soul_bound_catalyst",
name: "Soul-Bound Catalyst",
description: "Soul residue and demon ichor worked into a catalyst that draws the essence from everything around it — gently, without drawing the court's attention. Usually.",
zoneId: "infernal_court",
requiredMaterials: [{ materialId: "soul_residue", quantity: 2 }, { materialId: "demon_ichor", quantity: 8 }],
bonus: { type: "essence_income", value: 1.15 },
},
// Zone 10: crystalline_spire
{
id: "prism_array",
name: "Prism Array",
description: "Prism dust and calculation shards assembled into an array that the spire's intelligence would call elegant, if it had aesthetic preferences, which it might.",
zoneId: "crystalline_spire",
requiredMaterials: [{ materialId: "prism_dust", quantity: 10 }, { materialId: "calculation_shard", quantity: 4 }],
bonus: { type: "click_power", value: 1.18 },
},
{
id: "possibility_engine",
name: "Possibility Engine",
description: "A possibility crystal contained within a calculation shard framework. It runs through every possible outcome of every guild action and finds the one with the highest gold yield.",
zoneId: "crystalline_spire",
requiredMaterials: [{ materialId: "possibility_crystal", quantity: 2 }, { materialId: "calculation_shard", quantity: 6 }],
bonus: { type: "gold_income", value: 1.18 },
},
// Zone 11: void_sanctum
{
id: "null_field_generator",
name: "Null Field Generator",
description: "Null matter and resonance fragments assembled into something that generates a field where the laws governing how hard things are to kill become negotiable.",
zoneId: "void_sanctum",
requiredMaterials: [{ materialId: "null_matter", quantity: 10 }, { materialId: "resonance_fragment", quantity: 4 }],
bonus: { type: "combat_power", value: 1.18 },
},
{
id: "sanctum_key",
name: "Sanctum Key",
description: "A sanctum core and resonance fragments shaped into a key to something. The essence flows through it like it was designed to carry essence, which it may have been.",
zoneId: "void_sanctum",
requiredMaterials: [{ materialId: "sanctum_core", quantity: 2 }, { materialId: "resonance_fragment", quantity: 6 }],
bonus: { type: "essence_income", value: 1.18 },
},
// Zone 12: eternal_throne
{
id: "crown_circlet",
name: "Crown Circlet",
description: "Throne dust pressed into throne dust-lacquered crown fragments, shaped into a circlet. Wearing it — metaphorically — makes gold accumulate with the inevitability of authority.",
zoneId: "eternal_throne",
requiredMaterials: [{ materialId: "throne_dust", quantity: 10 }, { materialId: "crown_fragment", quantity: 4 }],
bonus: { type: "gold_income", value: 1.20 },
},
{
id: "eternity_bound_ring",
name: "Eternity-Bound Ring",
description: "An eternity splinter set into crown fragments, shaped into a ring. What it binds is unclear. Whatever it is, it makes your party significantly harder to kill.",
zoneId: "eternal_throne",
requiredMaterials: [{ materialId: "eternity_splinter", quantity: 2 }, { materialId: "crown_fragment", quantity: 6 }],
bonus: { type: "combat_power", value: 1.20 },
},
// Zone 13: primordial_chaos
{
id: "chaos_lens",
name: "Chaos Lens",
description: "Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
zoneId: "primordial_chaos",
requiredMaterials: [{ materialId: "chaos_fragment", quantity: 10 }, { materialId: "creation_shard", quantity: 4 }],
bonus: { type: "click_power", value: 1.20 },
},
{
id: "creation_core",
name: "Creation Core",
description: "Primordial essence held in a creation shard framework. It hums constantly. Gold flows toward it with the enthusiasm of something that wants to become something.",
zoneId: "primordial_chaos",
requiredMaterials: [{ materialId: "primordial_essence", quantity: 2 }, { materialId: "creation_shard", quantity: 6 }],
bonus: { type: "gold_income", value: 1.22 },
},
// Zone 14: infinite_expanse
{
id: "distance_coil",
name: "Distance Coil",
description: "Expanse dust wound around distance crystals into a coil that draws essence from distances too vast to measure, compressing it into something your guild can actually use.",
zoneId: "infinite_expanse",
requiredMaterials: [{ materialId: "expanse_dust", quantity: 10 }, { materialId: "distance_crystal", quantity: 4 }],
bonus: { type: "essence_income", value: 1.20 },
},
{
id: "infinity_prism",
name: "Infinity Prism",
description: "An infinity shard mounted in a distance crystal frame. The prism reflects gold from an infinite number of directions simultaneously. The math works out favourably.",
zoneId: "infinite_expanse",
requiredMaterials: [{ materialId: "infinity_shard", quantity: 2 }, { materialId: "distance_crystal", quantity: 6 }],
bonus: { type: "gold_income", value: 1.22 },
},
// Zone 15: reality_forge
{
id: "reality_ingot",
name: "Reality Ingot",
description: "Forge ash smelted with creation tools into an ingot of compressed reality. Your party hits harder when carrying it. Reality does not enjoy being concentrated like this.",
zoneId: "reality_forge",
requiredMaterials: [{ materialId: "forge_ash", quantity: 10 }, { materialId: "creation_tool", quantity: 4 }],
bonus: { type: "combat_power", value: 1.22 },
},
{
id: "universe_seed",
name: "Universe Seed",
description: "A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
zoneId: "reality_forge",
requiredMaterials: [{ materialId: "reality_shard", quantity: 2 }, { materialId: "creation_tool", quantity: 6 }],
bonus: { type: "click_power", value: 1.22 },
},
// Zone 16: cosmic_maelstrom
{
id: "force_lens",
name: "Force Lens",
description: "Maelstrom debris and force crystals ground into a lens at the intersection of fundamental forces. Gold flows toward it with the same inevitability that galaxies flow toward gravity.",
zoneId: "cosmic_maelstrom",
requiredMaterials: [{ materialId: "maelstrom_debris", quantity: 10 }, { materialId: "force_crystal", quantity: 4 }],
bonus: { type: "gold_income", value: 1.25 },
},
{
id: "maelstrom_eye",
name: "Maelstrom Eye",
description: "A cosmic fragment suspended in a force crystal matrix — a piece of the maelstrom's impossible calm, holding the eye of the storm. Essence accumulates in its vicinity.",
zoneId: "cosmic_maelstrom",
requiredMaterials: [{ materialId: "cosmic_fragment", quantity: 2 }, { materialId: "force_crystal", quantity: 6 }],
bonus: { type: "essence_income", value: 1.22 },
},
// Zone 17: primeval_sanctum
{
id: "ancient_memory_array",
name: "Ancient Memory Array",
description: "Ancient dust and memory shards arranged into something that remembers how to fight before fighting was invented. Your party benefits from this instinct enormously.",
zoneId: "primeval_sanctum",
requiredMaterials: [{ materialId: "ancient_dust", quantity: 10 }, { materialId: "memory_shard", quantity: 4 }],
bonus: { type: "combat_power", value: 1.25 },
},
{
id: "first_artefact",
name: "First Artefact",
description: "The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
zoneId: "primeval_sanctum",
requiredMaterials: [{ materialId: "primeval_relic", quantity: 2 }, { materialId: "memory_shard", quantity: 6 }],
bonus: { type: "click_power", value: 1.25 },
},
// Zone 18: the_absolute
{
id: "final_truth_lens",
name: "Final Truth Lens",
description: "Absolute fragments and boundary shards ground into a lens that sees to the end of all things — and in seeing, draws the wealth inherent in finality toward your guild.",
zoneId: "the_absolute",
requiredMaterials: [{ materialId: "absolute_fragment", quantity: 10 }, { materialId: "boundary_shard", quantity: 4 }],
bonus: { type: "gold_income", value: 1.30 },
},
{
id: "omega_convergence",
name: "Omega Convergence",
description: "The last omega crystal, set at the convergence of boundary shards in the precise arrangement of an ending. What it does to combat is what endings do to everything: make it final.",
zoneId: "the_absolute",
requiredMaterials: [{ materialId: "omega_crystal", quantity: 2 }, { materialId: "boundary_shard", quantity: 6 }],
bonus: { type: "combat_power", value: 1.30 },
},
];
+7 -1
View File
@@ -65,6 +65,8 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1;
let goldGained = 0;
let essenceGained = 0;
@@ -94,6 +96,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
echoIncome *
equipmentGoldMultiplier *
setGoldMultiplier *
craftedGoldMultiplier *
deltaSeconds;
essenceGained +=
@@ -102,6 +105,7 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
upgradeMultiplier *
prestige *
runestonesEssence *
craftedEssenceMultiplier *
deltaSeconds;
}
@@ -285,6 +289,7 @@ export const calculateClickPower = (state: GameState): number => {
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1;
return (
state.baseClickPower *
@@ -293,6 +298,7 @@ export const calculateClickPower = (state: GameState): number => {
runestonesClick *
echoIncome *
equipmentClickMultiplier *
setClickMultiplier
setClickMultiplier *
craftedClickMultiplier
);
};
+331
View File
@@ -2543,3 +2543,334 @@ body {
cursor: not-allowed;
opacity: 0.4;
}
/* ── Exploration Panel ───────────────────────────────────────────────────── */
.exploration-panel .panel-header {
margin-bottom: 0.5rem;
}
.exploration-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.75rem;
}
.exploration-card {
align-items: center;
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 0.75rem 1rem;
}
.exploration-card.exploration-locked {
opacity: 0.55;
}
.exploration-info h3 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.exploration-info p {
color: var(--colour-text-muted);
font-size: 0.85rem;
margin: 0 0 0.25rem;
}
.exploration-duration {
color: var(--colour-text-muted);
font-size: 0.8rem;
}
.exploration-discovered {
font-size: 0.85rem;
margin-left: 0.25rem;
}
.exploration-action {
flex-shrink: 0;
}
.start-quest-button,
.collect-button {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
padding: 0.45rem 1rem;
transition: opacity 0.2s;
white-space: nowrap;
}
.collect-button {
background: #16a34a;
}
.start-quest-button:hover:not(:disabled),
.collect-button:hover:not(:disabled) {
opacity: 0.85;
}
.start-quest-button:disabled,
.collect-button:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.exploration-result {
background: var(--colour-surface);
border: 1px solid var(--colour-accent);
border-radius: var(--radius);
margin-bottom: 0.75rem;
padding: 0.75rem 1rem;
position: relative;
}
.exploration-result-close {
background: transparent;
border: none;
color: var(--colour-text-muted);
cursor: pointer;
font-size: 1rem;
padding: 0;
position: absolute;
right: 0.75rem;
top: 0.6rem;
}
.exploration-result-close:hover {
color: var(--colour-text);
}
.exploration-nothing {
color: var(--colour-text-muted);
font-style: italic;
margin: 0;
}
.exploration-event-text {
font-style: italic;
margin: 0 0 0.5rem;
}
.exploration-rewards {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.25rem;
}
.reward-tag {
background: #14532d;
border-radius: 999px;
color: #4ade80;
font-size: 0.8rem;
font-weight: 600;
padding: 0.2rem 0.65rem;
}
.reward-tag.negative {
background: #450a0a;
color: #f87171;
}
.reward-tag.material-tag {
background: #1e3a5f;
color: #93c5fd;
}
/* ── Crafting Panel ───────────────────────────────────────────────────────── */
.crafting-panel .panel-header {
margin-bottom: 0.5rem;
}
.crafting-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 0.75rem;
}
.materials-section h3,
.recipes-section h3 {
font-size: 1rem;
font-weight: 700;
margin: 0 0 0.5rem;
}
.materials-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.material-card {
align-items: center;
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-left-width: 3px;
border-radius: var(--radius);
display: flex;
justify-content: space-between;
padding: 0.4rem 0.75rem;
}
.material-card.rarity-common {
border-left-color: #6b7280;
}
.material-card.rarity-uncommon {
border-left-color: #16a34a;
}
.material-card.rarity-rare {
border-left-color: #7c3aed;
}
.material-card.material-empty {
opacity: 0.45;
}
.material-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.material-name {
font-size: 0.9rem;
font-weight: 600;
}
.material-rarity {
color: var(--colour-text-muted);
font-size: 0.75rem;
text-transform: capitalize;
}
.material-quantity {
font-size: 0.95rem;
font-weight: 700;
}
.recipes-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.recipe-card {
align-items: flex-start;
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 0.75rem 1rem;
}
.recipe-card.recipe-crafted {
border-color: #16a34a;
opacity: 0.75;
}
.recipe-card.recipe-unaffordable {
opacity: 0.55;
}
.recipe-info {
flex: 1;
min-width: 0;
}
.recipe-info h4 {
font-size: 0.95rem;
font-weight: 700;
margin: 0 0 0.2rem;
}
.recipe-description {
color: var(--colour-text-muted);
font-size: 0.82rem;
margin: 0 0 0.4rem;
}
.recipe-bonus {
align-items: center;
display: flex;
font-size: 0.85rem;
font-weight: 600;
gap: 0.4rem;
margin-bottom: 0.4rem;
}
.bonus-value {
color: #fbbf24;
}
.recipe-requirements {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.req-tag {
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
}
.req-tag.req-met {
background: #14532d;
color: #4ade80;
}
.req-tag.req-missing {
background: #450a0a;
color: #f87171;
}
.recipe-action {
flex-shrink: 0;
padding-top: 0.15rem;
}
.craft-button {
background: #7c3aed;
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
padding: 0.45rem 1rem;
transition: opacity 0.2s;
white-space: nowrap;
}
.craft-button:hover:not(:disabled) {
opacity: 0.85;
}
.craft-button:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.empty-zone {
color: var(--colour-text-muted);
font-size: 0.9rem;
}
+18
View File
@@ -1,4 +1,15 @@
export type { ApotheosisData } from "./interfaces/Apotheosis.js";
export type { CraftingBonusType, CraftingMaterialRequirement, CraftingRecipe } from "./interfaces/CraftingRecipe.js";
export type {
ExplorationArea,
ExplorationAreaState,
ExplorationEvent,
ExplorationEventEffect,
ExplorationEventEffectType,
ExplorationMaterialDrop,
ExplorationState,
} from "./interfaces/Exploration.js";
export type { Material, MaterialRarity } from "./interfaces/Material.js";
export type { CodexEntry, CodexState } from "./interfaces/Codex.js";
export type {
Achievement,
@@ -19,6 +30,13 @@ export type {
BuyEchoUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
CraftRecipeRequest,
CraftRecipeResponse,
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
GiteaRelease,
LoadResponse,
PrestigeRequest,
+45 -9
View File
@@ -67,9 +67,7 @@ export interface BossChallengeResponse {
}>;
}
export interface PrestigeRequest {
characterName: string;
}
export type PrestigeRequest = Record<string, never>;
export interface PrestigeResponse {
runestones: number;
@@ -129,9 +127,7 @@ export interface UpdateProfileResponse {
profileSettings: ProfileSettings;
}
export interface TranscendenceRequest {
characterName: string;
}
export type TranscendenceRequest = Record<string, never>;
export interface TranscendenceResponse {
echoes: number;
@@ -152,9 +148,7 @@ export interface BuyEchoUpgradeResponse {
echoMetaMultiplier: number;
}
export interface ApotheosisRequest {
characterName: string;
}
export type ApotheosisRequest = Record<string, never>;
export interface ApotheosisResponse {
newApotheosisCount: number;
@@ -176,5 +170,47 @@ export interface AboutResponse {
releases: GiteaRelease[];
}
export interface ExploreStartRequest {
areaId: string;
}
export interface ExploreStartResponse {
areaId: string;
endsAt: number;
}
export interface ExploreCollectRequest {
areaId: string;
}
export interface ExploreCollectEventResult {
text: string;
goldChange: number;
essenceChange: number;
materialGained: { materialId: string; quantity: number } | null;
adventurerLostCount: number;
}
export interface ExploreCollectResponse {
foundNothing: boolean;
nothingMessage?: string;
materialsFound: Array<{ materialId: string; quantity: number }>;
event: ExploreCollectEventResult | null;
}
export interface CraftRecipeRequest {
recipeId: string;
}
export interface CraftRecipeResponse {
recipeId: string;
bonusType: string;
bonusValue: number;
craftedGoldMultiplier: number;
craftedEssenceMultiplier: number;
craftedClickMultiplier: number;
craftedCombatMultiplier: number;
}
// Re-export for convenience
export type { ProfileSettings };
+1 -1
View File
@@ -2,7 +2,7 @@ export interface CodexEntry {
id: string;
title: string;
content: string;
sourceType: "boss" | "quest" | "equipment" | "adventurer" | "upgrade" | "prestige" | "zone";
sourceType: "boss" | "quest" | "equipment" | "adventurer" | "upgrade" | "prestige" | "zone" | "exploration" | "recipe";
sourceId: string;
zoneId: string;
}
@@ -0,0 +1,19 @@
export type CraftingBonusType = "gold_income" | "essence_income" | "click_power" | "combat_power";
export interface CraftingMaterialRequirement {
materialId: string;
quantity: number;
}
export interface CraftingRecipe {
id: string;
name: string;
description: string;
zoneId: string;
requiredMaterials: CraftingMaterialRequirement[];
bonus: {
type: CraftingBonusType;
/** Multiplicative bonus value, e.g. 1.1 = +10% */
value: number;
};
}
@@ -0,0 +1,67 @@
export type ExplorationEventEffectType =
| "gold_gain"
| "gold_loss"
| "essence_gain"
| "material_gain"
| "adventurer_loss";
export interface ExplorationEventEffect {
type: ExplorationEventEffectType;
/** Gold amount for gold_gain / gold_loss */
amount?: number;
/** Material ID for material_gain */
materialId?: string;
/** Quantity for material_gain */
quantity?: number;
/** Fraction (01) of total adventurers lost for adventurer_loss */
fraction?: number;
}
export interface ExplorationEvent {
id: string;
text: string;
effect: ExplorationEventEffect;
}
export interface ExplorationMaterialDrop {
materialId: string;
minQuantity: number;
maxQuantity: number;
/** Relative probability weight — higher = more likely */
weight: number;
}
export interface ExplorationArea {
id: string;
name: string;
description: string;
zoneId: string;
durationSeconds: number;
possibleMaterials: ExplorationMaterialDrop[];
events: ExplorationEvent[];
}
export interface ExplorationAreaState {
id: string;
status: "locked" | "available" | "in_progress" | "completed";
/** Unix timestamp when exploration started (set when status becomes in_progress) */
startedAt?: number;
/** True after the first successful collect — used for codex unlock detection */
completedOnce?: boolean;
}
export interface ExplorationState {
areas: ExplorationAreaState[];
/** Current material inventory */
materials: Array<{ materialId: string; quantity: number }>;
/** IDs of crafting recipes that have been crafted (resets on prestige) */
craftedRecipeIds: string[];
/** Pre-computed gold income multiplier from all crafted recipes */
craftedGoldMultiplier: number;
/** Pre-computed essence income multiplier from all crafted recipes */
craftedEssenceMultiplier: number;
/** Pre-computed click power multiplier from all crafted recipes */
craftedClickMultiplier: number;
/** Pre-computed combat power multiplier from all crafted recipes */
craftedCombatMultiplier: number;
}
@@ -4,6 +4,7 @@ import type { Boss } from "./Boss.js";
import type { ApotheosisData } from "./Apotheosis.js";
import type { CodexState } from "./Codex.js";
import type { DailyChallengeState } from "./DailyChallenge.js";
import type { ExplorationState } from "./Exploration.js";
import type { TranscendenceData } from "./Transcendence.js";
import type { Equipment } from "./Equipment.js";
import type { Player } from "./Player.js";
@@ -36,4 +37,6 @@ export interface GameState {
transcendence?: TranscendenceData;
/** Apotheosis (third prestige layer) state — optional for backwards compatibility */
apotheosis?: ApotheosisData;
/** Exploration and crafting state — optional for backwards compatibility */
exploration?: ExplorationState;
}
@@ -0,0 +1,9 @@
export type MaterialRarity = "common" | "uncommon" | "rare";
export interface Material {
id: string;
name: string;
description: string;
zoneId: string;
rarity: MaterialRarity;
}