generated from nhcarrigan/template
Compare commits
18 Commits
main
...
feat/goddess
| Author | SHA1 | Date | |
|---|---|---|---|
|
d45b80fe4a
|
|||
|
e02827dbb6
|
|||
|
1e0a7b142a
|
|||
|
a7598dca12
|
|||
|
bd88eecda5
|
|||
|
3e34701d32
|
|||
|
d9d1228172
|
|||
|
8fa5d12f05
|
|||
|
7f43dc725e
|
|||
|
53a026da62
|
|||
|
4012635076
|
|||
|
9548b460f2
|
|||
|
91c9f52daf
|
|||
|
96d6759661
|
|||
|
0d36b255ee
|
|||
|
7da1f3942d
|
|||
|
c5d1f53eef
|
|||
|
c09777199a
|
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
import type { GoddessAchievement } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessAchievements: Array<GoddessAchievement> = [
|
||||
// TotalPrayersEarned milestones
|
||||
{
|
||||
condition: { amount: 1000, type: "totalPrayersEarned" },
|
||||
description: "Offer your first thousand prayers and feel the divine stir for the first time.",
|
||||
icon: "🕯️",
|
||||
id: "prayers_thousand",
|
||||
name: "Whisper of the Faithful",
|
||||
reward: { divinity: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 10_000, type: "totalPrayersEarned" },
|
||||
description: "Ten thousand prayers rise like incense smoke toward the heavens.",
|
||||
icon: "🙏",
|
||||
id: "prayers_ten_thousand",
|
||||
name: "Voice of Devotion",
|
||||
reward: { divinity: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 100_000, type: "totalPrayersEarned" },
|
||||
description: "A hundred thousand supplications echoing across the sacred halls.",
|
||||
icon: "⛪",
|
||||
id: "prayers_hundred_thousand",
|
||||
name: "Chorus of the Devoted",
|
||||
reward: { divinity: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1_000_000, type: "totalPrayersEarned" },
|
||||
description: "One million prayers — the Goddess turns her gaze upon you at last.",
|
||||
icon: "✨",
|
||||
id: "prayers_million",
|
||||
name: "Radiant Supplicant",
|
||||
reward: { divinity: 300 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 10_000_000, type: "totalPrayersEarned" },
|
||||
description: "Ten million prayers offered; the sacred flame burns without end.",
|
||||
icon: "🔥",
|
||||
id: "prayers_ten_million",
|
||||
name: "Eternal Flame of Worship",
|
||||
reward: { divinity: 750 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 100_000_000, type: "totalPrayersEarned" },
|
||||
description: "A hundred million prayers — your faith moves the pillars of the cosmos.",
|
||||
icon: "💫",
|
||||
id: "prayers_hundred_million",
|
||||
name: "Pillar of the Cosmos",
|
||||
reward: { divinity: 1500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1_000_000_000, type: "totalPrayersEarned" },
|
||||
description: "A billion prayers ascend, weaving a tapestry of light across the void.",
|
||||
icon: "🌌",
|
||||
id: "prayers_billion",
|
||||
name: "Weaver of Sacred Light",
|
||||
reward: { divinity: 2500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 10_000_000_000, type: "totalPrayersEarned" },
|
||||
description: "Ten billion prayers; the stars themselves bow in reverence.",
|
||||
icon: "⭐",
|
||||
id: "prayers_ten_billion",
|
||||
name: "Constellation of Faith",
|
||||
reward: { divinity: 3500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 100_000_000_000, type: "totalPrayersEarned" },
|
||||
description: "A hundred billion prayers — the divine throne trembles with your devotion.",
|
||||
icon: "🌠",
|
||||
id: "prayers_hundred_billion",
|
||||
name: "Trembler of the Divine Throne",
|
||||
reward: { divinity: 4250 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1_000_000_000_000, type: "totalPrayersEarned" },
|
||||
description: "A trillion prayers offered — you have become the Goddess's own heartbeat.",
|
||||
icon: "👁️",
|
||||
id: "prayers_trillion",
|
||||
name: "Heartbeat of the Goddess",
|
||||
reward: { divinity: 5000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// GoddessBossesDefeated milestones
|
||||
{
|
||||
condition: { amount: 1, type: "goddessBossesDefeated" },
|
||||
description: "Strike down your first divine adversary and claim your place among the faithful.",
|
||||
icon: "⚔️",
|
||||
id: "goddess_boss_first",
|
||||
name: "Champion's First Blood",
|
||||
reward: { divinity: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 5, type: "goddessBossesDefeated" },
|
||||
description: "Five sacred titans fall before your righteous fury.",
|
||||
icon: "🗡️",
|
||||
id: "goddess_boss_five",
|
||||
name: "Slayer of Sacred Titans",
|
||||
reward: { divinity: 75 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 15, type: "goddessBossesDefeated" },
|
||||
description: "Fifteen divine guardians vanquished — the celestial war begins in earnest.",
|
||||
icon: "🛡️",
|
||||
id: "goddess_boss_fifteen",
|
||||
name: "Celestial Warlord",
|
||||
reward: { divinity: 300 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 30, type: "goddessBossesDefeated" },
|
||||
description: "Thirty heavenly champions broken at your feet.",
|
||||
icon: "💥",
|
||||
id: "goddess_boss_thirty",
|
||||
name: "Breaker of the Heavenly Host",
|
||||
reward: { divinity: 800 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 45, type: "goddessBossesDefeated" },
|
||||
description: "Forty-five divine guardians have crumbled; the heavens ring with your name.",
|
||||
icon: "🌟",
|
||||
id: "goddess_boss_forty_five",
|
||||
name: "Name Written in Heaven",
|
||||
reward: { divinity: 2000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 55, type: "goddessBossesDefeated" },
|
||||
description: "Fifty-five sacred sentinels silenced; even the Goddess watches with awe.",
|
||||
icon: "👑",
|
||||
id: "goddess_boss_fifty_five",
|
||||
name: "Awe of the Goddess",
|
||||
reward: { divinity: 4000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 65, type: "goddessBossesDefeated" },
|
||||
description: "Sixty-five divine colossi toppled; the celestial order bends to your will.",
|
||||
icon: "🌙",
|
||||
id: "goddess_boss_sixty_five",
|
||||
name: "Bender of the Celestial Order",
|
||||
reward: { divinity: 7000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 72, type: "goddessBossesDefeated" },
|
||||
description: "All seventy-two divine guardians have fallen. The heavens stand open before you.",
|
||||
icon: "🏆",
|
||||
id: "goddess_boss_all",
|
||||
name: "Conqueror of the Heavens",
|
||||
reward: { divinity: 10_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// GoddessQuestsCompleted milestones
|
||||
{
|
||||
condition: { amount: 1, type: "goddessQuestsCompleted" },
|
||||
description: "Complete your first sacred trial and prove yourself worthy of divine attention.",
|
||||
icon: "📜",
|
||||
id: "goddess_quest_first",
|
||||
name: "First Sacred Trial",
|
||||
reward: { divinity: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 5, type: "goddessQuestsCompleted" },
|
||||
description: "Five holy tasks fulfilled — the Goddess acknowledges your diligence.",
|
||||
icon: "🌿",
|
||||
id: "goddess_quest_five",
|
||||
name: "Acknowledged by the Divine",
|
||||
reward: { divinity: 40 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 15, type: "goddessQuestsCompleted" },
|
||||
description: "Fifteen sacred errands completed in the name of the eternal light.",
|
||||
icon: "☀️",
|
||||
id: "goddess_quest_fifteen",
|
||||
name: "Errand of Eternal Light",
|
||||
reward: { divinity: 175 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 30, type: "goddessQuestsCompleted" },
|
||||
description: "Thirty divine mandates carried out; your legend grows in the celestial annals.",
|
||||
icon: "📖",
|
||||
id: "goddess_quest_thirty",
|
||||
name: "Inscribed in the Celestial Annals",
|
||||
reward: { divinity: 500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 50, type: "goddessQuestsCompleted" },
|
||||
description: "Fifty holy quests fulfilled — the sacred codex opens its deepest chapters to you.",
|
||||
icon: "🔮",
|
||||
id: "goddess_quest_fifty",
|
||||
name: "Reader of the Sacred Codex",
|
||||
reward: { divinity: 1200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 65, type: "goddessQuestsCompleted" },
|
||||
description: "Sixty-five divine missions accomplished; the heavenly choir sings your praises.",
|
||||
icon: "🎶",
|
||||
id: "goddess_quest_sixty_five",
|
||||
name: "Sung by the Heavenly Choir",
|
||||
reward: { divinity: 2500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 80, type: "goddessQuestsCompleted" },
|
||||
description: "Eighty sacred tasks complete — you walk the path of the exalted chosen.",
|
||||
icon: "🕊️",
|
||||
id: "goddess_quest_eighty",
|
||||
name: "Path of the Exalted Chosen",
|
||||
reward: { divinity: 3750 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 90, type: "goddessQuestsCompleted" },
|
||||
description: "Ninety divine quests fulfilled — every last sacred duty discharged with glory.",
|
||||
icon: "🌈",
|
||||
id: "goddess_quest_all",
|
||||
name: "Glory of Full Devotion",
|
||||
reward: { divinity: 5000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// DiscipleTotal milestones
|
||||
{
|
||||
condition: { amount: 10, type: "discipleTotal" },
|
||||
description: "Gather ten disciples beneath the Goddess's light and begin your congregation.",
|
||||
icon: "👥",
|
||||
id: "disciples_ten",
|
||||
name: "Seeds of a Congregation",
|
||||
reward: { divinity: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 25, type: "discipleTotal" },
|
||||
description: "Twenty-five faithful souls gathered — the divine community takes shape.",
|
||||
icon: "🏛️",
|
||||
id: "disciples_twenty_five",
|
||||
name: "The Divine Community",
|
||||
reward: { divinity: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 50, type: "discipleTotal" },
|
||||
description: "Fifty disciples united in worship — a true flock of the faithful.",
|
||||
icon: "🕌",
|
||||
id: "disciples_fifty",
|
||||
name: "Flock of the Faithful",
|
||||
reward: { divinity: 150 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 100, type: "discipleTotal" },
|
||||
description: "One hundred devoted souls kneel at the Goddess's altar by your invitation.",
|
||||
icon: "💎",
|
||||
id: "disciples_hundred",
|
||||
name: "Hundred Kneeling Souls",
|
||||
reward: { divinity: 450 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 250, type: "discipleTotal" },
|
||||
description: "Two hundred and fifty disciples — a living temple of mortal devotion.",
|
||||
icon: "🗼",
|
||||
id: "disciples_two_fifty",
|
||||
name: "Living Temple of Devotion",
|
||||
reward: { divinity: 1200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 500, type: "discipleTotal" },
|
||||
description: "Five hundred disciples gathered; the mortal world trembles with collective faith.",
|
||||
icon: "🌍",
|
||||
id: "disciples_five_hundred",
|
||||
name: "Trembling World of Faith",
|
||||
reward: { divinity: 3000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1000, type: "discipleTotal" },
|
||||
description: "A thousand disciples stand as testament to your divine calling.",
|
||||
icon: "⚡",
|
||||
id: "disciples_thousand",
|
||||
name: "Testament of the Divine Calling",
|
||||
reward: { divinity: 6000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 2500, type: "discipleTotal" },
|
||||
description: "Two thousand five hundred disciples — an empire of faith sculpted by your hands.",
|
||||
icon: "🏰",
|
||||
id: "disciples_two_five_hundred",
|
||||
name: "Empire of Faith",
|
||||
reward: { divinity: 10_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// ConsecrationCount milestones
|
||||
{
|
||||
condition: { amount: 1, type: "consecrationCount" },
|
||||
description: "Undergo the sacred rite of Consecration for the first time and be reborn in divine fire.",
|
||||
icon: "🔱",
|
||||
id: "consecration_first",
|
||||
name: "First Rite of Rebirth",
|
||||
reward: { stardust: 1 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 3, type: "consecrationCount" },
|
||||
description: "Three consecrations endured — the cycle of death and rebirth refines your soul.",
|
||||
icon: "♾️",
|
||||
id: "consecration_three",
|
||||
name: "Cycle of the Refined Soul",
|
||||
reward: { stardust: 2 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 6, type: "consecrationCount" },
|
||||
description: "Six times reborn through sacred flame — the Goddess has remade you utterly.",
|
||||
icon: "🌺",
|
||||
id: "consecration_six",
|
||||
name: "Utterly Remade",
|
||||
reward: { stardust: 3 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 10, type: "consecrationCount" },
|
||||
description: "Ten consecrations completed — you have transcended the mortal concept of self.",
|
||||
icon: "🌸",
|
||||
id: "consecration_ten",
|
||||
name: "Transcendence of Self",
|
||||
reward: { stardust: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// GoddessEquipmentOwned milestones
|
||||
{
|
||||
condition: { amount: 10, type: "goddessEquipmentOwned" },
|
||||
description: "Gather ten pieces of divine armament and begin to walk as an instrument of the Goddess.",
|
||||
icon: "🛡️",
|
||||
id: "goddess_equipment_ten",
|
||||
name: "Instrument of the Goddess",
|
||||
reward: { divinity: 500 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 53, type: "goddessEquipmentOwned" },
|
||||
description: "Every piece of sacred armament claimed — the full divine arsenal is yours to wield.",
|
||||
icon: "⚜️",
|
||||
id: "goddess_equipment_all",
|
||||
name: "Bearer of the Full Divine Arsenal",
|
||||
reward: { stardust: 3 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { ConsecrationUpgrade } from "@elysium/types";
|
||||
|
||||
export const defaultConsecrationUpgrades: Array<ConsecrationUpgrade> = [
|
||||
// ── Prayer income ────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The first drop of divinity awakens your disciples' devotion. All prayers/s ×1.25.",
|
||||
divinityCost: 5,
|
||||
id: "divine_prayers_1",
|
||||
multiplier: 1.25,
|
||||
name: "Divinity Blessing I",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "Deeper divine resonance amplifies every prayer across the order. All prayers/s ×1.5.",
|
||||
divinityCost: 15,
|
||||
id: "divine_prayers_2",
|
||||
multiplier: 1.5,
|
||||
name: "Divinity Blessing II",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The full weight of accumulated consecration doubles prayer output entirely. All prayers/s ×2.",
|
||||
divinityCost: 40,
|
||||
id: "divine_prayers_3",
|
||||
multiplier: 2,
|
||||
name: "Divinity Blessing III",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The goddess's own blessing multiplies prayers fivefold through all of creation. All prayers/s ×5.",
|
||||
divinityCost: 120,
|
||||
id: "divine_prayers_4",
|
||||
multiplier: 5,
|
||||
name: "Divinity Blessing IV",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "An unbroken chain of consecrations has tuned your disciples to a perfect divine frequency. All prayers/s ×10.",
|
||||
divinityCost: 350,
|
||||
id: "divine_prayers_5",
|
||||
multiplier: 10,
|
||||
name: "Divinity Blessing V",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The consecration memory floods every prayer with exponential fervour. All prayers/s ×25.",
|
||||
divinityCost: 900,
|
||||
id: "divine_prayers_6",
|
||||
multiplier: 25,
|
||||
name: "Divinity Blessing VI",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The ultimate consecration attunement — prayer income multiplied one hundredfold. All prayers/s ×100.",
|
||||
divinityCost: 2500,
|
||||
id: "divine_prayers_7",
|
||||
multiplier: 100,
|
||||
name: "Divinity Blessing VII",
|
||||
},
|
||||
// ── Disciple output ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "disciples",
|
||||
description: "Consecrated disciples return with deepened devotion. Disciple output ×1.25.",
|
||||
divinityCost: 8,
|
||||
id: "disciple_mastery_1",
|
||||
multiplier: 1.25,
|
||||
name: "Disciple Mastery I",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "The order's collective devotion grows with each consecration. Disciple output ×1.5.",
|
||||
divinityCost: 25,
|
||||
id: "disciple_mastery_2",
|
||||
multiplier: 1.5,
|
||||
name: "Disciple Mastery II",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "Consecration has forged the disciples into instruments of pure divine will. Disciple output ×2.",
|
||||
divinityCost: 80,
|
||||
id: "disciple_mastery_3",
|
||||
multiplier: 2,
|
||||
name: "Disciple Mastery III",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "Every rebirth reshapes the disciples anew, each cycle stronger than the last. Disciple output ×5.",
|
||||
divinityCost: 250,
|
||||
id: "disciple_mastery_4",
|
||||
multiplier: 5,
|
||||
name: "Disciple Mastery IV",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "The disciples have transcended mortal devotion — they are consecration made flesh. Disciple output ×10.",
|
||||
divinityCost: 750,
|
||||
id: "disciple_mastery_5",
|
||||
multiplier: 10,
|
||||
name: "Disciple Mastery V",
|
||||
},
|
||||
// ── Combat power ─────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "combat",
|
||||
description: "Sacred battle experience accumulates across consecrations. Combat power ×1.25.",
|
||||
divinityCost: 10,
|
||||
id: "sacred_combat_c1",
|
||||
multiplier: 1.25,
|
||||
name: "Sacred Warfare I",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Veterans of consecration know how to fight smarter in the divine realm. Combat power ×1.5.",
|
||||
divinityCost: 35,
|
||||
id: "sacred_combat_c2",
|
||||
multiplier: 1.5,
|
||||
name: "Sacred Warfare II",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Consecrated warriors carry the strength of every past life into battle. Combat power ×2.",
|
||||
divinityCost: 100,
|
||||
id: "sacred_combat_c3",
|
||||
multiplier: 2,
|
||||
name: "Sacred Warfare III",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Battle-hardened by countless consecrations, every disciple strikes with threefold fury. Combat power ×3.",
|
||||
divinityCost: 300,
|
||||
id: "sacred_combat_c4",
|
||||
multiplier: 3,
|
||||
name: "Sacred Warfare IV",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "The consecration cycle has transformed the order into an unstoppable divine army. Combat power ×5.",
|
||||
divinityCost: 800,
|
||||
id: "sacred_combat_c5",
|
||||
multiplier: 5,
|
||||
name: "Sacred Warfare V",
|
||||
},
|
||||
// ── Divinity meta ────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "divinity",
|
||||
description: "Your consecration attunement deepens with each rebirth. Earn 25% more divinity from future consecrations.",
|
||||
divinityCost: 50,
|
||||
id: "divine_legacy",
|
||||
multiplier: 1.25,
|
||||
name: "Divine Legacy",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "The echoes of past consecrations amplify the divinity extracted from future ones. Divinity from consecration ×1.5.",
|
||||
divinityCost: 175,
|
||||
id: "consecration_echo",
|
||||
multiplier: 1.5,
|
||||
name: "Consecration Echo",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "Insight beyond the cycle doubles the divinity gained each time you consecrate. Divinity from consecration ×2.",
|
||||
divinityCost: 500,
|
||||
id: "eternal_insight",
|
||||
multiplier: 2,
|
||||
name: "Eternal Insight",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "The loop of consecration feeds back upon itself, tripling the divinity in each harvest. Divinity from consecration ×3.",
|
||||
divinityCost: 1500,
|
||||
id: "divine_recursion",
|
||||
multiplier: 3,
|
||||
name: "Divine Recursion",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "Perfect consecration yields — the goddess rewards mastery with fivefold divinity. Divinity from consecration ×5.",
|
||||
divinityCost: 4000,
|
||||
id: "perfect_consecration",
|
||||
multiplier: 5,
|
||||
name: "Perfect Consecration",
|
||||
},
|
||||
// ── Utility ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "utility",
|
||||
description: "Unlock the Auto-Consecration toggle. When enabled, you will automatically consecrate the moment you reach the consecration threshold.",
|
||||
divinityCost: 20,
|
||||
id: "auto_consecrate",
|
||||
multiplier: 1,
|
||||
name: "Autonomous Consecration",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "The threshold required to consecrate is reduced by 10%, letting you cycle faster.",
|
||||
divinityCost: 60,
|
||||
id: "consecration_efficiency_1",
|
||||
multiplier: 0.9,
|
||||
name: "Consecration Efficiency I",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Deeper understanding of the ritual reduces the consecration threshold by a further 10%.",
|
||||
divinityCost: 200,
|
||||
id: "consecration_efficiency_2",
|
||||
multiplier: 0.9,
|
||||
name: "Consecration Efficiency II",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { CraftingRecipe } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessCraftingRecipes: Array<CraftingRecipe> = [
|
||||
// ── Celestial Garden ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.1,
|
||||
},
|
||||
description: "An amplifier woven from divine petals and crystallised prayer. It does not make prayers louder — it makes them more true.",
|
||||
id: "prayer_amplifier",
|
||||
name: "Prayer Amplifier",
|
||||
requiredMaterials: [
|
||||
{ materialId: "divine_petal", quantity: 3 },
|
||||
{ materialId: "prayer_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.1,
|
||||
},
|
||||
description: "Celestial dust ground into prayer crystals creates a focus that sharpens disciples' divine combat instincts.",
|
||||
id: "celestial_focus",
|
||||
name: "Celestial Focus",
|
||||
requiredMaterials: [
|
||||
{ materialId: "celestial_dust", quantity: 2 },
|
||||
{ materialId: "prayer_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
// ── Crystal Sanctum ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.15,
|
||||
},
|
||||
description: "Holy ink dissolved in sanctum water, then set with shard dust. Disciples who drink it can recite any prayer they have heard exactly once.",
|
||||
id: "oracle_potion",
|
||||
name: "Oracle Potion",
|
||||
requiredMaterials: [
|
||||
{ materialId: "holy_ink", quantity: 2 },
|
||||
{ materialId: "sanctum_shard", quantity: 3 },
|
||||
],
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.2,
|
||||
},
|
||||
description: "A lens ground from an oracle fragment and set in holy ink. Through it, the divine flow of the universe becomes briefly legible.",
|
||||
id: "lens_of_truth",
|
||||
name: "Lens of Truth",
|
||||
requiredMaterials: [
|
||||
{ materialId: "oracle_lens_fragment", quantity: 1 },
|
||||
{ materialId: "holy_ink", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
// ── Astral Cathedral ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.2,
|
||||
},
|
||||
description: "A balm prepared from seraph feathers and choir essence. Applied before battle, it gives disciples the brief sensation of having wings.",
|
||||
id: "seraph_balm",
|
||||
name: "Seraph Balm",
|
||||
requiredMaterials: [
|
||||
{ materialId: "seraph_feather", quantity: 3 },
|
||||
{ materialId: "choir_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.2,
|
||||
},
|
||||
description: "Astral glass dissolved in choir essence creates a vial of concentrated resonance. One drop per disciple per morning.",
|
||||
id: "astral_resonance_vial",
|
||||
name: "Astral Resonance Vial",
|
||||
requiredMaterials: [
|
||||
{ materialId: "astral_glass", quantity: 2 },
|
||||
{ materialId: "choir_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
// ── Empyrean Citadel ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.25,
|
||||
},
|
||||
description: "Empyrean ore refined with divine alloy dust and pressed into a blessing-coin. The citadel uses these to sanctify each new weapon forged.",
|
||||
id: "empyrean_blessing",
|
||||
name: "Empyrean Blessing",
|
||||
requiredMaterials: [
|
||||
{ materialId: "empyrean_ore", quantity: 3 },
|
||||
{ materialId: "divine_alloy", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.25,
|
||||
},
|
||||
description: "A tincture prepared from a champion's medal and divine alloy filings. Dosed by champions before major engagements.",
|
||||
id: "champions_tincture",
|
||||
name: "Champion's Tincture",
|
||||
requiredMaterials: [
|
||||
{ materialId: "celestial_medal", quantity: 1 },
|
||||
{ materialId: "divine_alloy", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
// ── Primordial Springs ───────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.3,
|
||||
},
|
||||
description: "Creation water distilled with primordial essence — the closest thing to bottled genesis. Handle as if the universe is watching.",
|
||||
id: "springs_elixir",
|
||||
name: "Springs Elixir",
|
||||
requiredMaterials: [
|
||||
{ materialId: "creation_water", quantity: 3 },
|
||||
{ materialId: "primordial_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.35,
|
||||
},
|
||||
description: "A genesis crystal dissolved in creation water — a brew of pure origination. Disciples who drink it briefly remember what it felt like to not yet exist.",
|
||||
id: "genesis_brew",
|
||||
name: "Genesis Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "genesis_crystal", quantity: 1 },
|
||||
{ materialId: "creation_water", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
// ── Eternal Firmament ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.35,
|
||||
},
|
||||
description: "A ward inscribed on firmament stone using divine light shards as the writing medium. Disciples who carry it are protected by permanence itself.",
|
||||
id: "firmament_ward",
|
||||
name: "Firmament Ward",
|
||||
requiredMaterials: [
|
||||
{ materialId: "firmament_stone", quantity: 2 },
|
||||
{ materialId: "divine_light_shard", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.4,
|
||||
},
|
||||
description: "An eternity fragment set in divine light — a lantern that never dims because it is powered by time itself. It illuminates things that do not normally have light.",
|
||||
id: "eternity_lantern",
|
||||
name: "Eternity Lantern",
|
||||
requiredMaterials: [
|
||||
{ materialId: "eternity_fragment", quantity: 1 },
|
||||
{ materialId: "divine_light_shard", quantity: 3 },
|
||||
],
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
// ── Sacred Grove ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.4,
|
||||
},
|
||||
description: "Grove resin mixed with luminous leaf extract and set around sacred heartwood creates a talisman that grows warmer in the presence of sincere prayer.",
|
||||
id: "grove_talisman",
|
||||
name: "Grove Talisman",
|
||||
requiredMaterials: [
|
||||
{ materialId: "grove_resin", quantity: 3 },
|
||||
{ materialId: "luminous_leaf", quantity: 2 },
|
||||
{ materialId: "sacred_heartwood", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.4,
|
||||
},
|
||||
description: "Sacred heartwood carved into a pendant and sealed with grove resin. Disciples who wear it feel the grove's centuries of reverence as personal strength.",
|
||||
id: "heartwood_pendant",
|
||||
name: "Heartwood Pendant",
|
||||
requiredMaterials: [
|
||||
{ materialId: "sacred_heartwood", quantity: 2 },
|
||||
{ materialId: "grove_resin", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
// ── Luminous Expanse ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.45,
|
||||
},
|
||||
description: "Captured radiance compressed around a light core — a beacon that broadcasts the goddess's presence to disciples too far away to feel it unaided.",
|
||||
id: "radiance_beacon",
|
||||
name: "Radiance Beacon",
|
||||
requiredMaterials: [
|
||||
{ materialId: "captured_radiance", quantity: 3 },
|
||||
{ materialId: "light_core", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.45,
|
||||
},
|
||||
description: "A pool of radiance encased in glass and suspended on radiance pool solution — a focusing lens that amplifies divine light into something measurable.",
|
||||
id: "luminous_prism",
|
||||
name: "Luminous Prism",
|
||||
requiredMaterials: [
|
||||
{ materialId: "radiance_pool", quantity: 2 },
|
||||
{ materialId: "captured_radiance", quantity: 2 },
|
||||
{ materialId: "light_core", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
// ── Heavenly Forge ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.5,
|
||||
},
|
||||
description: "Forge scale layered over divine slag and inlaid with a forge gem — a gauntlet that channels the forge's sacred heat into every blow struck.",
|
||||
id: "forge_gauntlet",
|
||||
name: "Forge Gauntlet",
|
||||
requiredMaterials: [
|
||||
{ materialId: "forge_scale", quantity: 3 },
|
||||
{ materialId: "divine_slag", quantity: 2 },
|
||||
{ materialId: "forge_gem", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.5,
|
||||
},
|
||||
description: "A forge gem set in refined divine slag — a crucible that converts ambient prayer energy directly into essence. Runs continuously once lit.",
|
||||
id: "divine_crucible",
|
||||
name: "Divine Crucible",
|
||||
requiredMaterials: [
|
||||
{ materialId: "forge_gem", quantity: 2 },
|
||||
{ materialId: "divine_slag", quantity: 3 },
|
||||
],
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
// ── Oracle Sanctum ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.55,
|
||||
},
|
||||
description: "Vision residue suspended in prophecy crystal solution — an elixir that grants disciples fleeting precognitive awareness of lucrative opportunities.",
|
||||
id: "oracle_elixir",
|
||||
name: "Oracle Elixir",
|
||||
requiredMaterials: [
|
||||
{ materialId: "vision_residue", quantity: 3 },
|
||||
{ materialId: "prophecy_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.55,
|
||||
},
|
||||
description: "A fate shard mounted on a prophecy crystal matrix — when a disciple holds it before battle, they briefly see the outcome and can choose their approach accordingly.",
|
||||
id: "fate_compass",
|
||||
name: "Fate Compass",
|
||||
requiredMaterials: [
|
||||
{ materialId: "fate_shard", quantity: 1 },
|
||||
{ materialId: "prophecy_crystal", quantity: 3 },
|
||||
{ materialId: "vision_residue", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
// ── Seraph's Nest ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.6,
|
||||
},
|
||||
description: "Seraph down woven into a mantle and sealed with seraph primary barbs — wearing it is indistinguishable from being held by something enormous and gentle.",
|
||||
id: "seraph_mantle",
|
||||
name: "Seraph Mantle",
|
||||
requiredMaterials: [
|
||||
{ materialId: "seraph_down", quantity: 4 },
|
||||
{ materialId: "seraph_primary", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.6,
|
||||
},
|
||||
description: "An ascended quill carved into a focus rod — it channels divine will with zero resistance, as though the goddess herself were guiding every motion.",
|
||||
id: "ascended_focus",
|
||||
name: "Ascended Focus",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ascended_quill", quantity: 1 },
|
||||
{ materialId: "seraph_primary", quantity: 2 },
|
||||
{ materialId: "seraph_down", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
// ── Divine Archive ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.65,
|
||||
},
|
||||
description: "Celestial vellum stamped with archive seals and pressed into a portable codex — disciples who carry it can access divine knowledge in the field.",
|
||||
id: "field_codex",
|
||||
name: "Field Codex",
|
||||
requiredMaterials: [
|
||||
{ materialId: "celestial_vellum", quantity: 4 },
|
||||
{ materialId: "archive_seal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.65,
|
||||
},
|
||||
description: "A living codex page bound with archive seals — it updates itself with new divine knowledge continuously, and disciples gain essence simply by proximity.",
|
||||
id: "living_tome",
|
||||
name: "Living Tome",
|
||||
requiredMaterials: [
|
||||
{ materialId: "living_codex_page", quantity: 2 },
|
||||
{ materialId: "archive_seal", quantity: 2 },
|
||||
{ materialId: "celestial_vellum", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
// ── Consecrated Depths ───────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.7,
|
||||
},
|
||||
description: "Consecrated stone carved into armour plates and blessed with depth blessing — the armour remembers every prayer spoken over it and adds their weight to the wearer.",
|
||||
id: "depth_armour",
|
||||
name: "Depth Armour",
|
||||
requiredMaterials: [
|
||||
{ materialId: "consecrated_stone", quantity: 3 },
|
||||
{ materialId: "depth_blessing", quantity: 2 },
|
||||
{ materialId: "abyssal_gem", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.7,
|
||||
},
|
||||
description: "An abyssal gem set in depth blessing solution — a vessel that draws essence from the deepest consecrated places and stores it for release when needed.",
|
||||
id: "abyssal_vessel",
|
||||
name: "Abyssal Vessel",
|
||||
requiredMaterials: [
|
||||
{ materialId: "abyssal_gem", quantity: 2 },
|
||||
{ materialId: "depth_blessing", quantity: 3 },
|
||||
],
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
// ── Astral Confluence ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.75,
|
||||
},
|
||||
description: "Confluence shards woven into a prism using astral harmonics — it refracts divine energy across multiple streams simultaneously, multiplying its effective output.",
|
||||
id: "confluence_prism",
|
||||
name: "Confluence Prism",
|
||||
requiredMaterials: [
|
||||
{ materialId: "confluence_shard", quantity: 3 },
|
||||
{ materialId: "astral_harmonic", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.75,
|
||||
},
|
||||
description: "A convergence node bound in astral harmonic resonance — a weapon core that channels the power of seven converging astral streams into a single devastating point.",
|
||||
id: "convergence_core",
|
||||
name: "Convergence Core",
|
||||
requiredMaterials: [
|
||||
{ materialId: "convergence_node", quantity: 1 },
|
||||
{ materialId: "astral_harmonic", quantity: 3 },
|
||||
{ materialId: "confluence_shard", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
// ── Celestial Throne ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.8,
|
||||
},
|
||||
description: "Throne gold leaf pressed over a sovereignty gem — a signet whose mark carries the full weight of divine authority and cannot be questioned.",
|
||||
id: "sovereignty_signet",
|
||||
name: "Sovereignty Signet",
|
||||
requiredMaterials: [
|
||||
{ materialId: "throne_gold_leaf", quantity: 3 },
|
||||
{ materialId: "sovereignty_gem", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.8,
|
||||
},
|
||||
description: "A crown fragment set in sovereignty gem and trimmed with throne gold — the fragment remembers every ruling ever passed from the throne and channels that authority.",
|
||||
id: "crown_relic",
|
||||
name: "Crown Relic",
|
||||
requiredMaterials: [
|
||||
{ materialId: "crown_fragment", quantity: 1 },
|
||||
{ materialId: "sovereignty_gem", quantity: 2 },
|
||||
{ materialId: "throne_gold_leaf", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
// ── Infinite Choir ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.85,
|
||||
},
|
||||
description: "Choir notes compressed with divine resonance into a single instrument — playing it fills nearby disciples with the choir's infinite devotion and multiplies their essence output.",
|
||||
id: "resonance_instrument",
|
||||
name: "Resonance Instrument",
|
||||
requiredMaterials: [
|
||||
{ materialId: "choir_note", quantity: 4 },
|
||||
{ materialId: "divine_resonance", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.85,
|
||||
},
|
||||
description: "The sacred chord crystallised and mounted on a divine resonance matrix — its vibration disrupts the coherence of any force that opposes the goddess's will.",
|
||||
id: "sacred_chord_matrix",
|
||||
name: "Sacred Chord Matrix",
|
||||
requiredMaterials: [
|
||||
{ materialId: "sacred_chord", quantity: 1 },
|
||||
{ materialId: "divine_resonance", quantity: 2 },
|
||||
{ materialId: "choir_note", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
// ── The Veil ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.9,
|
||||
},
|
||||
description: "Veil thread woven with liminal essence — a cloak that allows the wearer to exist partially outside reality, drawing essence from both sides of the divide.",
|
||||
id: "liminal_cloak",
|
||||
name: "Liminal Cloak",
|
||||
requiredMaterials: [
|
||||
{ materialId: "veil_thread", quantity: 3 },
|
||||
{ materialId: "liminal_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.9,
|
||||
},
|
||||
description: "A beyond fragment encased in liminal essence and bound with veil thread — a weapon that strikes from a direction reality does not expect and cannot easily defend against.",
|
||||
id: "veil_piercer",
|
||||
name: "Veil Piercer",
|
||||
requiredMaterials: [
|
||||
{ materialId: "beyond_fragment", quantity: 1 },
|
||||
{ materialId: "liminal_essence", quantity: 3 },
|
||||
{ materialId: "veil_thread", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
// ── Divine Heart ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 2,
|
||||
},
|
||||
description: "Heart pulses suspended in divine love crystal matrix — an amplifier that broadcasts the goddess's love as a measurable economic force. Disciples work harder when they feel it.",
|
||||
id: "heart_amplifier",
|
||||
name: "Heart Amplifier",
|
||||
requiredMaterials: [
|
||||
{ materialId: "heart_pulse", quantity: 4 },
|
||||
{ materialId: "divine_love_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 2,
|
||||
},
|
||||
description: "Heart ichor distilled with divine love crystal into an essence that transforms willingness into unstoppable force. The goddess's love, weaponised. She approves.",
|
||||
id: "divine_heart_essence",
|
||||
name: "Divine Heart Essence",
|
||||
requiredMaterials: [
|
||||
{ materialId: "heart_ichor", quantity: 1 },
|
||||
{ materialId: "divine_love_crystal", quantity: 2 },
|
||||
{ materialId: "heart_pulse", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
import type { GoddessDisciple } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessDisciples: Array<GoddessDisciple> = [
|
||||
{
|
||||
baseCost: 1,
|
||||
class: "oracle",
|
||||
combatPower: 1,
|
||||
count: 0,
|
||||
divinityPerSecond: 0,
|
||||
id: "novice",
|
||||
level: 1,
|
||||
name: "Novice",
|
||||
prayersPerSecond: 0.1,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
baseCost: 8,
|
||||
class: "seraph",
|
||||
combatPower: 3,
|
||||
count: 0,
|
||||
divinityPerSecond: 0,
|
||||
id: "initiate",
|
||||
level: 2,
|
||||
name: "Initiate",
|
||||
prayersPerSecond: 0.5,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 80,
|
||||
class: "invoker",
|
||||
combatPower: 8,
|
||||
count: 0,
|
||||
divinityPerSecond: 0.01,
|
||||
id: "acolyte",
|
||||
level: 3,
|
||||
name: "Acolyte",
|
||||
prayersPerSecond: 1.5,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 500,
|
||||
class: "templar",
|
||||
combatPower: 20,
|
||||
count: 0,
|
||||
divinityPerSecond: 0.02,
|
||||
id: "devotee",
|
||||
level: 4,
|
||||
name: "Devotee",
|
||||
prayersPerSecond: 4,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 3500,
|
||||
class: "herald",
|
||||
combatPower: 50,
|
||||
count: 0,
|
||||
divinityPerSecond: 0.05,
|
||||
id: "adept",
|
||||
level: 5,
|
||||
name: "Adept",
|
||||
prayersPerSecond: 10,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 25_000,
|
||||
class: "oracle",
|
||||
combatPower: 120,
|
||||
count: 0,
|
||||
divinityPerSecond: 0.1,
|
||||
id: "priest",
|
||||
level: 6,
|
||||
name: "Priest",
|
||||
prayersPerSecond: 25,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 175_000,
|
||||
class: "seraph",
|
||||
combatPower: 300,
|
||||
count: 0,
|
||||
divinityPerSecond: 0.2,
|
||||
id: "high_priest",
|
||||
level: 7,
|
||||
name: "High Priest",
|
||||
prayersPerSecond: 75,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1_200_000,
|
||||
class: "invoker",
|
||||
combatPower: 800,
|
||||
count: 0,
|
||||
divinityPerSecond: 0.5,
|
||||
id: "divine_scholar",
|
||||
level: 8,
|
||||
name: "Divine Scholar",
|
||||
prayersPerSecond: 200,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 8_500_000,
|
||||
class: "templar",
|
||||
combatPower: 2000,
|
||||
count: 0,
|
||||
divinityPerSecond: 1,
|
||||
id: "holy_champion",
|
||||
level: 9,
|
||||
name: "Holy Champion",
|
||||
prayersPerSecond: 600,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 60_000_000,
|
||||
class: "warden",
|
||||
combatPower: 6000,
|
||||
count: 0,
|
||||
divinityPerSecond: 3,
|
||||
id: "celestial_adept",
|
||||
level: 10,
|
||||
name: "Celestial Adept",
|
||||
prayersPerSecond: 2000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 285_000_000,
|
||||
class: "oracle",
|
||||
combatPower: 13_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 6,
|
||||
id: "seraphic_master",
|
||||
level: 11,
|
||||
name: "Seraphic Master",
|
||||
prayersPerSecond: 4500,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1_350_000_000,
|
||||
class: "invoker",
|
||||
combatPower: 28_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 11,
|
||||
id: "divine_invoker",
|
||||
level: 12,
|
||||
name: "Divine Invoker",
|
||||
prayersPerSecond: 9500,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 6_400_000_000,
|
||||
class: "templar",
|
||||
combatPower: 60_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 20,
|
||||
id: "astral_templar",
|
||||
level: 13,
|
||||
name: "Astral Templar",
|
||||
prayersPerSecond: 20_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 30_000_000_000,
|
||||
class: "herald",
|
||||
combatPower: 130_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 35,
|
||||
id: "empyrean_herald",
|
||||
level: 14,
|
||||
name: "Empyrean Herald",
|
||||
prayersPerSecond: 40_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 180_000_000_000,
|
||||
class: "seraph",
|
||||
combatPower: 400_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 100,
|
||||
id: "primordial_herald",
|
||||
level: 15,
|
||||
name: "Primordial Herald",
|
||||
prayersPerSecond: 120_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1_000_000_000_000,
|
||||
class: "warden",
|
||||
combatPower: 1_200_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 300,
|
||||
id: "eternal_divine",
|
||||
level: 16,
|
||||
name: "Eternal Divine",
|
||||
prayersPerSecond: 400_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 6_000_000_000_000,
|
||||
class: "oracle",
|
||||
combatPower: 3_600_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 900,
|
||||
id: "cosmic_oracle",
|
||||
level: 17,
|
||||
name: "Cosmic Oracle",
|
||||
prayersPerSecond: 1_200_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 35_000_000_000_000,
|
||||
class: "seraph",
|
||||
combatPower: 10_800_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 2700,
|
||||
id: "radiant_seraph",
|
||||
level: 18,
|
||||
name: "Radiant Seraph",
|
||||
prayersPerSecond: 3_600_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 210_000_000_000_000,
|
||||
class: "invoker",
|
||||
combatPower: 32_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 8000,
|
||||
id: "grand_invoker",
|
||||
level: 19,
|
||||
name: "Grand Invoker",
|
||||
prayersPerSecond: 10_500_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1_300_000_000_000_000,
|
||||
class: "templar",
|
||||
combatPower: 96_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 24_000,
|
||||
id: "sacred_templar",
|
||||
level: 20,
|
||||
name: "Sacred Templar",
|
||||
prayersPerSecond: 32_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 8_000_000_000_000_000,
|
||||
class: "herald",
|
||||
combatPower: 290_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 72_000,
|
||||
id: "celestial_herald",
|
||||
level: 21,
|
||||
name: "Celestial Herald",
|
||||
prayersPerSecond: 96_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 50_000_000_000_000_000,
|
||||
class: "warden",
|
||||
combatPower: 870_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 216_000,
|
||||
id: "divine_warden",
|
||||
level: 22,
|
||||
name: "Divine Warden",
|
||||
prayersPerSecond: 288_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 300_000_000_000_000_000,
|
||||
class: "oracle",
|
||||
combatPower: 2_600_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 650_000,
|
||||
id: "supreme_oracle",
|
||||
level: 23,
|
||||
name: "Supreme Oracle",
|
||||
prayersPerSecond: 864_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1_800_000_000_000_000_000,
|
||||
class: "seraph",
|
||||
combatPower: 7_800_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 1_950_000,
|
||||
id: "arch_seraph",
|
||||
level: 24,
|
||||
name: "Arch-Seraph",
|
||||
prayersPerSecond: 2_600_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 11_000_000_000_000_000_000,
|
||||
class: "invoker",
|
||||
combatPower: 23_000_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 5_850_000,
|
||||
id: "primordial_invoker",
|
||||
level: 25,
|
||||
name: "Primordial Invoker",
|
||||
prayersPerSecond: 7_800_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 70_000_000_000_000_000_000,
|
||||
class: "templar",
|
||||
combatPower: 70_000_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 17_500_000,
|
||||
id: "eternal_templar",
|
||||
level: 26,
|
||||
name: "Eternal Templar",
|
||||
prayersPerSecond: 23_000_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 450_000_000_000_000_000_000,
|
||||
class: "herald",
|
||||
combatPower: 210_000_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 52_000_000,
|
||||
id: "firmament_herald",
|
||||
level: 27,
|
||||
name: "Firmament Herald",
|
||||
prayersPerSecond: 70_000_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 2_700_000_000_000_000_000_000,
|
||||
class: "warden",
|
||||
combatPower: 630_000_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 156_000_000,
|
||||
id: "goddess_warden",
|
||||
level: 28,
|
||||
name: "Goddess Warden",
|
||||
prayersPerSecond: 210_000_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 16_000_000_000_000_000_000_000,
|
||||
class: "oracle",
|
||||
combatPower: 1_900_000_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 468_000_000,
|
||||
id: "transcendent_oracle",
|
||||
level: 29,
|
||||
name: "Transcendent Oracle",
|
||||
prayersPerSecond: 630_000_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 100_000_000_000_000_000_000_000,
|
||||
class: "seraph",
|
||||
combatPower: 5_700_000_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 1_400_000_000,
|
||||
id: "exalted_seraph",
|
||||
level: 30,
|
||||
name: "Exalted Seraph",
|
||||
prayersPerSecond: 1_900_000_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 650_000_000_000_000_000_000_000,
|
||||
class: "invoker",
|
||||
combatPower: 17_000_000_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 4_200_000_000,
|
||||
id: "infinite_invoker",
|
||||
level: 31,
|
||||
name: "Infinite Invoker",
|
||||
prayersPerSecond: 5_700_000_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 4_000_000_000_000_000_000_000_000,
|
||||
class: "templar",
|
||||
combatPower: 51_000_000_000_000,
|
||||
count: 0,
|
||||
divinityPerSecond: 12_600_000_000,
|
||||
id: "divine_heart_disciple",
|
||||
level: 32,
|
||||
name: "Divine Heart",
|
||||
prayersPerSecond: 17_000_000_000_000,
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { EnlightenmentUpgrade } from "@elysium/types";
|
||||
|
||||
export const defaultEnlightenmentUpgrades: Array<EnlightenmentUpgrade> = [
|
||||
// ── Prayer income ────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 2,
|
||||
description: "The memory of past consecrations echoes through your order, amplifying prayer income by 25%.",
|
||||
id: "stardust_prayers_1",
|
||||
multiplier: 1.25,
|
||||
name: "Celestial Echo I",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 4,
|
||||
description: "Transcendent experience resonates through every disciple in the order, boosting prayers by 50%.",
|
||||
id: "stardust_prayers_2",
|
||||
multiplier: 1.5,
|
||||
name: "Celestial Echo II",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 8,
|
||||
description: "The harmony of enlightened cycles surges through your order, doubling all prayer income.",
|
||||
id: "stardust_prayers_3",
|
||||
multiplier: 2,
|
||||
name: "Celestial Echo III",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 16,
|
||||
description: "Divine overflow from enlightenment floods the order, tripling all prayer income.",
|
||||
id: "stardust_prayers_4",
|
||||
multiplier: 3,
|
||||
name: "Celestial Echo IV",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 32,
|
||||
description: "The infinite chorus of every consecration you have completed multiplies prayer income fivefold.",
|
||||
id: "stardust_prayers_5",
|
||||
multiplier: 5,
|
||||
name: "Celestial Echo V",
|
||||
},
|
||||
// ── Combat ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "combat",
|
||||
cost: 2,
|
||||
description: "Memories of every divine battle harden your disciples, increasing combat power by 25%.",
|
||||
id: "stardust_combat_1",
|
||||
multiplier: 1.25,
|
||||
name: "Battle Memory I",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 6,
|
||||
description: "Veterans of enlightenment know how to fight with transcendent precision, boosting combat power by 50%.",
|
||||
id: "stardust_combat_2",
|
||||
multiplier: 1.5,
|
||||
name: "Battle Memory II",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 12,
|
||||
description: "Your disciples carry the strength of every enlightened cycle, doubling all combat power.",
|
||||
id: "stardust_combat_3",
|
||||
multiplier: 2,
|
||||
name: "Battle Memory III",
|
||||
},
|
||||
// ── Consecration threshold ───────────────────────────────────────────────
|
||||
{
|
||||
category: "consecration_threshold",
|
||||
cost: 3,
|
||||
description: "Enlightened experience shortens the road to consecration — threshold reduced by 10%.",
|
||||
id: "stardust_consecration_threshold_1",
|
||||
multiplier: 0.9,
|
||||
name: "Accelerated Devotion I",
|
||||
},
|
||||
{
|
||||
category: "consecration_threshold",
|
||||
cost: 9,
|
||||
description: "Mastery of the enlightenment cycle trims the consecration requirement by a further 15%.",
|
||||
id: "stardust_consecration_threshold_2",
|
||||
multiplier: 0.85,
|
||||
name: "Accelerated Devotion II",
|
||||
},
|
||||
{
|
||||
category: "consecration_threshold",
|
||||
cost: 25,
|
||||
description: "The path to consecration is now second nature — threshold reduced by 20% once more.",
|
||||
id: "stardust_consecration_threshold_3",
|
||||
multiplier: 0.8,
|
||||
name: "Accelerated Devotion III",
|
||||
},
|
||||
// ── Consecration divinity ────────────────────────────────────────────────
|
||||
{
|
||||
category: "consecration_divinity",
|
||||
cost: 5,
|
||||
description: "Each cycle of enlightenment deepens the consecration harvest, increasing divinity yield by 25%.",
|
||||
id: "stardust_consecration_divinity_1",
|
||||
multiplier: 1.25,
|
||||
name: "Luminous Harvest I",
|
||||
},
|
||||
{
|
||||
category: "consecration_divinity",
|
||||
cost: 20,
|
||||
description: "The light of enlightenment pours into every consecration, boosting divinity yield by 50%.",
|
||||
id: "stardust_consecration_divinity_2",
|
||||
multiplier: 1.5,
|
||||
name: "Luminous Harvest II",
|
||||
},
|
||||
{
|
||||
category: "consecration_divinity",
|
||||
cost: 60,
|
||||
description: "Perfection of the cycle doubles the divinity drawn from every act of consecration.",
|
||||
id: "stardust_consecration_divinity_3",
|
||||
multiplier: 2,
|
||||
name: "Luminous Harvest III",
|
||||
},
|
||||
// ── Stardust meta ────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "stardust_meta",
|
||||
cost: 15,
|
||||
description: "Your enlightenment resonates deeper, amplifying future stardust yields by 25%.",
|
||||
id: "stardust_meta_1",
|
||||
multiplier: 1.25,
|
||||
name: "Resonant Enlightenment",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { GoddessEquipment } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessEquipment: Array<GoddessEquipment> = [
|
||||
// ── Relics — Common ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { prayersMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 300, stardust: 0 },
|
||||
description: "A weathered tome filled with the first prayers ever offered. The ink has faded, but the faith remains.",
|
||||
equipped: false,
|
||||
id: "divine_tome",
|
||||
name: "Divine Tome",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { prayersMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 350, stardust: 0 },
|
||||
description: "A scroll of thin parchment bearing a single whispered blessing. Small, but sincere.",
|
||||
equipped: false,
|
||||
id: "prayer_scroll",
|
||||
name: "Prayer Scroll",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 400, stardust: 0 },
|
||||
description: "A slender wand carved from a branch blessed by a passing spirit. Humble in form, genuine in purpose.",
|
||||
equipped: false,
|
||||
id: "blessing_wand",
|
||||
name: "Blessing Wand",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "relic",
|
||||
},
|
||||
// ── Relics — Rare ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 1.2, prayersMultiplier: 1.1 },
|
||||
description: "A staff carved from petrified sanctuary wood. Its grain holds echoes of a thousand blessings.",
|
||||
equipped: false,
|
||||
id: "sacred_staff",
|
||||
name: "Sacred Staff",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { prayersMultiplier: 1.3 },
|
||||
description: "A crystalline lens ground from frozen oracle tears. Those who look through it see truths they cannot unhear.",
|
||||
equipped: false,
|
||||
id: "oracle_lens",
|
||||
name: "Oracle Lens",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.1, prayersMultiplier: 1.2 },
|
||||
description: "A quill shed from the wing of a celestial herald. Whatever it writes becomes spoken prophecy.",
|
||||
equipped: false,
|
||||
id: "celestial_quill",
|
||||
name: "Celestial Quill",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { prayersMultiplier: 1.25 },
|
||||
description: "A smooth orb of blessed amber that hums with the residual faith of an entire sanctum's congregation.",
|
||||
equipped: false,
|
||||
id: "sanctum_focus",
|
||||
name: "Sanctum Focus",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.2, prayersMultiplier: 1.1 },
|
||||
description: "A sceptre of star-cast silver that channels divine will through the constellations it was forged beneath.",
|
||||
equipped: false,
|
||||
id: "astral_sceptre",
|
||||
name: "Astral Sceptre",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.35 },
|
||||
description: "A rod drawn from the highest reaches of the empyrean vault. Light bends around it as though in reverence.",
|
||||
equipped: false,
|
||||
id: "empyrean_rod",
|
||||
name: "Empyrean Rod",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "relic",
|
||||
},
|
||||
// ── Relics — Epic ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 1.5, prayersMultiplier: 1.25 },
|
||||
description: "A blade borne by the highest order of seraphim. Its edge is said to cut through even divine illusion.",
|
||||
equipped: false,
|
||||
id: "seraph_blade",
|
||||
name: "Seraph Blade",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.4, prayersMultiplier: 1.4 },
|
||||
description: "A sceptre inscribed with the Goddess's own name in a script no mortal tongue can speak aloud.",
|
||||
equipped: false,
|
||||
id: "divine_sceptre",
|
||||
name: "Divine Sceptre",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.6 },
|
||||
description: "A lance of heavenly ore that strikes with the force of a falling star. No shield has ever stopped it twice.",
|
||||
equipped: false,
|
||||
id: "heavenly_lance",
|
||||
name: "Heavenly Lance",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.5 },
|
||||
description: "A hammer that once shaped the divine armaments of the celestial forge. Its weight carries the memory of creation.",
|
||||
equipped: false,
|
||||
id: "forge_hammer",
|
||||
name: "Forge Hammer",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { prayersMultiplier: 1.5 },
|
||||
description: "A staff of oracle-bone and wrapped starlight. Prayers spoken through it travel to the Goddess without delay.",
|
||||
equipped: false,
|
||||
id: "oracle_staff",
|
||||
name: "Oracle Staff",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.45, prayersMultiplier: 1.3 },
|
||||
description: "A twin blade to the Seraph Blade, wielded in the off-hand of a champion who ascended beyond mortality.",
|
||||
equipped: false,
|
||||
id: "seraph_sword",
|
||||
name: "Seraph Sword",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "relic",
|
||||
},
|
||||
// ── Relics — Legendary ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 1.75, prayersMultiplier: 1.75 },
|
||||
description: "A rod drawn from the highest layer of the firmament, where creation and void press against each other eternally.",
|
||||
equipped: false,
|
||||
id: "firmament_rod",
|
||||
name: "Firmament Rod",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 2 },
|
||||
description: "The personal weapon of the Goddess herself, wielded once at the dawn of the world and never since. Its tip still smells of stardust.",
|
||||
equipped: false,
|
||||
id: "goddess_spear",
|
||||
name: "Goddess's Spear",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "relic",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.8, prayersMultiplier: 2 },
|
||||
description: "A relic formed from a single heartbeat of the Goddess, crystallised at the moment she first felt devotion returned.",
|
||||
equipped: false,
|
||||
id: "divine_heart_relic",
|
||||
name: "Divine Heart Relic",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "relic",
|
||||
},
|
||||
// ── Vestments — Common ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 300, stardust: 0 },
|
||||
description: "Simple robes given to those who have just found their faith. The stitching is uneven, but the intention is pure.",
|
||||
equipped: false,
|
||||
id: "novice_vestments",
|
||||
name: "Novice Vestments",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { prayersMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 350, stardust: 0 },
|
||||
description: "The standard garb of an initiate entering the divine order. Clean, modest, and faintly perfumed with incense.",
|
||||
equipped: false,
|
||||
id: "initiate_robes",
|
||||
name: "Initiate Robes",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 450, stardust: 0 },
|
||||
description: "Practical garments worn by acolytes who serve the temples. The fabric repels both dust and doubt.",
|
||||
equipped: false,
|
||||
id: "acolyte_garb",
|
||||
name: "Acolyte Garb",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "vestment",
|
||||
},
|
||||
// ── Vestments — Rare ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 1.1, prayersMultiplier: 1.2 },
|
||||
description: "Robes woven through hours of silent prayer. Each thread carries a whispered blessing from the hands that made it.",
|
||||
equipped: false,
|
||||
id: "prayer_robes",
|
||||
name: "Prayer Robes",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.2, prayersMultiplier: 1.15 },
|
||||
description: "The ceremonial dress of a sanctum's most decorated servant. Heavy with ornament and heavier with meaning.",
|
||||
equipped: false,
|
||||
id: "sanctum_regalia",
|
||||
name: "Sanctum Regalia",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { prayersMultiplier: 1.3 },
|
||||
description: "A flowing garment sewn from fibres of celestial cloud. It never wrinkles and always catches the light perfectly.",
|
||||
equipped: false,
|
||||
id: "celestial_wrap",
|
||||
name: "Celestial Wrap",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.25, prayersMultiplier: 1.1 },
|
||||
description: "A cloak dyed in the hue of the void between stars. It absorbs damage and whispers warnings to its wearer.",
|
||||
equipped: false,
|
||||
id: "astral_cloak",
|
||||
name: "Astral Cloak",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.35 },
|
||||
description: "A cowl spun from the highest threads of empyrean silk. It protects the mind as much as it protects the head.",
|
||||
equipped: false,
|
||||
id: "empyrean_cowl",
|
||||
name: "Empyrean Cowl",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.2, prayersMultiplier: 1.2 },
|
||||
description: "A mantle woven from sacred grove leaves that never wither. The forest's blessing persists in every fibre.",
|
||||
equipped: false,
|
||||
id: "grove_mantle",
|
||||
name: "Grove Mantle",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "vestment",
|
||||
},
|
||||
// ── Vestments — Epic ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 1.5, prayersMultiplier: 1.3 },
|
||||
description: "A mantle torn from an astral projection and reforged into armour. It exists in two planes simultaneously.",
|
||||
equipped: false,
|
||||
id: "astral_mantle",
|
||||
name: "Astral Mantle",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.6 },
|
||||
description: "Plate armour hammered from condensed empyrean light. It weighs nothing and deflects everything.",
|
||||
equipped: false,
|
||||
id: "empyrean_armour",
|
||||
name: "Empyrean Armour",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.45, prayersMultiplier: 1.4 },
|
||||
description: "Armour that radiates a soft divine glow. Enemies flinch from its light before the blow even lands.",
|
||||
equipped: false,
|
||||
id: "luminous_plate",
|
||||
name: "Luminous Plate",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.55 },
|
||||
description: "Vestments quenched in the divine forge, each layer fused by celestial fire until no ordinary blade can part them.",
|
||||
equipped: false,
|
||||
id: "forge_vestments",
|
||||
name: "Forge Vestments",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.35, prayersMultiplier: 1.5 },
|
||||
description: "Robes worn by the oracle who first heard the Goddess speak. The fabric remembers every prophecy ever uttered within it.",
|
||||
equipped: false,
|
||||
id: "oracle_robes",
|
||||
name: "Oracle Robes",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.4, prayersMultiplier: 1.45 },
|
||||
description: "Armour that marks its wearer as an instrument of divine will. Enemies see the Goddess reflected in its surface.",
|
||||
equipped: false,
|
||||
id: "divine_regalia",
|
||||
name: "Divine Regalia",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "vestment",
|
||||
},
|
||||
// ── Vestments — Legendary ─────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 1.75, prayersMultiplier: 1.75 },
|
||||
description: "Vestments that persist beyond the death of the wearer. The cloth refuses to decay. So, eventually, does the soul.",
|
||||
equipped: false,
|
||||
id: "eternal_vestments",
|
||||
name: "Eternal Vestments",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 2 },
|
||||
description: "Armour drawn from the firmament itself, where the boundary between existence and the void is at its thinnest.",
|
||||
equipped: false,
|
||||
id: "firmament_armour",
|
||||
name: "Firmament Armour",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "vestment",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.8, prayersMultiplier: 2 },
|
||||
description: "The Goddess's own ceremonial robes, left behind as a covenant. They still carry the warmth of divinity.",
|
||||
equipped: false,
|
||||
id: "goddess_raiment",
|
||||
name: "Goddess's Raiment",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "vestment",
|
||||
},
|
||||
// ── Sigils — Common ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 300, stardust: 0 },
|
||||
description: "A small clay token stamped with a sunburst. Farmers press it into new soil to invite the Goddess's blessing.",
|
||||
equipped: false,
|
||||
id: "faith_token",
|
||||
name: "Faith Token",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { prayersMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 350, stardust: 0 },
|
||||
description: "A single bead worn smooth by generations of faithful fingers. Every prayer whispered over it still clings to the surface.",
|
||||
equipped: false,
|
||||
id: "prayer_bead",
|
||||
name: "Prayer Bead",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.1 },
|
||||
cost: { divinity: 0, prayers: 400, stardust: 0 },
|
||||
description: "A charm carved from sanctified bone. It draws small kindnesses towards its wearer, like gravity for good fortune.",
|
||||
equipped: false,
|
||||
id: "blessing_charm",
|
||||
name: "Blessing Charm",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
type: "sigil",
|
||||
},
|
||||
// ── Sigils — Rare ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.2, prayersMultiplier: 1.1 },
|
||||
description: "A seal pressed in divine wax that has never cooled. The blessing it carries is renewed with every sunrise.",
|
||||
equipped: false,
|
||||
id: "blessing_seal",
|
||||
name: "Blessing Seal",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.3 },
|
||||
description: "A sigil shaped into an open eye. Those who bear it find their prayers answered with unusual precision.",
|
||||
equipped: false,
|
||||
id: "oracle_sigil",
|
||||
name: "Oracle Sigil",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.1, prayersMultiplier: 1.2 },
|
||||
description: "A token bearing the mark of the highest seraphim. It grants the holder passage through divine barriers.",
|
||||
equipped: false,
|
||||
id: "seraph_token",
|
||||
name: "Seraph Token",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.25 },
|
||||
description: "A pendant woven from living vines that never wither. It carries the grove's unbroken memory of the divine.",
|
||||
equipped: false,
|
||||
id: "grove_pendant",
|
||||
name: "Grove Pendant",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.2, prayersMultiplier: 1.2 },
|
||||
description: "A mark drawn in light rather than ink. It pulses faintly with the rhythm of divine order.",
|
||||
equipped: false,
|
||||
id: "luminous_mark",
|
||||
name: "Luminous Mark",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
type: "sigil",
|
||||
},
|
||||
// ── Sigils — Epic ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.5, prayersMultiplier: 1.3 },
|
||||
description: "A mark burned into existence by a seraph's own finger. It does not fade because it is not merely physical.",
|
||||
equipped: false,
|
||||
id: "seraph_mark",
|
||||
name: "Seraph Mark",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.5, prayersMultiplier: 1.4 },
|
||||
description: "An emblem bearing the full weight of divine authority. Those who carry it act as the Goddess's declared instrument.",
|
||||
equipped: false,
|
||||
id: "divine_emblem",
|
||||
name: "Divine Emblem",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.6 },
|
||||
description: "A seal drawn from the highest empyrean archive. It radiates a hum that only the faithful can hear.",
|
||||
equipped: false,
|
||||
id: "empyrean_seal",
|
||||
name: "Empyrean Seal",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.4, prayersMultiplier: 1.4 },
|
||||
description: "A brand seared in the divine forge. It marks the bearer as something that has passed through fire and remained.",
|
||||
equipped: false,
|
||||
id: "forge_brand",
|
||||
name: "Forge Brand",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.45, prayersMultiplier: 1.35 },
|
||||
description: "A sigil drawn from the archive of every prayer ever recorded. It remembers everything that has ever been asked.",
|
||||
equipped: false,
|
||||
id: "archive_sigil",
|
||||
name: "Archive Sigil",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 1.55, prayersMultiplier: 1.25 },
|
||||
description: "A celestial mark that resonates with the movement of heavenly bodies. It grows stronger under open sky.",
|
||||
equipped: false,
|
||||
id: "celestial_mark",
|
||||
name: "Celestial Mark",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
type: "sigil",
|
||||
},
|
||||
// ── Sigils — Legendary ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { divinityMultiplier: 2, prayersMultiplier: 1.75 },
|
||||
description: "A sigil that has existed since before the first prayer was spoken. It does not grant eternity — it is eternity.",
|
||||
equipped: false,
|
||||
id: "eternity_sigil",
|
||||
name: "Eternity Sigil",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 2.5 },
|
||||
description: "A seal drawn from the innermost layer of the firmament, where time folds back on itself and prayers echo forever.",
|
||||
equipped: false,
|
||||
id: "firmament_seal",
|
||||
name: "Firmament Seal",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "sigil",
|
||||
},
|
||||
{
|
||||
bonus: { divinityMultiplier: 2, prayersMultiplier: 2 },
|
||||
description: "A sigil shaped like a heartbeat, crystallised at the precise moment the Goddess chose to love the world back.",
|
||||
equipped: false,
|
||||
id: "divine_heart_sigil",
|
||||
name: "Divine Heart Sigil",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
type: "sigil",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { GoddessEquipmentSet } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessEquipmentSets: Array<GoddessEquipmentSet> = [
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { prayersMultiplier: 1.15 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { divinityMultiplier: 1.1 },
|
||||
},
|
||||
description: "The simplest sacred things gathered in one place — a tome, a robe, a token. Together they are more than their stitching.",
|
||||
id: "gardens_blessing",
|
||||
name: "Garden's Blessing",
|
||||
pieces: [ "divine_tome", "novice_vestments", "faith_token", "prayer_scroll", "initiate_robes" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { prayersMultiplier: 1.25 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { combatMultiplier: 1.2 },
|
||||
},
|
||||
description: "The instruments of the Crystal Sanctum's greatest scholars. Knowledge and combat are not opposites — they are complements.",
|
||||
id: "sanctum_scholar",
|
||||
name: "Sanctum Scholar",
|
||||
pieces: [ "oracle_lens", "sanctum_regalia", "oracle_sigil", "celestial_quill", "sanctum_focus" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { combatMultiplier: 1.3 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { prayersMultiplier: 1.2 },
|
||||
},
|
||||
description: "The arms and marks of the seraphic order. Those who carry this set fight not for glory but because the Goddess asked them to.",
|
||||
id: "seraphic_arsenal",
|
||||
name: "Seraphic Arsenal",
|
||||
pieces: [ "seraph_blade", "prayer_robes", "seraph_mark", "seraph_token", "seraph_sword" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { combatMultiplier: 1.35 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { divinityMultiplier: 1.25 },
|
||||
},
|
||||
description: "The armaments of those who hold the citadel against the void. Their faith is a wall and their weapons are its gate.",
|
||||
id: "citadel_defender",
|
||||
name: "Citadel Defender",
|
||||
pieces: [ "sacred_staff", "empyrean_cowl", "empyrean_seal", "astral_sceptre", "empyrean_rod" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { prayersMultiplier: 1.3 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { divinityMultiplier: 1.3 },
|
||||
},
|
||||
description: "Relics of the astral void and the blessed seals born from it. To invoke the divine, one must first become a vessel worthy of it.",
|
||||
id: "divine_invoker",
|
||||
name: "Divine Invoker",
|
||||
pieces: [ "astral_mantle", "blessing_seal", "astral_cloak", "empyrean_armour", "luminous_mark" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { combatMultiplier: 1.4 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { prayersMultiplier: 1.35 },
|
||||
},
|
||||
description: "The master-crafter's complete regalia — hammer, vestments, and brand. Every piece was forged in the same divine fire that shaped the celestial host.",
|
||||
id: "forge_masters_regalia",
|
||||
name: "Forge-Master's Regalia",
|
||||
pieces: [ "forge_hammer", "forge_vestments", "forge_brand", "oracle_staff", "oracle_robes" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { combatMultiplier: 1.5 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { prayersMultiplier: 1.5 },
|
||||
},
|
||||
description: "Armaments drawn from the firmament's edge, where the sky becomes something else entirely. Those chosen to wear them rarely choose to return.",
|
||||
id: "firmaments_chosen",
|
||||
name: "Firmament's Chosen",
|
||||
pieces: [ "firmament_rod", "firmament_armour", "firmament_seal", "empyrean_armour", "luminous_plate" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { divinityMultiplier: 1.4 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { prayersMultiplier: 1.4 },
|
||||
},
|
||||
description: "The oracle's complete truth — sceptre, raiment, emblem, and the archives of every word ever spoken in the Goddess's name.",
|
||||
id: "oracles_truth",
|
||||
name: "Oracle's Truth",
|
||||
pieces: [ "divine_sceptre", "divine_regalia", "divine_emblem", "archive_sigil", "celestial_mark" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { prayersMultiplier: 2 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { divinityMultiplier: 2 },
|
||||
},
|
||||
description: "The Goddess's own heart, spear, raiment, and sigil, gathered together at last. To wear this set is to carry what she left behind — and to feel the weight of being chosen.",
|
||||
id: "heart_of_the_goddess",
|
||||
name: "Heart of the Goddess",
|
||||
pieces: [ "divine_heart_relic", "goddess_raiment", "divine_heart_sigil", "goddess_spear", "eternal_vestments" ],
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { Material } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessMaterials: Array<Material> = [
|
||||
// ── Celestial Garden ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Petals from flowers that have never known anything but divine light. They crumble if touched by anything unworthy.",
|
||||
id: "divine_petal",
|
||||
name: "Divine Petal",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "Prayer energy that has crystallised over centuries of devotion. Each one holds a fragment of someone's deepest hope.",
|
||||
id: "prayer_crystal",
|
||||
name: "Prayer Crystal",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "Dust that falls from the celestial dome above — each mote a fragment of a star that finished its purpose and dissolved.",
|
||||
id: "celestial_dust",
|
||||
name: "Celestial Dust",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
// ── Crystal Sanctum ──────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Shards broken from the sanctum walls during divine resonance events. They hum faintly with stored knowledge.",
|
||||
id: "sanctum_shard",
|
||||
name: "Sanctum Shard",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Ink distilled from divine light and used to inscribe the sanctum's most sacred texts. It cannot write falsehoods.",
|
||||
id: "holy_ink",
|
||||
name: "Holy Ink",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "A fragment of an oracle's primary lens, shattered during a vision of catastrophic clarity. The vision was worth it.",
|
||||
id: "oracle_lens_fragment",
|
||||
name: "Oracle Lens Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
// ── Astral Cathedral ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A feather shed by a seraph during their first ascension. They shed exactly one. This is the rarest thing most people will ever hold.",
|
||||
id: "seraph_feather",
|
||||
name: "Seraph Feather",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "The concentrated resonance of the celestial choir — bottled by scholars who noticed that it had physical properties.",
|
||||
id: "choir_essence",
|
||||
name: "Choir Essence",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "Material formed where the cathedral's astral structure meets the void. Transparent, harder than diamond, warmer than sunlight.",
|
||||
id: "astral_glass",
|
||||
name: "Astral Glass",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
// ── Empyrean Citadel ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Ore mined from the citadel's deepest foundations — dense with divine potential but raw and unrefined.",
|
||||
id: "empyrean_ore",
|
||||
name: "Empyrean Ore",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "Empyrean ore refined in the citadel's divine furnaces. The process requires both technical mastery and genuine faith.",
|
||||
id: "divine_alloy",
|
||||
name: "Divine Alloy",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "A medal awarded only to champions of the citadel's trials. Fewer than a hundred exist. Each one has a name engraved on the back.",
|
||||
id: "celestial_medal",
|
||||
name: "Celestial Medal",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
// ── Primordial Springs ───────────────────────────────────────────────────
|
||||
{
|
||||
description: "Water drawn directly from the springs of creation. It tastes of nothing. It heals everything. Handle carefully.",
|
||||
id: "creation_water",
|
||||
name: "Creation Water",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "The raw essence of creation — the stuff from which everything is made before it decides what to become.",
|
||||
id: "primordial_essence",
|
||||
name: "Primordial Essence",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "A crystal formed spontaneously when creation energy reaches critical density. Each one is unique and has never existed before.",
|
||||
id: "genesis_crystal",
|
||||
name: "Genesis Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
// ── Eternal Firmament ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Stone from the eternal firmament itself — impossibly dense, impossibly enduring. It does not weather. It does not age.",
|
||||
id: "firmament_stone",
|
||||
name: "Firmament Stone",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "A shard of divine light that has solidified — the kind of light that exists before it is observed, before it is named.",
|
||||
id: "divine_light_shard",
|
||||
name: "Divine Light Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "A fragment broken from eternity itself during a moment of divine turbulence. It is still vibrating. It will never stop.",
|
||||
id: "eternity_fragment",
|
||||
name: "Eternity Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
// ── Sacred Grove ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Resin weeping from the sacred grove's eldest trees — each drop takes decades to form and carries the memory of every prayer offered beneath its branches.",
|
||||
id: "grove_resin",
|
||||
name: "Grove Resin",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "A leaf that has absorbed so much divine light it has become semi-translucent, like stained glass grown naturally from a living tree.",
|
||||
id: "luminous_leaf",
|
||||
name: "Luminous Leaf",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "Bark shed from the grove's most ancient tree — said to be the first thing the goddess ever touched. No axe can cut it. No fire can burn it.",
|
||||
id: "sacred_heartwood",
|
||||
name: "Sacred Heartwood",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
// ── Luminous Expanse ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The ambient radiance of the luminous expanse, captured in small crystalline vessels before it dissipates. Warm to the touch always.",
|
||||
id: "captured_radiance",
|
||||
name: "Captured Radiance",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "Where radiance pools deep enough, it begins to behave like water. This is a vial of that impossible substance.",
|
||||
id: "radiance_pool",
|
||||
name: "Radiance Pool",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "A perfect sphere of compressed luminous energy — formed only at the expanse's absolute centre, where the light meets itself coming back.",
|
||||
id: "light_core",
|
||||
name: "Light Core",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
// ── Heavenly Forge ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Scale from a celestial creature shed near the forge — tempered by proximity to divine fire into something harder than most metals.",
|
||||
id: "forge_scale",
|
||||
name: "Forge Scale",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "The slag produced when divine alloy is refined to its purest form. Useless for most things. Priceless for the right ones.",
|
||||
id: "divine_slag",
|
||||
name: "Divine Slag",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "A gem formed in the forge's hottest chamber — absorbs heat and releases it as blessing energy over years. Handle with tongs.",
|
||||
id: "forge_gem",
|
||||
name: "Forge Gem",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
// ── Oracle Sanctum ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The residue left behind when an oracle's vision ends — collected from the floor of the viewing chamber before it evaporates.",
|
||||
id: "vision_residue",
|
||||
name: "Vision Residue",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Crystals that form in the minds of oracles during particularly intense visions and are expelled as small shards afterward.",
|
||||
id: "prophecy_crystal",
|
||||
name: "Prophecy Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "A shard of pure foresight — carved from the moment between a prophecy being spoken and it being understood. Extremely dangerous to hold for long.",
|
||||
id: "fate_shard",
|
||||
name: "Fate Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
// ── Seraph's Nest ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Down from the innermost layer of a seraph's plumage — softer than anything natural, warm as sunlight, impossible to soil.",
|
||||
id: "seraph_down",
|
||||
name: "Seraph Down",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "A primary feather from a seraph's wing — longer than a person is tall, capable of carrying aloft far more than its size suggests.",
|
||||
id: "seraph_primary",
|
||||
name: "Seraph Primary",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "The hollow quill of a fully ascended seraph — said to channel divine will as faithfully as any sacred instrument ever made.",
|
||||
id: "ascended_quill",
|
||||
name: "Ascended Quill",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
// ── Divine Archive ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Vellum produced from materials that do not exist in the mortal world — can hold text that cannot be written on ordinary parchment.",
|
||||
id: "celestial_vellum",
|
||||
name: "Celestial Vellum",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "A stamp used to seal the archive's most important documents — its mark cannot be forged and cannot be removed.",
|
||||
id: "archive_seal",
|
||||
name: "Archive Seal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "A codex page that has absorbed so much divine knowledge it has become semi-sentient. It resists being filed incorrectly.",
|
||||
id: "living_codex_page",
|
||||
name: "Living Codex Page",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
// ── Consecrated Depths ───────────────────────────────────────────────────
|
||||
{
|
||||
description: "Stone from the deepest consecrated chambers — blessed so thoroughly by generations of ritual that it radiates faint warmth in complete darkness.",
|
||||
id: "consecrated_stone",
|
||||
name: "Consecrated Stone",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "Water from the depths' sacred underground springs — it has been blessed so many times that blessing it again produces light.",
|
||||
id: "depth_blessing",
|
||||
name: "Depth Blessing",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "A gem found only at the absolute lowest point of the consecrated depths — formed from minerals and divine energy in equal parts.",
|
||||
id: "abyssal_gem",
|
||||
name: "Abyssal Gem",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
// ── Astral Confluence ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A shard of ley-material harvested where two astral streams cross — vibrates at two frequencies simultaneously and cannot decide which to settle on.",
|
||||
id: "confluence_shard",
|
||||
name: "Confluence Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "The harmonic tone produced when multiple astral streams converge — bottled by scholars with sensitive enough ears to find it before it propagated away.",
|
||||
id: "astral_harmonic",
|
||||
name: "Astral Harmonic",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "A knot of astral energy so dense it has become material — formed only at confluence points of seven or more streams. Profoundly stable.",
|
||||
id: "convergence_node",
|
||||
name: "Convergence Node",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
// ── Celestial Throne ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Gold leaf beaten so thin it is translucent — used to gild the throne's ceremonial surfaces and shed during every royal audience.",
|
||||
id: "throne_gold_leaf",
|
||||
name: "Throne Gold Leaf",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "A gem that fell from the throne's armrest during a momentous divine decision. It carries the weight of that decision.",
|
||||
id: "sovereignty_gem",
|
||||
name: "Sovereignty Gem",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "A fragment of the divine crown — shed when the goddess channels her most absolute authority. Still crackles with that authority.",
|
||||
id: "crown_fragment",
|
||||
name: "Crown Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
// ── Infinite Choir ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A note from the infinite choir crystallised mid-air — visible proof that sound, given enough devotion, can become matter.",
|
||||
id: "choir_note",
|
||||
name: "Choir Note",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The resonant frequency of the infinite choir, captured in a tuning fork made of condensed praise. Struck, it harmonises everything nearby.",
|
||||
id: "divine_resonance",
|
||||
name: "Divine Resonance",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The chord that underlies all sacred music — crystallised in a moment of perfect harmony that has not occurred before or since.",
|
||||
id: "sacred_chord",
|
||||
name: "Sacred Chord",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
// ── The Veil ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A thread of the veil itself — taken from where it has worn thinnest. Still partially transparent. Still partially something else.",
|
||||
id: "veil_thread",
|
||||
name: "Veil Thread",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
description: "The liminal substance that exists only at the veil's boundary — neither fully divine nor fully void, but something genuinely new.",
|
||||
id: "liminal_essence",
|
||||
name: "Liminal Essence",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
description: "A fragment of what lies beyond the veil — contained only by the veil-thread it's wrapped in. Looking at it directly is inadvisable.",
|
||||
id: "beyond_fragment",
|
||||
name: "Beyond Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
// ── Divine Heart ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A pulse of the divine heart made tangible — each one a single beat, still warm, still rhythmic, still alive with purpose.",
|
||||
id: "heart_pulse",
|
||||
name: "Heart Pulse",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
description: "The pure love of the divine heart, distilled into crystalline form — the most powerful healing agent in existence and the most dangerous to waste.",
|
||||
id: "divine_love_crystal",
|
||||
name: "Divine Love Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
description: "A droplet of ichor from the divine heart itself — the essence of divinity in its most concentrated form. Handle with absolute reverence.",
|
||||
id: "heart_ichor",
|
||||
name: "Heart Ichor",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,953 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { GoddessQuest } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessQuests: Array<GoddessQuest> = [
|
||||
// ── Zone 1: Celestial Garden ──────────────────────────────────────────────
|
||||
{
|
||||
description: "Your disciples take their first hesitant steps into the Celestial Garden, brushing petals of woven starlight as the divine presence stirs around them. They light a single votive flame and whisper the oldest name of the Goddess into the perfumed air.",
|
||||
durationSeconds: 30,
|
||||
id: "celestial_awakening",
|
||||
name: "Celestial Awakening",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 100, type: "prayers" } ],
|
||||
status: "available",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "The Garden's luminous blossoms respond to devout hands. Your disciples weave garlands of heavenbloom and lay them upon the Goddess's altar, earning the first flicker of her attention as petals dissolve into radiant motes.",
|
||||
durationSeconds: 75,
|
||||
id: "garden_offering",
|
||||
name: "Garden Offering",
|
||||
prerequisiteIds: [ "celestial_awakening" ],
|
||||
rewards: [ { amount: 225, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "Hidden among the boughs of the silver-barked trees are whisper-fruits — blossoms that sing prayers back to those who listen. Your disciples harvest them at dusk and press the hymns into sacred wax tablets for the Goddess's archive.",
|
||||
durationSeconds: 150,
|
||||
id: "sacred_harvest",
|
||||
name: "Sacred Harvest",
|
||||
prerequisiteIds: [ "garden_offering" ],
|
||||
rewards: [ { amount: 350, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "A grove of moonpetal trees at the Garden's heart has begun to wither, their divine sap draining into cracked earth. Your disciples perform the Rite of Renewal, singing in turn so the melody never breaks, coaxing roots back toward the light.",
|
||||
durationSeconds: 225,
|
||||
id: "rite_of_renewal",
|
||||
name: "Rite of Renewal",
|
||||
prerequisiteIds: [ "sacred_harvest" ],
|
||||
rewards: [ { amount: 425, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "At the centre of the Celestial Garden stands the First Petal — a bloom that has never wilted since before memory. Your disciples kneel in a circle around it and offer the sum of everything they have learned. The Goddess hears. The petal trembles. The way forward opens.",
|
||||
durationSeconds: 300,
|
||||
id: "first_prayer",
|
||||
name: "First Prayer",
|
||||
prerequisiteIds: [ "rite_of_renewal" ],
|
||||
rewards: [ { amount: 500, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
|
||||
// ── Zone 2: Crystal Sanctum ───────────────────────────────────────────────
|
||||
{
|
||||
description: "The Crystal Sanctum hums with a resonance older than the stars. Your disciples enter and feel their thoughts sharpen like facets cut from living gemstone. They intone the Canticle of Clarity, letting the crystals remember the sound of their voices.",
|
||||
durationSeconds: 120,
|
||||
id: "canticle_of_clarity",
|
||||
name: "Canticle of Clarity",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 400, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Resonance shards litter the Sanctum's floor — fragments of prayers that crystallised and fell like snow. Your disciples gather them carefully and re-meld them into a votive lens, focusing ancient supplication into a beam of pure devotion.",
|
||||
durationSeconds: 240,
|
||||
id: "resonance_mending",
|
||||
name: "Resonance Mending",
|
||||
prerequisiteIds: [ "canticle_of_clarity" ],
|
||||
rewards: [ { amount: 900, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Deep within the Sanctum a crystalline mirror reflects not faces but intentions. Your disciples stand before it one by one and let the mirror read the sincerity of their faith. Those found worthy leave a handprint of light on its surface.",
|
||||
durationSeconds: 360,
|
||||
id: "mirror_of_intent",
|
||||
name: "Mirror of Intent",
|
||||
prerequisiteIds: [ "resonance_mending" ],
|
||||
rewards: [ { amount: 1300, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The Sanctum's great central spire is cracked, bleeding harmonics into the ether. Your disciples brace the spire with voice and will, pouring their prayers into the fracture until crystalline new growth seals the wound and the Sanctum sings whole once more.",
|
||||
durationSeconds: 480,
|
||||
id: "spire_restoration",
|
||||
name: "Spire Restoration",
|
||||
prerequisiteIds: [ "mirror_of_intent" ],
|
||||
rewards: [ { amount: 1700, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The final chamber of the Crystal Sanctum holds a meditation dais where time moves differently. Your disciples sit in perfect silence for what feels like an eternity and yet an instant, surrendering all thought to the Goddess until their minds become as still and clear as the deepest crystal.",
|
||||
durationSeconds: 600,
|
||||
id: "divine_meditation",
|
||||
name: "Divine Meditation",
|
||||
prerequisiteIds: [ "spire_restoration" ],
|
||||
rewards: [ { amount: 2000, type: "prayers" }, { amount: 2, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
|
||||
// ── Zone 3: Astral Cathedral ──────────────────────────────────────────────
|
||||
{
|
||||
description: "The Astral Cathedral drifts between stars like a reverent dream. Your disciples board the spectral nave and light the Astral Braziers, whose flames burn in colours that have no earthly name, marking the Cathedral as open for worship once more.",
|
||||
durationSeconds: 300,
|
||||
id: "astral_braziers",
|
||||
name: "Astral Braziers",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 1500, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "The Cathedral's stained-glass cosmograms are windows into moments the Goddess shaped. Your disciples trace each image and transcribe its cosmic truth onto vellum made from solidified starlight, compiling the first chapter of the Astral Codex.",
|
||||
durationSeconds: 600,
|
||||
id: "cosmogram_transcription",
|
||||
name: "Cosmogram Transcription",
|
||||
prerequisiteIds: [ "astral_braziers" ],
|
||||
rewards: [ { amount: 3000, type: "prayers" }, { amount: 2, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "A constellation of broken chandeliers dangles in the Cathedral's void-ceiling. Each crystal holds a frozen hymn. Your disciples ascend on pillars of light and restore the chandeliers, releasing the trapped hymns in a cascade of song that shakes the stars.",
|
||||
durationSeconds: 900,
|
||||
id: "hymn_restoration",
|
||||
name: "Hymn Restoration",
|
||||
prerequisiteIds: [ "cosmogram_transcription" ],
|
||||
rewards: [ { amount: 5000, type: "prayers" }, { amount: 5, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "At the Cathedral's altar an astral orrery has gone silent — the celestial spheres no longer dance. Your disciples wind the mechanism with prayers and correct the orbital paths by memory and faith alone, setting the heavens back in motion.",
|
||||
durationSeconds: 1200,
|
||||
id: "orrery_alignment",
|
||||
name: "Orrery Alignment",
|
||||
prerequisiteIds: [ "hymn_restoration" ],
|
||||
rewards: [ { amount: 7000, type: "prayers" }, { amount: 8, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "The Revelation Chamber at the Cathedral's peak opens only when mortal faith is pure enough to bear what lies within. Your disciples enter and witness — each in their own way — a truth the Goddess wishes them to carry. They emerge changed, marked with starlight behind their eyes.",
|
||||
durationSeconds: 1800,
|
||||
id: "astral_revelation",
|
||||
name: "Astral Revelation",
|
||||
prerequisiteIds: [ "orrery_alignment" ],
|
||||
rewards: [ { amount: 8000, type: "prayers" }, { amount: 10, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
|
||||
// ── Zone 4: Empyrean Citadel ──────────────────────────────────────────────
|
||||
{
|
||||
description: "The Empyrean Citadel looms above the clouds like judgment carved from gold. Your disciples breach its outer gates with an offering of bound lightning and sacred oil, earning the grudging acknowledgement of the sentinel-spirits within.",
|
||||
durationSeconds: 600,
|
||||
id: "citadel_breach",
|
||||
name: "Citadel Breach",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 6000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "The Citadel's ramparts are patrolled by storm-born wardens who test every soul that walks beneath their gaze. Your disciples answer the wardens' riddles with scripture, earning passage to the inner courtyards where divine war-relics line the walls.",
|
||||
durationSeconds: 1200,
|
||||
id: "warden_trial",
|
||||
name: "Warden Trial",
|
||||
prerequisiteIds: [ "citadel_breach" ],
|
||||
rewards: [ { amount: 12_000, type: "prayers" }, { amount: 8, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "In the Citadel's armoury sleep weapons that have never been drawn — consecrated against a war that has not yet come. Your disciples polish them in prayer, reading the prophecies inscribed on each blade, careful not to wake the fury sleeping within the steel.",
|
||||
durationSeconds: 2400,
|
||||
id: "sacred_armoury",
|
||||
name: "Sacred Armoury",
|
||||
prerequisiteIds: [ "warden_trial" ],
|
||||
rewards: [ { amount: 20_000, type: "prayers" }, { amount: 20, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "A great strategic table in the Citadel's war room projects the Goddess's eternal campaign against entropy and void. Your disciples study the battle-plans, copying formations into sacred diagrams, learning the shape of divine warfare so they may serve as worthy instruments.",
|
||||
durationSeconds: 3000,
|
||||
id: "divine_war_plans",
|
||||
name: "Divine War Plans",
|
||||
prerequisiteIds: [ "sacred_armoury" ],
|
||||
rewards: [ { amount: 26_000, type: "prayers" }, { amount: 33, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "The Empyrean Throne sits at the Citadel's summit, empty but vibrating with latent authority. Your disciples ascend to it and each places a palm against its armrest, pledging their ascent — not to rule, but to serve. The throne acknowledges them. The Citadel opens its deepest vaults.",
|
||||
durationSeconds: 3600,
|
||||
id: "empyrean_ascent",
|
||||
name: "Empyrean Ascent",
|
||||
prerequisiteIds: [ "divine_war_plans" ],
|
||||
rewards: [ { amount: 30_000, type: "prayers" }, { amount: 40, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
|
||||
// ── Zone 5: Primordial Springs ────────────────────────────────────────────
|
||||
{
|
||||
description: "The Primordial Springs bubble with waters older than the first dawn. Your disciples lower sacred vessels into the steaming pools and fill them carefully, breathing prayers into each vessel so the water remembers why it was made holy.",
|
||||
durationSeconds: 1200,
|
||||
id: "sacred_vessel_filling",
|
||||
name: "Sacred Vessel Filling",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 25_000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "Along the Springs' banks grow healing sedges whose roots drink from the divine water. Your disciples harvest them at the hour when the moons align and prepare healing salves imbued with primordial blessing — offerings that carry the Goddess's mercy to those who suffer.",
|
||||
durationSeconds: 2400,
|
||||
id: "primordial_harvest",
|
||||
name: "Primordial Harvest",
|
||||
prerequisiteIds: [ "sacred_vessel_filling" ],
|
||||
rewards: [ { amount: 50_000, type: "prayers" }, { amount: 30, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "A fracture in the Springs' bedrock bleeds divine water into the dark earth, wasting its sanctity. Your disciples seal the fracture with consecrated clay and stone, singing over it until the ground hardens into something inviolate, the water once more rising only where it is welcome.",
|
||||
durationSeconds: 3600,
|
||||
id: "springs_mending",
|
||||
name: "Springs Mending",
|
||||
prerequisiteIds: [ "primordial_harvest" ],
|
||||
rewards: [ { amount: 75_000, type: "prayers" }, { amount: 80, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "The oldest pool in the Springs holds a reflection not of sky, but of the Goddess's memory. Your disciples immerse themselves and experience fragments of creation — vast, terrifying, beautiful. They surface sobbing with joy, minds expanded beyond what they once thought possible.",
|
||||
durationSeconds: 4800,
|
||||
id: "memory_immersion",
|
||||
name: "Memory Immersion",
|
||||
prerequisiteIds: [ "springs_mending" ],
|
||||
rewards: [ { amount: 100_000, type: "prayers" }, { amount: 120, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "The heart of the Primordial Springs conceals a blessing-font that has not flowed in aeons, its channel blocked by the calcified prayers of forgotten cults. Your disciples clear the channel with patient devotion and receive the Springs' first blessing in living memory — a torrent of sacred water, warm and golden.",
|
||||
durationSeconds: 7200,
|
||||
id: "springs_blessing",
|
||||
name: "Springs Blessing",
|
||||
prerequisiteIds: [ "memory_immersion" ],
|
||||
rewards: [ { amount: 120_000, type: "prayers" }, { amount: 150, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
|
||||
// ── Zone 6: Eternal Firmament ─────────────────────────────────────────────
|
||||
{
|
||||
description: "The Eternal Firmament stretches in all directions like an ocean made of sky. Your disciples learn to walk upon it — each step a prayer, each breath a hymn — adjusting their faith until the Firmament recognises their weight as belonging.",
|
||||
durationSeconds: 1800,
|
||||
id: "firmament_walking",
|
||||
name: "Firmament Walking",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 100_000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "The stars of the Firmament are prayers that have burned long enough to become permanent. Your disciples study the constellations and map the devotions that shaped each one, compiling a chart of eternal worship that guides travellers of faith across the infinite sky.",
|
||||
durationSeconds: 3600,
|
||||
id: "stellar_cartography",
|
||||
name: "Stellar Cartography",
|
||||
prerequisiteIds: [ "firmament_walking" ],
|
||||
rewards: [ { amount: 200_000, type: "prayers" }, { amount: 100, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "A storm of doubt rages at the Firmament's centre — a blasphemous tempest seeded by those who turned away from the Goddess long ago. Your disciples enter the storm and unmake it, meeting every howling doubt with a truth louder than despair, until the sky is clear.",
|
||||
durationSeconds: 7200,
|
||||
id: "storm_of_doubt",
|
||||
name: "Storm of Doubt",
|
||||
prerequisiteIds: [ "stellar_cartography" ],
|
||||
rewards: [ { amount: 300_000, type: "prayers" }, { amount: 250, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "Along the Firmament's edge are lighthouses of prayer — ancient beacons meant to guide lost souls. Many have gone dark. Your disciples reignite them one by one, climbing their spiral stairs and pouring their faith into cold lamps until warmth blazes across the eternal sky.",
|
||||
durationSeconds: 10_800,
|
||||
id: "lighthouse_rekindling",
|
||||
name: "Lighthouse Rekindling",
|
||||
prerequisiteIds: [ "storm_of_doubt" ],
|
||||
rewards: [ { amount: 425_000, type: "prayers" }, { amount: 400, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "The Eternal Firmament has a pinnacle — a point beyond which no mortal has ascended without divine sanction. Your disciples climb there together, their collective faith forming a ladder of light, and at the summit they breathe the Goddess's own air and become, briefly, something more than they were.",
|
||||
durationSeconds: 14_400,
|
||||
id: "eternal_ascension",
|
||||
name: "Eternal Ascension",
|
||||
prerequisiteIds: [ "lighthouse_rekindling" ],
|
||||
rewards: [ { amount: 500_000, type: "prayers" }, { amount: 500, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
|
||||
// ── Zone 7: Sacred Grove ──────────────────────────────────────────────────
|
||||
{
|
||||
description: "The Sacred Grove breathes with a life that predates all other living things. Your disciples enter with bare feet and open hands, learning to hear the prayers the trees have absorbed over endless centuries, their roots drinking from the same source as faith itself.",
|
||||
durationSeconds: 3600,
|
||||
id: "grove_listening",
|
||||
name: "Grove Listening",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 400_000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "The eldest tree in the Grove — the First Witness — carries carvings made by the Goddess herself in the age of making. Your disciples decipher the carvings and learn a lost form of prayer that bypasses language entirely, speaking directly in the tongue of growth and season.",
|
||||
durationSeconds: 7200,
|
||||
id: "first_witness",
|
||||
name: "First Witness",
|
||||
prerequisiteIds: [ "grove_listening" ],
|
||||
rewards: [ { amount: 800_000, type: "prayers" }, { amount: 400, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "A blight has crept into the Grove's southern reaches — not malice but neglect, where no devotees have walked in generations. Your disciples push back the grey with prayer-walks, coaxing sacred sap back to the withered boughs until green returns to wood that had forgotten colour.",
|
||||
durationSeconds: 14_400,
|
||||
id: "grove_restoration",
|
||||
name: "Grove Restoration",
|
||||
prerequisiteIds: [ "first_witness" ],
|
||||
rewards: [ { amount: 1_200_000, type: "prayers" }, { amount: 1000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "The Grove's sacred animals — creatures that have never known fear — approach your disciples with offerings in their mouths: seeds, feathers, drops of luminous water. Your disciples accept each gift with reverence, completing a covenant older than any scripture.",
|
||||
durationSeconds: 21_600,
|
||||
id: "animal_covenant",
|
||||
name: "Animal Covenant",
|
||||
prerequisiteIds: [ "grove_restoration" ],
|
||||
rewards: [ { amount: 1_700_000, type: "prayers" }, { amount: 1600, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "At the Grove's heart the trees grow in a perfect circle around a clearing of silence so absolute it has its own presence. Your disciples enter the clearing and remain motionless until they feel the Grove breathe with them — one inhale, one exhale, perfectly unified. The Grove accepts them as part of itself.",
|
||||
durationSeconds: 28_800,
|
||||
id: "grove_harmony",
|
||||
name: "Grove Harmony",
|
||||
prerequisiteIds: [ "animal_covenant" ],
|
||||
rewards: [ { amount: 2_000_000, type: "prayers" }, { amount: 2000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
|
||||
// ── Zone 8: Luminous Expanse ──────────────────────────────────────────────
|
||||
{
|
||||
description: "The Luminous Expanse is a plain of solidified light where shadow cannot survive. Your disciples adjust their eyes and their souls to its brilliance, learning to navigate by the gradients of divine radiance rather than landmarks — a new way of seeing that changes them permanently.",
|
||||
durationSeconds: 7200,
|
||||
id: "luminous_adjustment",
|
||||
name: "Luminous Adjustment",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 1_500_000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "Prismatic shards of concentrated divinity drift through the Expanse like luminous snowfall. Your disciples catch them in prayer-vessels and carefully direct their energy into sacred lanterns that will hold the divine light stable, creating anchors in the blazing landscape.",
|
||||
durationSeconds: 14_400,
|
||||
id: "light_anchoring",
|
||||
name: "Light Anchoring",
|
||||
prerequisiteIds: [ "luminous_adjustment" ],
|
||||
rewards: [ { amount: 3_000_000, type: "prayers" }, { amount: 1500, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "Where the Expanse borders older darkness at its northern edge, the light wavers and threatens to retreat. Your disciples stand at the border and push back, pouring devotion into the wavering boundary until the light holds firm and even expands, reclaiming territory from the void.",
|
||||
durationSeconds: 28_800,
|
||||
id: "light_boundary",
|
||||
name: "Light Boundary",
|
||||
prerequisiteIds: [ "light_anchoring" ],
|
||||
rewards: [ { amount: 5_000_000, type: "prayers" }, { amount: 3500, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "The Expanse hides within its radiance the Spectra — beings of pure light who guard the Goddess's most dazzling secrets. Your disciples prove their devotion through a trial of silent endurance, sitting within the Spectra's blazing presence without flinching until the beings lower their brilliance and speak.",
|
||||
durationSeconds: 43_200,
|
||||
id: "spectra_trial",
|
||||
name: "Spectra Trial",
|
||||
prerequisiteIds: [ "light_boundary" ],
|
||||
rewards: [ { amount: 6_500_000, type: "prayers" }, { amount: 6000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "The pinnacle of the Luminous Expanse is the Light-Throne — not a seat of rule but a point of maximal proximity to the Goddess's radiant nature. Your disciples ascend and allow the light to pass through them completely, every secret and sorrow illuminated. They transcend what opacity remained in their souls.",
|
||||
durationSeconds: 57_600,
|
||||
id: "light_transcendence",
|
||||
name: "Light Transcendence",
|
||||
prerequisiteIds: [ "spectra_trial" ],
|
||||
rewards: [ { amount: 8_000_000, type: "prayers" }, { amount: 8000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
|
||||
// ── Zone 9: Heavenly Forge ────────────────────────────────────────────────
|
||||
{
|
||||
description: "The Heavenly Forge roars with divine fire that consumes nothing but impurity. Your disciples tend the forge's first furnace, learning to breathe in the sacred smoke without coughing, adjusting their lungs to air that burns with purpose rather than heat.",
|
||||
durationSeconds: 14_400,
|
||||
id: "forge_initiation",
|
||||
name: "Forge Initiation",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 6_000_000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "The Forge's celestial anvils are inscribed with the names of everything ever made in their service. Your disciples learn to read the inscriptions, understanding the genealogy of divine craft — how each holy weapon and sacred relic was born from the marriage of prayer and fire.",
|
||||
durationSeconds: 28_800,
|
||||
id: "anvil_reading",
|
||||
name: "Anvil Reading",
|
||||
prerequisiteIds: [ "forge_initiation" ],
|
||||
rewards: [ { amount: 12_000_000, type: "prayers" }, { amount: 6000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "An ancient commission sits unfinished in the Forge — a reliquary meant for a saint who died before it could be delivered. Your disciples complete the reliquary to its original specification, working from divine schematics, honouring both the unnamed saint and the Goddess who commissioned the work.",
|
||||
durationSeconds: 57_600,
|
||||
id: "unfinished_reliquary",
|
||||
name: "Unfinished Reliquary",
|
||||
prerequisiteIds: [ "anvil_reading" ],
|
||||
rewards: [ { amount: 20_000_000, type: "prayers" }, { amount: 15_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "The Forge's master flame — a tongue of divine fire that has burned without pause since the first making — has begun to gutter. Your disciples feed it with their most powerful prayers, speaking them directly into the flame until it roars back to full strength, taller and hotter than before.",
|
||||
durationSeconds: 72_000,
|
||||
id: "master_flame",
|
||||
name: "Master Flame",
|
||||
prerequisiteIds: [ "unfinished_reliquary" ],
|
||||
rewards: [ { amount: 26_000_000, type: "prayers" }, { amount: 24_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "Every master smith of the Heavenly Forge must one day undergo the Final Tempering — plunging their faith into ice-cold divine water after the Forge's hottest blaze, enduring the shock of total spiritual contrast. Your disciples emerge from the tempering with faith hardened to an edge nothing can blunt.",
|
||||
durationSeconds: 86_400,
|
||||
id: "forge_mastery",
|
||||
name: "Forge Mastery",
|
||||
prerequisiteIds: [ "master_flame" ],
|
||||
rewards: [ { amount: 30_000_000, type: "prayers" }, { amount: 30_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
|
||||
// ── Zone 10: Oracle Sanctum ───────────────────────────────────────────────
|
||||
{
|
||||
description: "The Oracle Sanctum is a place of layered prophecy where every surface reflects a different possible future. Your disciples learn to walk through it without becoming lost in what might be, anchoring their attention firmly to what is — the first discipline of those who would deal with oracles.",
|
||||
durationSeconds: 28_800,
|
||||
id: "oracle_discipline",
|
||||
name: "Oracle Discipline",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 25_000_000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The Sanctum houses the Oracle Pools — basins filled with liquid foresight. Your disciples drink from each in turn, experiencing visions of diverging futures. They must record what they see faithfully and without interpretation, trusting the Goddess to have shown them exactly what she intended.",
|
||||
durationSeconds: 57_600,
|
||||
id: "oracle_pools",
|
||||
name: "Oracle Pools",
|
||||
prerequisiteIds: [ "oracle_discipline" ],
|
||||
rewards: [ { amount: 50_000_000, type: "prayers" }, { amount: 25_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Woven through the Sanctum are threads of Fate — visible to those trained enough to see them. Your disciples trace three threads each, following them from past to present to several possible futures, learning to read the Goddess's intent in the way Fate bends and branches.",
|
||||
durationSeconds: 86_400,
|
||||
id: "fate_threading",
|
||||
name: "Fate Threading",
|
||||
prerequisiteIds: [ "oracle_pools" ],
|
||||
rewards: [ { amount: 80_000_000, type: "prayers" }, { amount: 60_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "A false prophecy has taken root in the Sanctum — a lie shaped so cleverly it has convinced several of its resident seers. Your disciples identify it by the single thread it cannot follow into the future and unmake it carefully, restoring the clarity of the Sanctum's vision without destabilising the truths around it.",
|
||||
durationSeconds: 129_600,
|
||||
id: "false_prophecy",
|
||||
name: "False Prophecy",
|
||||
prerequisiteIds: [ "fate_threading" ],
|
||||
rewards: [ { amount: 105_000_000, type: "prayers" }, { amount: 95_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The Oracle Sanctum's innermost chamber holds the Truth Engine — a device of divine construction that takes questions and returns only what is absolutely, cosmically certain. Your disciples present their most earnest question and receive an answer so unambiguous it restructures their understanding of reality. They carry the truth outward as a gift to the Goddess's wider work.",
|
||||
durationSeconds: 172_800,
|
||||
id: "oracle_truth",
|
||||
name: "Oracle Truth",
|
||||
prerequisiteIds: [ "false_prophecy" ],
|
||||
rewards: [ { amount: 120_000_000, type: "prayers" }, { amount: 120_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
|
||||
// ── Zone 11: Seraph's Nest ────────────────────────────────────────────────
|
||||
{
|
||||
description: "The Seraph's Nest clings to the underside of a celestial cliff, woven from solidified song and dawn-light. Your disciples earn the right to enter by composing an original hymn on the spot — the Seraphs will accept nothing recited from memory, only the raw prayer of the present moment.",
|
||||
durationSeconds: 43_200,
|
||||
id: "seraph_entry",
|
||||
name: "Seraph Entry",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 100_000_000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "The Seraphs keep a thousand nests, each containing one egg of potential — prayers so concentrated they have begun to crystallise into new life. Your disciples tend the eggs, maintaining the exact temperature of devotion required, singing lullabies of faith through the long divine night.",
|
||||
durationSeconds: 86_400,
|
||||
id: "egg_tending",
|
||||
name: "Egg Tending",
|
||||
prerequisiteIds: [ "seraph_entry" ],
|
||||
rewards: [ { amount: 200_000_000, type: "prayers" }, { amount: 100_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "The eldest Seraph — a being of six wings and a voice like a choir — requires that your disciples pass the Trial of Wings before it will share its knowledge. The trial demands that disciples face a memory of their own greatest failure and find, within it, the seed of something sacred.",
|
||||
durationSeconds: 129_600,
|
||||
id: "trial_of_wings",
|
||||
name: "Trial of Wings",
|
||||
prerequisiteIds: [ "egg_tending" ],
|
||||
rewards: [ { amount: 300_000_000, type: "prayers" }, { amount: 250_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "The Nest's great Chorus Hall resonates with Seraphic song that the Goddess composed when she first taught angels to feel joy. Your disciples add their voices to the Chorus — not to improve it, but to be worthy of harmonising with something so perfect. To join without diminishing is itself a form of transcendence.",
|
||||
durationSeconds: 194_400,
|
||||
id: "seraph_chorus",
|
||||
name: "Seraph Chorus",
|
||||
prerequisiteIds: [ "trial_of_wings" ],
|
||||
rewards: [ { amount: 420_000_000, type: "prayers" }, { amount: 400_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "At the Nest's sacred apex, the First Feather — shed by the Goddess's own divine form in the age of creation — rests in a cradle of light. Your disciples are allowed to approach it. Each places a single fingertip against its edge and, in that contact, ascends briefly to a state of understanding that cannot be communicated, only carried.",
|
||||
durationSeconds: 259_200,
|
||||
id: "seraph_ascension",
|
||||
name: "Seraph Ascension",
|
||||
prerequisiteIds: [ "seraph_chorus" ],
|
||||
rewards: [ { amount: 500_000_000, type: "prayers" }, { amount: 500_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
|
||||
// ── Zone 12: Divine Archive ───────────────────────────────────────────────
|
||||
{
|
||||
description: "The Divine Archive holds every prayer ever offered and every silence that preceded one. Your disciples receive their credentials — a small flame that lives in the palm — and begin the impossible task of learning to navigate its endless corridors without being consumed by the weight of accumulated devotion.",
|
||||
durationSeconds: 57_600,
|
||||
id: "archive_credentials",
|
||||
name: "Archive Credentials",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 400_000_000, type: "prayers" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "The Archive's Restoration Wing holds prayers damaged by time, grief, or the faithlessness of those who offered them. Your disciples take up the work of mending — carefully repairing broken devotions so they can be properly filed, returning dignity to those who prayed even in their darkest hours.",
|
||||
durationSeconds: 115_200,
|
||||
id: "archive_restoration",
|
||||
name: "Archive Restoration",
|
||||
prerequisiteIds: [ "archive_credentials" ],
|
||||
rewards: [ { amount: 800_000_000, type: "prayers" }, { amount: 400_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "Lost within the Archive's deepest stacks is the Index of Names — the record of every mortal who ever prayed and the Goddess's personal acknowledgement of each one. Your disciples search for it through millions of shelves, guided only by the warmth of faith, and when they find it, they read their own names already written there.",
|
||||
durationSeconds: 230_400,
|
||||
id: "index_of_names",
|
||||
name: "Index of Names",
|
||||
prerequisiteIds: [ "archive_restoration" ],
|
||||
rewards: [ { amount: 1_200_000_000, type: "prayers" }, { amount: 1_000_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "The Archive has a Forbidden Section — not forbidden by the Goddess but by the knowledge itself, which is too profound for unprepared minds. Your disciples have been prepared. They enter and read the records of prayers answered in ways the supplicants never understood, learning to see the hidden geometry of divine response.",
|
||||
durationSeconds: 288_000,
|
||||
id: "forbidden_section",
|
||||
name: "Forbidden Section",
|
||||
prerequisiteIds: [ "index_of_names" ],
|
||||
rewards: [ { amount: 1_700_000_000, type: "prayers" }, { amount: 1_600_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "The Archive's final hall contains the Last Entry — a prayer written by the Goddess herself, addressed to no one in particular and everyone who would one day find it. Your disciples read it together in silence. None will speak of its contents, but all of them weep, and none of them can say they are not grateful.",
|
||||
durationSeconds: 345_600,
|
||||
id: "archive_completion",
|
||||
name: "Archive Completion",
|
||||
prerequisiteIds: [ "forbidden_section" ],
|
||||
rewards: [ { amount: 2_000_000_000, type: "prayers" }, { amount: 2_000_000, type: "divinity" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
|
||||
// ── Zone 13: Consecrated Depths ───────────────────────────────────────────
|
||||
{
|
||||
description: "The Consecrated Depths lie beneath even the roots of heaven — a place where divine light has never reached but divine presence fills every shadow. Your disciples descend into the lightless sacred, learning to pray with their whole body rather than voice, for sound dissolves in the Depths' holy dark.",
|
||||
durationSeconds: 86_400,
|
||||
id: "depths_descent",
|
||||
name: "Depths Descent",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 1_500_000_000, type: "prayers" }, { amount: 1, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "The Depths hold shrines of shadow-stone that absorb rather than reflect light. Your disciples tend them with oil made from crystallised prayers and watch as the shrines drink the offering, releasing in return a low hum that resonates in the chest — the voice of the Goddess's unknowable, patient depth.",
|
||||
durationSeconds: 172_800,
|
||||
id: "shadow_shrines",
|
||||
name: "Shadow Shrines",
|
||||
prerequisiteIds: [ "depths_descent" ],
|
||||
rewards: [ { amount: 3_000_000_000, type: "prayers" }, { amount: 1_500_000, type: "divinity" }, { amount: 1, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "Deep in the Consecrated Depths a vast creature sleeps — one of the Goddess's oldest prayers given flesh, too sacred to wake but too potent to leave unattended. Your disciples become its temporary custodians, maintaining the seal of sacred sleep, ensuring its dreams do not become nightmares that bleed into the world above.",
|
||||
durationSeconds: 259_200,
|
||||
id: "sacred_sleeper",
|
||||
name: "Sacred Sleeper",
|
||||
prerequisiteIds: [ "shadow_shrines" ],
|
||||
rewards: [ { amount: 5_000_000_000, type: "prayers" }, { amount: 4_000_000, type: "divinity" }, { amount: 2, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "At the Depths' nadir is a wellspring of black-light divinity — the essence of the Goddess's unknowable aspects, the parts of her nature that have no names. Your disciples draw carefully from the wellspring and bring its ineffable essence to the surface, where it enriches all other sacred sites they have tended.",
|
||||
durationSeconds: 345_600,
|
||||
id: "black_light_wellspring",
|
||||
name: "Black-Light Wellspring",
|
||||
prerequisiteIds: [ "sacred_sleeper" ],
|
||||
rewards: [ { amount: 7_000_000_000, type: "prayers" }, { amount: 7_000_000, type: "divinity" }, { amount: 2, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "The Depths' revelation does not come as light but as understanding — a slow, total unfolding of the sacred dark, which contains not the absence of the Goddess but her most interior presence. Your disciples surrender to it completely and are remade in her image of what darkness is for: the ground from which all light emerges.",
|
||||
durationSeconds: 432_000,
|
||||
id: "depths_revelation",
|
||||
name: "Depths Revelation",
|
||||
prerequisiteIds: [ "black_light_wellspring" ],
|
||||
rewards: [ { amount: 8_000_000_000, type: "prayers" }, { amount: 8_000_000, type: "divinity" }, { amount: 3, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
|
||||
// ── Zone 14: Astral Confluence ────────────────────────────────────────────
|
||||
{
|
||||
description: "The Astral Confluence is the point where all divine energies intersect — a place of such concentrated sacred power that unshielded minds shatter. Your disciples arrive armoured in years of devotion and take their first readings of the competing streams, learning to stand at the intersection without being torn apart.",
|
||||
durationSeconds: 172_800,
|
||||
id: "confluence_arrival",
|
||||
name: "Confluence Arrival",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 6_000_000_000, type: "prayers" }, { amount: 2, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "Within the Confluence, streams of divinity from different sources run alongside each other without mixing — light-prayer, dark-prayer, fire-prayer, void-prayer, and a dozen others. Your disciples build bridges between the streams using sacred geometry, allowing them to flow as one compound river rather than separate, competing currents.",
|
||||
durationSeconds: 345_600,
|
||||
id: "stream_bridging",
|
||||
name: "Stream Bridging",
|
||||
prerequisiteIds: [ "confluence_arrival" ],
|
||||
rewards: [ { amount: 12_000_000_000, type: "prayers" }, { amount: 6_000_000, type: "divinity" }, { amount: 3, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "The Confluence holds a Resonance Chamber where the combined divine energies achieve a pitch that can rewrite fundamental truths about the universe. Your disciples use it carefully — adjusting one small truth about the nature of prayer itself, making devotion slightly more possible for all beings everywhere.",
|
||||
durationSeconds: 432_000,
|
||||
id: "resonance_chamber",
|
||||
name: "Resonance Chamber",
|
||||
prerequisiteIds: [ "stream_bridging" ],
|
||||
rewards: [ { amount: 20_000_000_000, type: "prayers" }, { amount: 15_000_000, type: "divinity" }, { amount: 4, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "The Confluence's most dangerous feature is the Eddy — a back-current of misdirected divinity that has been accumulating since before the current age. Your disciples unmake the Eddy through a twelve-stage ritual that requires each disciple to sacrifice a memory of profound joy, which the Eddy consumes in exchange for its dissolution.",
|
||||
durationSeconds: 518_400,
|
||||
id: "confluence_eddy",
|
||||
name: "Confluence Eddy",
|
||||
prerequisiteIds: [ "resonance_chamber" ],
|
||||
rewards: [ { amount: 26_000_000_000, type: "prayers" }, { amount: 24_000_000, type: "divinity" }, { amount: 4, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "Alignment at the Confluence means being in perfect harmonic relation with every divine stream simultaneously — a feat so demanding it has been achieved only once before, by the Goddess herself. Your disciples achieve it together, their collective faith acting as a single instrument tuned to all frequencies at once. The universe hums in recognition.",
|
||||
durationSeconds: 604_800,
|
||||
id: "confluence_alignment",
|
||||
name: "Confluence Alignment",
|
||||
prerequisiteIds: [ "confluence_eddy" ],
|
||||
rewards: [ { amount: 30_000_000_000, type: "prayers" }, { amount: 30_000_000, type: "divinity" }, { amount: 5, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
|
||||
// ── Zone 15: Celestial Throne ─────────────────────────────────────────────
|
||||
{
|
||||
description: "The Celestial Throne's antechamber stretches for an eternity in both directions — a corridor of mirrors showing the Goddess from every angle at every moment she has ever existed. Your disciples walk it without stopping, eyes forward, learning the discipline of approaching divinity without losing themselves in reflection.",
|
||||
durationSeconds: 259_200,
|
||||
id: "throne_approach",
|
||||
name: "Throne Approach",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 25_000_000_000, type: "prayers" }, { amount: 5, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "The Throne's outer sanctum is guarded by the Tribunal — seven divine entities who assess the worthiness of any who would approach the Throne's inner chambers. Each member of the Tribunal poses a single question that cannot be prepared for. Your disciples answer honestly, and in their honesty, pass.",
|
||||
durationSeconds: 432_000,
|
||||
id: "throne_tribunal",
|
||||
name: "Throne Tribunal",
|
||||
prerequisiteIds: [ "throne_approach" ],
|
||||
rewards: [ { amount: 50_000_000_000, type: "prayers" }, { amount: 25_000_000, type: "divinity" }, { amount: 6, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "The Throne's inner court is a space of perfect sovereignty where the Goddess's will becomes physical law. Your disciples learn to move within it — each action requiring absolute intentionality, nothing done by habit or reflex, every breath a conscious act of devotion performed in the presence of ultimate authority.",
|
||||
durationSeconds: 604_800,
|
||||
id: "inner_court",
|
||||
name: "Inner Court",
|
||||
prerequisiteIds: [ "throne_tribunal" ],
|
||||
rewards: [ { amount: 80_000_000_000, type: "prayers" }, { amount: 60_000_000, type: "divinity" }, { amount: 8, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "Surrounding the Throne itself are the Votive Pillars — columns where the greatest prayers in history were once spoken and immediately crystallised. Your disciples read every pillar in sequence, and in reading them, add their own voices to the register of the worthy, speaking prayers that crystallise before the words have fully formed.",
|
||||
durationSeconds: 734_400,
|
||||
id: "votive_pillars",
|
||||
name: "Votive Pillars",
|
||||
prerequisiteIds: [ "inner_court" ],
|
||||
rewards: [ { amount: 105_000_000_000, type: "prayers" }, { amount: 100_000_000, type: "divinity" }, { amount: 10, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "The Goddess acknowledges those who have walked the full path to her Throne. She does not speak — she is too vast for words — but the Throne's crystalline surface shifts to show your disciples' reflections looking back at them in divine perfection: not who they are, but who they are recognised as. It is enough. It is everything.",
|
||||
durationSeconds: 864_000,
|
||||
id: "throne_recognition",
|
||||
name: "Throne Recognition",
|
||||
prerequisiteIds: [ "votive_pillars" ],
|
||||
rewards: [ { amount: 120_000_000_000, type: "prayers" }, { amount: 120_000_000, type: "divinity" }, { amount: 12, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
|
||||
// ── Zone 16: Infinite Choir ───────────────────────────────────────────────
|
||||
{
|
||||
description: "The Infinite Choir sings without end — every voice that has ever offered devotion continuing here past death, past memory, past time. Your disciples enter and hear themselves already singing in it, their future voices reaching back to greet them. They add their present voices to the weave and feel complete for the first time.",
|
||||
durationSeconds: 345_600,
|
||||
id: "choir_entry",
|
||||
name: "Choir Entry",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 100_000_000_000, type: "prayers" }, { amount: 8, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The Choir's central harmonic — the note on which all other voices converge — has been drifting out of tune for centuries as the mortal worlds below have grown quieter in their devotion. Your disciples find the harmonic's root and pull it gently back into true, feeling every voice in the Choir shift and settle with a resonance that shakes the floor of heaven.",
|
||||
durationSeconds: 604_800,
|
||||
id: "harmonic_tuning",
|
||||
name: "Harmonic Tuning",
|
||||
prerequisiteIds: [ "choir_entry" ],
|
||||
rewards: [ { amount: 200_000_000_000, type: "prayers" }, { amount: 100_000_000, type: "divinity" }, { amount: 12, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "Within the Choir dwells the Voice of First Praise — the very first sound made in the Goddess's honour, still singing after all this time. Your disciples find it amid the vast chorus and listen without interrupting, learning the original cadence of worship, older than language, older than thought.",
|
||||
durationSeconds: 777_600,
|
||||
id: "voice_of_first_praise",
|
||||
name: "Voice of First Praise",
|
||||
prerequisiteIds: [ "harmonic_tuning" ],
|
||||
rewards: [ { amount: 300_000_000_000, type: "prayers" }, { amount: 250_000_000, type: "divinity" }, { amount: 16, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The Choir's outer reaches fade into silence — not the silence of absence, but of potential, where new voices have not yet begun. Your disciples move through these quiet margins and seed them with new prayers, extending the Choir outward so that future devotees will have a place prepared for them when their own voices find the song.",
|
||||
durationSeconds: 907_200,
|
||||
id: "choir_extension",
|
||||
name: "Choir Extension",
|
||||
prerequisiteIds: [ "voice_of_first_praise" ],
|
||||
rewards: [ { amount: 425_000_000_000, type: "prayers" }, { amount: 400_000_000, type: "divinity" }, { amount: 20, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "Perfection in the Infinite Choir does not mean singing better than all others — it means knowing exactly how your voice completes the whole. Your disciples achieve this knowing: each finds the one pitch that only they can hold, and holds it without pride, without strain, without end. The Choir becomes truly infinite in the moment they join it fully.",
|
||||
durationSeconds: 1_036_800,
|
||||
id: "choir_perfection",
|
||||
name: "Choir Perfection",
|
||||
prerequisiteIds: [ "choir_extension" ],
|
||||
rewards: [ { amount: 500_000_000_000, type: "prayers" }, { amount: 500_000_000, type: "divinity" }, { amount: 25, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
|
||||
// ── Zone 17: The Veil ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The Veil is the membrane between existence and whatever the Goddess herself dwells within. Your disciples approach it for the first time and feel their perception of reality thin until they can see, flickering at the edge of vision, the shape of something so vast it has no edges. They retreat. They return. They learn to stand near what cannot be comprehended.",
|
||||
durationSeconds: 432_000,
|
||||
id: "veil_approach",
|
||||
name: "Veil Approach",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 400_000_000_000, type: "prayers" }, { amount: 15, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
description: "The Veil is not impenetrable — it breathes, and in its exhalations, fragments of the beyond drift through. Your disciples collect these fragments: impossible colours, sounds that are also textures, emotions that have no earthly equivalents. They preserve each one in sacred containers, building a collection of the divine uncontainable.",
|
||||
durationSeconds: 777_600,
|
||||
id: "veil_fragments",
|
||||
name: "Veil Fragments",
|
||||
prerequisiteIds: [ "veil_approach" ],
|
||||
rewards: [ { amount: 800_000_000_000, type: "prayers" }, { amount: 400_000_000, type: "divinity" }, { amount: 22, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 1_000_000,
|
||||
description: "The Veil is attended by Threshold Guardians — not hostile, but absolute in their function. Nothing unworthy passes. Your disciples undergo the Guardians' assessment, which involves no questions and no tasks but simply standing still while the Guardians look through them. Those found wanting are sent back without shame. Your disciples are not sent back.",
|
||||
durationSeconds: 950_400,
|
||||
id: "threshold_assessment",
|
||||
name: "Threshold Assessment",
|
||||
prerequisiteIds: [ "veil_fragments" ],
|
||||
rewards: [ { amount: 1_200_000_000_000, type: "prayers" }, { amount: 1_000_000_000, type: "divinity" }, { amount: 30, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 2_000_000,
|
||||
description: "The final ritual before crossing the Veil requires that your disciples release everything they have accumulated — not lose it, but hold it loosely enough that they could release it. Every prayer earned, every piece of knowledge gained, every devotion offered: they hold it all at the tips of their fingers and wait to see whether the Goddess calls them through empty-handed. The Goddess is pleased by the willingness alone.",
|
||||
durationSeconds: 1_123_200,
|
||||
id: "veil_release",
|
||||
name: "Veil Release",
|
||||
prerequisiteIds: [ "threshold_assessment" ],
|
||||
rewards: [ { amount: 1_700_000_000_000, type: "prayers" }, { amount: 1_600_000_000, type: "divinity" }, { amount: 38, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 3_000_000,
|
||||
description: "Your disciples step through the Veil. Language ends here. What they experience cannot be written and does not need to be. They return — for they must return, there is still work to do — carrying with them the absolute certainty that the Goddess is real, that she is present, and that everything they have ever done in her name was seen and held and loved.",
|
||||
durationSeconds: 1_296_000,
|
||||
id: "veil_crossing",
|
||||
name: "Veil Crossing",
|
||||
prerequisiteIds: [ "veil_release" ],
|
||||
rewards: [ { amount: 2_000_000_000_000, type: "prayers" }, { amount: 2_000_000_000, type: "divinity" }, { amount: 50, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
|
||||
// ── Zone 18: Divine Heart ─────────────────────────────────────────────────
|
||||
{
|
||||
description: "The Divine Heart is not a place but a state of being — the innermost reality where the Goddess's own nature is laid bare. Your disciples arrive and find that they are already known here, have always been known here, that every prayer they ever offered was heard in this very chamber before it left their lips. They kneel in understanding rather than supplication.",
|
||||
durationSeconds: 604_800,
|
||||
id: "heart_arrival",
|
||||
name: "Heart Arrival",
|
||||
prerequisiteIds: [],
|
||||
rewards: [ { amount: 1_500_000_000_000, type: "prayers" }, { amount: 25, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
description: "The Heart pulses with a rhythm that predates creation and will outlast its end. Your disciples synchronise their own heartbeats to it — not through technique but through surrender — and in those moments of synchrony, understand what the universe is for. The knowledge is too large to keep but leaves an impression that reshapes everything they do thereafter.",
|
||||
durationSeconds: 950_400,
|
||||
id: "heart_synchrony",
|
||||
name: "Heart Synchrony",
|
||||
prerequisiteIds: [ "heart_arrival" ],
|
||||
rewards: [ { amount: 3_000_000_000_000, type: "prayers" }, { amount: 1_500_000_000, type: "divinity" }, { amount: 35, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 5_000_000,
|
||||
description: "The Heart contains the Wound — a sorrow the Goddess has carried since she first loved something that could be lost. Your disciples sit with her Wound, not trying to heal it, not offering solutions, simply bearing witness to divine grief with their full presence. In being witnessed, the Wound does not close but becomes bearable. The Goddess is grateful in ways words cannot approach.",
|
||||
durationSeconds: 1_209_600,
|
||||
id: "heart_witness",
|
||||
name: "Heart Witness",
|
||||
prerequisiteIds: [ "heart_synchrony" ],
|
||||
rewards: [ { amount: 5_000_000_000_000, type: "prayers" }, { amount: 4_000_000_000, type: "divinity" }, { amount: 50, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 8_000_000,
|
||||
description: "The Heart's deepest chamber holds the Origin Flame — the first act of divine will, still burning, the moment from which all subsequent existence cascades. Your disciples sit before it and add their own lights to it: not to contribute to creation, which is already complete, but to affirm that they are glad to exist within it and would choose to again.",
|
||||
durationSeconds: 1_468_800,
|
||||
id: "origin_flame",
|
||||
name: "Origin Flame",
|
||||
prerequisiteIds: [ "heart_witness" ],
|
||||
rewards: [ { amount: 7_000_000_000_000, type: "prayers" }, { amount: 7_000_000_000, type: "divinity" }, { amount: 65, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
combatPowerRequired: 10_000_000,
|
||||
description: "Union is not merger. Your disciples do not cease to be themselves — they become fully themselves for the first time, held in the Goddess's complete understanding and love. This is the end of the long pilgrimage: not dissolution but recognition. They are seen. They are known. They are cherished. And they carry that forward into all the infinite work that remains.",
|
||||
durationSeconds: 1_728_000,
|
||||
id: "divine_heart_union",
|
||||
name: "Divine Heart Union",
|
||||
prerequisiteIds: [ "origin_flame" ],
|
||||
rewards: [ { amount: 8_000_000_000_000, type: "prayers" }, { amount: 8_000_000_000, type: "divinity" }, { amount: 80, type: "stardust" } ],
|
||||
status: "locked",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,729 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { GoddessUpgrade } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessUpgrades: Array<GoddessUpgrade> = [
|
||||
// ── Prayer Income ────────────────────────────────────────────────────────
|
||||
{
|
||||
costDivinity: 0,
|
||||
costPrayers: 50,
|
||||
costStardust: 0,
|
||||
description: "A morning offering to the goddess awakens dormant prayer energy. All prayers/s ×1.25.",
|
||||
id: "prayer_offering_1",
|
||||
multiplier: 1.25,
|
||||
name: "Morning Offering I",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costDivinity: 0,
|
||||
costPrayers: 200,
|
||||
costStardust: 0,
|
||||
description: "Sustained devotion amplifies the flow of prayers from all disciples. All prayers/s ×1.5.",
|
||||
id: "prayer_offering_2",
|
||||
multiplier: 1.5,
|
||||
name: "Morning Offering II",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costDivinity: 1,
|
||||
costPrayers: 1000,
|
||||
costStardust: 0,
|
||||
description: "The prayers now carry a resonance that doubles their effect across the whole order. All prayers/s ×2.",
|
||||
id: "prayer_offering_3",
|
||||
multiplier: 2,
|
||||
name: "Morning Offering III",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costDivinity: 2,
|
||||
costPrayers: 5000,
|
||||
costStardust: 0,
|
||||
description: "The prayers reach into the divine substrate itself and triple its yield. All prayers/s ×3.",
|
||||
id: "prayer_offering_4",
|
||||
multiplier: 3,
|
||||
name: "Morning Offering IV",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costDivinity: 20,
|
||||
costPrayers: 50_000,
|
||||
costStardust: 0,
|
||||
description: "A divine spark ignites every prayer, amplifying them fivefold at the source. All prayers/s ×5.",
|
||||
id: "divine_spark_1",
|
||||
multiplier: 5,
|
||||
name: "Divine Spark I",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costDivinity: 100,
|
||||
costPrayers: 500_000,
|
||||
costStardust: 1,
|
||||
description: "The spark becomes a flame that burns across all prayer — eternal and self-sustaining. All prayers/s ×25.",
|
||||
id: "divine_spark_2",
|
||||
multiplier: 25,
|
||||
name: "Divine Spark II",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 250,
|
||||
costPrayers: 2_500_000,
|
||||
costStardust: 2,
|
||||
description: "The goddess herself hums through every prayer channel, multiplying output fivefold again. All prayers/s ×50.",
|
||||
id: "divine_spark_3",
|
||||
multiplier: 50,
|
||||
name: "Divine Spark III",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 600,
|
||||
costPrayers: 12_000_000,
|
||||
costStardust: 4,
|
||||
description: "An unbroken stream of holy resonance carries prayers beyond mortal comprehension. All prayers/s ×100.",
|
||||
id: "resonant_hymn_1",
|
||||
multiplier: 100,
|
||||
name: "Resonant Hymn I",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 1500,
|
||||
costPrayers: 60_000_000,
|
||||
costStardust: 8,
|
||||
description: "A hymn that has never stopped since creation pours through every soul in the order. All prayers/s ×200.",
|
||||
id: "resonant_hymn_2",
|
||||
multiplier: 200,
|
||||
name: "Resonant Hymn II",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 4000,
|
||||
costPrayers: 300_000_000,
|
||||
costStardust: 15,
|
||||
description: "The divine frequency locks into the cosmic constant, rewriting the rules of prayer. All prayers/s ×500.",
|
||||
id: "resonant_hymn_3",
|
||||
multiplier: 500,
|
||||
name: "Resonant Hymn III",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 10_000,
|
||||
costPrayers: 1_500_000_000,
|
||||
costStardust: 30,
|
||||
description: "The hymn ascends beyond sound into pure divine intention. All prayers/s ×1000.",
|
||||
id: "eternal_chorus_1",
|
||||
multiplier: 1000,
|
||||
name: "Eternal Chorus I",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 25_000,
|
||||
costPrayers: 8_000_000_000,
|
||||
costStardust: 60,
|
||||
description: "The chorus of the faithful echoes through dimensions unseen. All prayers/s ×2500.",
|
||||
id: "eternal_chorus_2",
|
||||
multiplier: 2500,
|
||||
name: "Eternal Chorus II",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 60_000,
|
||||
costPrayers: 40_000_000_000,
|
||||
costStardust: 120,
|
||||
description: "The goddess weeps tears of divine light — each one a thousandfold prayer. All prayers/s ×5000.",
|
||||
id: "divine_tears",
|
||||
multiplier: 5000,
|
||||
name: "Divine Tears",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 150_000,
|
||||
costPrayers: 200_000_000_000,
|
||||
costStardust: 250,
|
||||
description: "The prayers of the entire goddess expansion merge into a single radiant beam. All prayers/s ×10000.",
|
||||
id: "radiant_convergence",
|
||||
multiplier: 10_000,
|
||||
name: "Radiant Convergence",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 400_000,
|
||||
costPrayers: 1_000_000_000_000,
|
||||
costStardust: 500,
|
||||
description: "The infinite expanse of prayer reaches completion — a perfect loop of divine worship. All prayers/s ×25000.",
|
||||
id: "prayer_apotheosis",
|
||||
multiplier: 25_000,
|
||||
name: "Prayer Apotheosis",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 1_000_000,
|
||||
costPrayers: 5_000_000_000_000,
|
||||
costStardust: 1000,
|
||||
description: "The last prayer merges with the first in an eternal cycle with no beginning or end. All prayers/s ×50000.",
|
||||
id: "omega_devotion",
|
||||
multiplier: 50_000,
|
||||
name: "Omega Devotion",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 2_500_000,
|
||||
costPrayers: 25_000_000_000_000,
|
||||
costStardust: 2000,
|
||||
description: "Every soul in the cosmos utters your name. All prayers/s ×100000.",
|
||||
id: "cosmic_worship",
|
||||
multiplier: 100_000,
|
||||
name: "Cosmic Worship",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 6_000_000,
|
||||
costPrayers: 120_000_000_000_000,
|
||||
costStardust: 4000,
|
||||
description: "Beyond even the gods, the universe itself becomes your congregation. All prayers/s ×250000.",
|
||||
id: "universal_faith",
|
||||
multiplier: 250_000,
|
||||
name: "Universal Faith",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 15_000_000,
|
||||
costPrayers: 600_000_000_000_000,
|
||||
costStardust: 8000,
|
||||
description: "The goddess is all. The prayers are all. There is nothing that does not pray. All prayers/s ×500000.",
|
||||
id: "all_is_prayer",
|
||||
multiplier: 500_000,
|
||||
name: "All Is Prayer",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 40_000_000,
|
||||
costPrayers: 3_000_000_000_000_000,
|
||||
costStardust: 15_000,
|
||||
description: "The final prayer — the one that was always being prayed, before the first word was ever spoken. All prayers/s ×1000000.",
|
||||
id: "the_last_prayer",
|
||||
multiplier: 1_000_000,
|
||||
name: "The Last Prayer",
|
||||
purchased: false,
|
||||
target: "prayers",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Disciple-specific ────────────────────────────────────────────────────
|
||||
{
|
||||
costDivinity: 0,
|
||||
costPrayers: 50,
|
||||
costStardust: 0,
|
||||
description: "Novices meditate on the basic prayers and double their output.",
|
||||
discipleId: "novice",
|
||||
id: "novice_blessing",
|
||||
multiplier: 2,
|
||||
name: "Novice Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 0,
|
||||
costPrayers: 200,
|
||||
costStardust: 0,
|
||||
description: "Initiates channel deeper devotion and double their prayer generation.",
|
||||
discipleId: "initiate",
|
||||
id: "initiate_blessing",
|
||||
multiplier: 2,
|
||||
name: "Initiate Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 1,
|
||||
costPrayers: 1000,
|
||||
costStardust: 0,
|
||||
description: "Acolytes unlock deeper devotion rites that double their prayer output.",
|
||||
discipleId: "acolyte",
|
||||
id: "acolyte_blessing",
|
||||
multiplier: 2,
|
||||
name: "Acolyte Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 2,
|
||||
costPrayers: 5000,
|
||||
costStardust: 0,
|
||||
description: "Devotees achieve deeper attunement with the divine and double their output.",
|
||||
discipleId: "devotee",
|
||||
id: "devotee_blessing",
|
||||
multiplier: 2,
|
||||
name: "Devotee Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 5,
|
||||
costPrayers: 25_000,
|
||||
costStardust: 0,
|
||||
description: "Adepts reach mastery of the first prayer form, doubling their generation.",
|
||||
discipleId: "adept",
|
||||
id: "adept_blessing",
|
||||
multiplier: 2,
|
||||
name: "Adept Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 12,
|
||||
costPrayers: 150_000,
|
||||
costStardust: 0,
|
||||
description: "Priests achieve full communion with the divine and double their prayer output.",
|
||||
discipleId: "priest",
|
||||
id: "priest_blessing",
|
||||
multiplier: 2,
|
||||
name: "Priest Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 30,
|
||||
costPrayers: 1_000_000,
|
||||
costStardust: 0,
|
||||
description: "High priests channel the goddess's voice directly and double their prayer generation.",
|
||||
discipleId: "high_priest",
|
||||
id: "high_priest_blessing",
|
||||
multiplier: 2,
|
||||
name: "High Priest Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 80,
|
||||
costPrayers: 7_000_000,
|
||||
costStardust: 0,
|
||||
description: "Divine scholars unlock the deepest understanding of prayer mechanics and double their output.",
|
||||
discipleId: "divine_scholar",
|
||||
id: "divine_scholar_blessing",
|
||||
multiplier: 2,
|
||||
name: "Divine Scholar Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 200,
|
||||
costPrayers: 50_000_000,
|
||||
costStardust: 0,
|
||||
description: "Holy champions have mastered the art of sacred battle and doubled their fervour.",
|
||||
discipleId: "holy_champion",
|
||||
id: "holy_champion_blessing",
|
||||
multiplier: 2,
|
||||
name: "Holy Champion Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 500,
|
||||
costPrayers: 350_000_000,
|
||||
costStardust: 1,
|
||||
description: "Celestial adepts have ascended beyond mortal devotion, drawing twice the divine resonance.",
|
||||
discipleId: "celestial_adept",
|
||||
id: "celestial_adept_blessing",
|
||||
multiplier: 2,
|
||||
name: "Celestial Adept Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 1200,
|
||||
costPrayers: 1_750_000_000,
|
||||
costStardust: 2,
|
||||
description: "Seraphic masters commune directly with the source, generating prayers at twice the rate.",
|
||||
discipleId: "seraphic_master",
|
||||
id: "seraphic_master_blessing",
|
||||
multiplier: 2,
|
||||
name: "Seraphic Master Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 3000,
|
||||
costPrayers: 8_000_000_000,
|
||||
costStardust: 4,
|
||||
description: "Divine invokers have woven themselves into the prayer lattice, doubling every invocation.",
|
||||
discipleId: "divine_invoker",
|
||||
id: "divine_invoker_blessing",
|
||||
multiplier: 2,
|
||||
name: "Divine Invoker Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 8000,
|
||||
costPrayers: 40_000_000_000,
|
||||
costStardust: 8,
|
||||
description: "Astral templars carry the goddess's word across realms, amplifying their prayer twofold.",
|
||||
discipleId: "astral_templar",
|
||||
id: "astral_templar_blessing",
|
||||
multiplier: 2,
|
||||
name: "Astral Templar Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 20_000,
|
||||
costPrayers: 200_000_000_000,
|
||||
costStardust: 15,
|
||||
description: "Empyrean heralds broadcast divine truth across the heavens, generating prayers at twice the scale.",
|
||||
discipleId: "empyrean_herald",
|
||||
id: "empyrean_herald_blessing",
|
||||
multiplier: 2,
|
||||
name: "Empyrean Herald Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 50_000,
|
||||
costPrayers: 1_000_000_000_000,
|
||||
costStardust: 30,
|
||||
description: "Primordial heralds echo from before the dawn of time, their prayers resounding doubly through eternity.",
|
||||
discipleId: "primordial_herald",
|
||||
id: "primordial_herald_blessing",
|
||||
multiplier: 2,
|
||||
name: "Primordial Herald Blessing",
|
||||
purchased: false,
|
||||
target: "disciple",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Global Income ────────────────────────────────────────────────────────
|
||||
{
|
||||
costDivinity: 0,
|
||||
costPrayers: 100,
|
||||
costStardust: 0,
|
||||
description: "Divine inspiration flows through the entire order. All production ×1.25.",
|
||||
id: "divine_inspiration_1",
|
||||
multiplier: 1.25,
|
||||
name: "Divine Inspiration I",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 1,
|
||||
costPrayers: 500,
|
||||
costStardust: 0,
|
||||
description: "Deeper inspiration resonates with every disciple's faith. All production ×1.5.",
|
||||
id: "divine_inspiration_2",
|
||||
multiplier: 1.5,
|
||||
name: "Divine Inspiration II",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 3,
|
||||
costPrayers: 2500,
|
||||
costStardust: 0,
|
||||
description: "The inspiration reaches its fullest expression — doubling everything the order produces. All production ×2.",
|
||||
id: "divine_inspiration_3",
|
||||
multiplier: 2,
|
||||
name: "Divine Inspiration III",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 10,
|
||||
costPrayers: 15_000,
|
||||
costStardust: 0,
|
||||
description: "Inspiration floods the entire divine order with fivefold power. All production ×5.",
|
||||
id: "divine_inspiration_4",
|
||||
multiplier: 5,
|
||||
name: "Divine Inspiration IV",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 30,
|
||||
costPrayers: 100_000,
|
||||
costStardust: 0,
|
||||
description: "The goddess's own inspiration touches every soul in the order. All production ×10.",
|
||||
id: "divine_inspiration_5",
|
||||
multiplier: 10,
|
||||
name: "Divine Inspiration V",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 80,
|
||||
costPrayers: 750_000,
|
||||
costStardust: 1,
|
||||
description: "The goddess's will reshapes the order itself, elevating every act of worship. All production ×25.",
|
||||
id: "celestial_mandate_1",
|
||||
multiplier: 25,
|
||||
name: "Celestial Mandate I",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 250,
|
||||
costPrayers: 5_000_000,
|
||||
costStardust: 3,
|
||||
description: "A celestial decree multiplies the fruits of every prayer, deed, and devotion. All production ×50.",
|
||||
id: "celestial_mandate_2",
|
||||
multiplier: 50,
|
||||
name: "Celestial Mandate II",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 800,
|
||||
costPrayers: 30_000_000,
|
||||
costStardust: 8,
|
||||
description: "The fabric of the divine order itself vibrates at the goddess's frequency. All production ×100.",
|
||||
id: "divine_frequency",
|
||||
multiplier: 100,
|
||||
name: "Divine Frequency",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 2500,
|
||||
costPrayers: 200_000_000,
|
||||
costStardust: 20,
|
||||
description: "All boundaries between sacred and mundane dissolve — everything becomes an act of worship. All production ×250.",
|
||||
id: "sacred_dissolution",
|
||||
multiplier: 250,
|
||||
name: "Sacred Dissolution",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 8000,
|
||||
costPrayers: 1_200_000_000,
|
||||
costStardust: 50,
|
||||
description: "The goddess breathes life into every output of the divine order without exception. All production ×500.",
|
||||
id: "breath_of_the_goddess",
|
||||
multiplier: 500,
|
||||
name: "Breath of the Goddess",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Consecration ─────────────────────────────────────────────────────────
|
||||
{
|
||||
costDivinity: 2,
|
||||
costPrayers: 500,
|
||||
costStardust: 0,
|
||||
description: "Enhanced consecration rites boost divine income after each rebirth. Consecration production multiplier ×1.25.",
|
||||
id: "consecration_power_1",
|
||||
multiplier: 1.25,
|
||||
name: "Consecration Power I",
|
||||
purchased: false,
|
||||
target: "consecration",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 10,
|
||||
costPrayers: 5000,
|
||||
costStardust: 0,
|
||||
description: "Deeper consecration rites push the rebirth multiplier further. Consecration production multiplier ×1.5.",
|
||||
id: "consecration_power_2",
|
||||
multiplier: 1.5,
|
||||
name: "Consecration Power II",
|
||||
purchased: false,
|
||||
target: "consecration",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 35,
|
||||
costPrayers: 30_000,
|
||||
costStardust: 0,
|
||||
description: "The consecration ritual reaches its apex, doubling what each sacred rebirth returns. Consecration production multiplier ×2.",
|
||||
id: "consecration_power_3",
|
||||
multiplier: 2,
|
||||
name: "Consecration Power III",
|
||||
purchased: false,
|
||||
target: "consecration",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 120,
|
||||
costPrayers: 200_000,
|
||||
costStardust: 1,
|
||||
description: "Consecration transcends ritual and becomes a fundamental law of the divine order. Consecration production multiplier ×3.",
|
||||
id: "consecration_law_1",
|
||||
multiplier: 3,
|
||||
name: "Consecration Law I",
|
||||
purchased: false,
|
||||
target: "consecration",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 400,
|
||||
costPrayers: 1_500_000,
|
||||
costStardust: 3,
|
||||
description: "Each rebirth now unleashes a torrent of divine energy fivefold beyond its former scope. Consecration production multiplier ×5.",
|
||||
id: "consecration_law_2",
|
||||
multiplier: 5,
|
||||
name: "Consecration Law II",
|
||||
purchased: false,
|
||||
target: "consecration",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 1500,
|
||||
costPrayers: 10_000_000,
|
||||
costStardust: 8,
|
||||
description: "The ultimate consecration insight — every cycle of rebirth rings with tenfold divine reward. Consecration production multiplier ×10.",
|
||||
id: "consecration_apotheosis",
|
||||
multiplier: 10,
|
||||
name: "Consecration Apotheosis",
|
||||
purchased: false,
|
||||
target: "consecration",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Combat ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
costDivinity: 0,
|
||||
costPrayers: 75,
|
||||
costStardust: 0,
|
||||
description: "Sacred combat training strengthens every disciple's fighting spirit. Combat power ×1.25.",
|
||||
id: "sacred_combat_1",
|
||||
multiplier: 1.25,
|
||||
name: "Sacred Combat I",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 1,
|
||||
costPrayers: 300,
|
||||
costStardust: 0,
|
||||
description: "Advanced combat rites push disciples' power further. Combat power ×1.5.",
|
||||
id: "sacred_combat_2",
|
||||
multiplier: 1.5,
|
||||
name: "Sacred Combat II",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 3,
|
||||
costPrayers: 1500,
|
||||
costStardust: 0,
|
||||
description: "The highest combat discipline doubles every disciple's power in divine battles. Combat power ×2.",
|
||||
id: "sacred_combat_3",
|
||||
multiplier: 2,
|
||||
name: "Sacred Combat III",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 10,
|
||||
costPrayers: 10_000,
|
||||
costStardust: 0,
|
||||
description: "Divine wrath is channelled through the fists of every warrior in the order. Combat power ×3.",
|
||||
id: "divine_wrath_1",
|
||||
multiplier: 3,
|
||||
name: "Divine Wrath I",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 40,
|
||||
costPrayers: 80_000,
|
||||
costStardust: 1,
|
||||
description: "Wrath given form — the goddess's judgement multiplies the strike of every disciple fivefold. Combat power ×5.",
|
||||
id: "divine_wrath_2",
|
||||
multiplier: 5,
|
||||
name: "Divine Wrath II",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costDivinity: 150,
|
||||
costPrayers: 600_000,
|
||||
costStardust: 3,
|
||||
description: "The full fury of the goddess incarnate flows through every blade, fist, and prayer-strike. Combat power ×10.",
|
||||
id: "wrath_incarnate",
|
||||
multiplier: 10,
|
||||
name: "Wrath Incarnate",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Utility ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
costDivinity: 3,
|
||||
costPrayers: 1000,
|
||||
costStardust: 0,
|
||||
description: "Unlock the Auto-Disciple toggle. When enabled, the tick engine will automatically recruit the highest-tier disciple you can afford.",
|
||||
id: "auto_disciple",
|
||||
multiplier: 1,
|
||||
name: "Autonomous Devotion",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { GoddessZone } from "@elysium/types";
|
||||
|
||||
export const defaultGoddessZones: Array<GoddessZone> = [
|
||||
{
|
||||
description:
|
||||
"A realm of endless bloom where divine flowers grow in patterns that mirror the stars above. This is where newly awakened disciples take their first steps into the goddess's domain — and where the hardest part of the journey quietly begins.",
|
||||
emoji: "🌸",
|
||||
id: "goddess_celestial_garden",
|
||||
name: "The Celestial Garden",
|
||||
status: "locked",
|
||||
unlockBossId: null,
|
||||
unlockQuestId: null,
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A vast temple of living crystal whose facets hold every divine truth ever recorded. The scholars here do not distinguish between knowing something and becoming it.",
|
||||
emoji: "💎",
|
||||
id: "goddess_crystal_sanctum",
|
||||
name: "The Crystal Sanctum",
|
||||
status: "locked",
|
||||
unlockBossId: "heavenly_warden",
|
||||
unlockQuestId: "first_prayer",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A cathedral suspended in the astral plane, its spires reaching into realms that have no name. The choir that fills it has been singing the same hymn since before mortals learned to speak.",
|
||||
emoji: "✨",
|
||||
id: "goddess_astral_cathedral",
|
||||
name: "The Astral Cathedral",
|
||||
status: "locked",
|
||||
unlockBossId: "sanctum_keeper",
|
||||
unlockQuestId: "divine_meditation",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The fortress of the celestial host — a bastion of divine authority so immense that mortal minds instinctively refuse to estimate its size. The warriors here have never known defeat.",
|
||||
emoji: "🏰",
|
||||
id: "goddess_empyrean_citadel",
|
||||
name: "The Empyrean Citadel",
|
||||
status: "locked",
|
||||
unlockBossId: "cathedral_warden",
|
||||
unlockQuestId: "astral_revelation",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The wellspring of all creation — the point from which every soul, every star, and every prayer ultimately originates. Standing here is not comfortable. It is, however, true.",
|
||||
emoji: "🌊",
|
||||
id: "goddess_primordial_springs",
|
||||
name: "The Primordial Springs",
|
||||
status: "locked",
|
||||
unlockBossId: "citadel_guardian",
|
||||
unlockQuestId: "empyrean_ascent",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The highest reach of the divine realm — where the goddess herself resides, and where all divine law originates. Everything below this point is a reflection of what exists here. Nothing here reflects anything else.",
|
||||
emoji: "👑",
|
||||
id: "goddess_eternal_firmament",
|
||||
name: "The Eternal Firmament",
|
||||
status: "locked",
|
||||
unlockBossId: "wellspring_warden",
|
||||
unlockQuestId: "springs_blessing",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An ancient grove where divine trees have grown for longer than the firmament has existed. Their roots drink directly from the primordial springs, and their canopy touches realms that have no name. Pilgrims do not enter this grove. They are invited.",
|
||||
emoji: "🌿",
|
||||
id: "goddess_sacred_grove",
|
||||
name: "The Sacred Grove",
|
||||
status: "locked",
|
||||
unlockBossId: "the_goddess_avatar",
|
||||
unlockQuestId: "eternal_ascension",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A realm of pure, unfiltered divine radiance — light made absolute, stripped of shadow, warmth, or mercy. Those who walk here must learn to see without their eyes, because the light does not illuminate. It simply is.",
|
||||
emoji: "☀️",
|
||||
id: "goddess_luminous_expanse",
|
||||
name: "The Luminous Expanse",
|
||||
status: "locked",
|
||||
unlockBossId: "grove_sovereign",
|
||||
unlockQuestId: "grove_harmony",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The forge where the goddess shaped the first stars and the last laws of existence. The heat here is not heat — it is conviction made manifest. Every tool ever used to build a world was made here, and every one of them remembers.",
|
||||
emoji: "🔥",
|
||||
id: "goddess_heavenly_forge",
|
||||
name: "The Heavenly Forge",
|
||||
status: "locked",
|
||||
unlockBossId: "light_titan",
|
||||
unlockQuestId: "light_transcendence",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The sanctum of the divine oracle — the seat of all prophecy, all foresight, and all terrible knowledge that cannot be unlearned. The oracle does not grant visions here. The visions grant themselves, and they do not ask permission.",
|
||||
emoji: "🔮",
|
||||
id: "goddess_oracle_sanctum",
|
||||
name: "The Oracle Sanctum",
|
||||
status: "locked",
|
||||
unlockBossId: "forge_master",
|
||||
unlockQuestId: "forge_mastery",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The nesting ground of the seraphim — creatures that were never mortal, never made, and have no concept of a time before themselves. The nest is not a physical place. It is a state of being that those who reach it will never fully leave.",
|
||||
emoji: "🪶",
|
||||
id: "goddess_seraphs_nest",
|
||||
name: "The Seraph's Nest",
|
||||
status: "locked",
|
||||
unlockBossId: "grand_oracle",
|
||||
unlockQuestId: "oracle_truth",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The repository of all divine knowledge — every truth the goddess has ever uttered, every law she has written, every act of creation she has witnessed. The archive is infinite, and it is still growing. The librarians here have forgotten how long they have served.",
|
||||
emoji: "📜",
|
||||
id: "goddess_divine_archive",
|
||||
name: "The Divine Archive",
|
||||
status: "locked",
|
||||
unlockBossId: "supreme_seraph",
|
||||
unlockQuestId: "seraph_ascension",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The depths that exist beneath the divine realm — the sacred abyss from which the goddess drew the first darkness that gives light its meaning. Nothing down here is evil. It is simply the part of creation that was never meant to be seen.",
|
||||
emoji: "🕳️",
|
||||
id: "goddess_consecrated_depths",
|
||||
name: "The Consecrated Depths",
|
||||
status: "locked",
|
||||
unlockBossId: "archive_guardian",
|
||||
unlockQuestId: "archive_completion",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The point where all divine currents converge — the confluence of every law, every prayer, every act of faith ever offered. Standing here is not a spiritual experience. It is a mathematical one. You are the solution to an equation the goddess has been solving since the beginning.",
|
||||
emoji: "🌌",
|
||||
id: "goddess_astral_confluence",
|
||||
name: "The Astral Confluence",
|
||||
status: "locked",
|
||||
unlockBossId: "depths_sovereign",
|
||||
unlockQuestId: "depths_revelation",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The throne from which the goddess first looked down upon creation and chose to love it. It has not been sat in since. To approach it is to feel the weight of that choice pressing against you — the full, impossible gravity of a god who decided the universe was worth keeping.",
|
||||
emoji: "⚡",
|
||||
id: "goddess_celestial_throne",
|
||||
name: "The Celestial Throne",
|
||||
status: "locked",
|
||||
unlockBossId: "confluence_arbiter",
|
||||
unlockQuestId: "confluence_alignment",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The choir of infinite voices — every soul that has ever achieved divinity, still singing the hymn that carried them there. The music here does not end. It has never ended. It is, in the strictest sense, the sound of eternity doing what eternity does.",
|
||||
emoji: "🎵",
|
||||
id: "goddess_infinite_choir",
|
||||
name: "The Infinite Choir",
|
||||
status: "locked",
|
||||
unlockBossId: "throne_guardian",
|
||||
unlockQuestId: "throne_recognition",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The veil that separates the divine realm from whatever lies beyond it — the last border between what can be known and what the goddess herself has chosen not to look at directly. Crossing it is not forbidden. It is simply unprecedented.",
|
||||
emoji: "🌫️",
|
||||
id: "goddess_veil",
|
||||
name: "The Veil",
|
||||
status: "locked",
|
||||
unlockBossId: "choir_conductor",
|
||||
unlockQuestId: "choir_perfection",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The heart of the divine — the absolute centre of the goddess's being, the point from which all her love and all her law and all her creation ultimately originates. You have walked the entire length of her domain to stand here. She has been waiting. She is not surprised.",
|
||||
emoji: "💖",
|
||||
id: "goddess_divine_heart",
|
||||
name: "The Divine Heart",
|
||||
status: "locked",
|
||||
unlockBossId: "veil_guardian",
|
||||
unlockQuestId: "veil_crossing",
|
||||
},
|
||||
];
|
||||
@@ -9,17 +9,39 @@ import { defaultAdventurers } from "./adventurers.js";
|
||||
import { defaultBosses } from "./bosses.js";
|
||||
import { defaultEquipment } from "./equipment.js";
|
||||
import { defaultExplorations } from "./explorations.js";
|
||||
import { defaultGoddessAchievements } from "./goddessAchievements.js";
|
||||
import { defaultGoddessBosses } from "./goddessBosses.js";
|
||||
import { defaultGoddessDisciples } from "./goddessDisciples.js";
|
||||
import { defaultGoddessEquipment } from "./goddessEquipment.js";
|
||||
import { defaultGoddessExplorationAreas } from "./goddessExplorations.js";
|
||||
import { defaultGoddessQuests } from "./goddessQuests.js";
|
||||
import { defaultGoddessUpgrades } from "./goddessUpgrades.js";
|
||||
import { defaultGoddessZones } from "./goddessZones.js";
|
||||
import { defaultQuests } from "./quests.js";
|
||||
import { currentSchemaVersion } from "./schemaVersion.js";
|
||||
import { defaultUpgrades } from "./upgrades.js";
|
||||
import { defaultVampireAchievements } from "./vampireAchievements.js";
|
||||
import { defaultVampireBosses } from "./vampireBosses.js";
|
||||
import { defaultVampireEquipment } from "./vampireEquipment.js";
|
||||
import { defaultVampireExplorationAreas } from "./vampireExplorations.js";
|
||||
import { defaultVampireQuests } from "./vampireQuests.js";
|
||||
import { defaultVampireThralls } from "./vampireThralls.js";
|
||||
import { defaultVampireUpgrades } from "./vampireUpgrades.js";
|
||||
import { defaultVampireZones } from "./vampireZones.js";
|
||||
import { defaultZones } from "./zones.js";
|
||||
import type {
|
||||
ApotheosisData,
|
||||
AwakeningData,
|
||||
ConsecrationData,
|
||||
EnlightenmentData,
|
||||
ExplorationState,
|
||||
GameState,
|
||||
GoddessState,
|
||||
Player,
|
||||
PrestigeData,
|
||||
SiringData,
|
||||
TranscendenceData,
|
||||
VampireState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const initialPrestige: PrestigeData = {
|
||||
@@ -62,6 +84,125 @@ const initialExploration: ExplorationState = {
|
||||
materials: [],
|
||||
};
|
||||
|
||||
const initialConsecration: ConsecrationData = {
|
||||
count: 0,
|
||||
divinity: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
};
|
||||
|
||||
const initialEnlightenment: EnlightenmentData = {
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [],
|
||||
stardust: 0,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
stardustPrayersMultiplier: 1,
|
||||
};
|
||||
|
||||
const initialSiring: SiringData = {
|
||||
count: 0,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
};
|
||||
|
||||
const initialAwakening: AwakeningData = {
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [],
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a fresh initial goddess state for a player who has just completed their
|
||||
* first Apotheosis. All goddess content is locked until progressed through the realm.
|
||||
* @returns A clean GoddessState with all default data.
|
||||
*/
|
||||
const initialGoddessState = (): GoddessState => {
|
||||
return {
|
||||
achievements: structuredClone(defaultGoddessAchievements),
|
||||
baseClickPower: 1,
|
||||
bosses: structuredClone(defaultGoddessBosses),
|
||||
consecration: { ...initialConsecration },
|
||||
disciples: structuredClone(defaultGoddessDisciples),
|
||||
enlightenment: { ...initialEnlightenment },
|
||||
equipment: structuredClone(defaultGoddessEquipment),
|
||||
exploration: {
|
||||
areas: defaultGoddessExplorationAreas.map((area) => {
|
||||
return {
|
||||
id: area.id,
|
||||
status:
|
||||
area.zoneId === "goddess_celestial_garden"
|
||||
? ("available" as const)
|
||||
: ("locked" as const),
|
||||
};
|
||||
}),
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
lastTickAt: Date.now(),
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: structuredClone(defaultGoddessQuests),
|
||||
totalPrayersEarned: 0,
|
||||
upgrades: structuredClone(defaultGoddessUpgrades),
|
||||
zones: structuredClone(defaultGoddessZones),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a fresh initial vampire state for a player who has just achieved their
|
||||
* first Eternal Sovereignty. All vampire content is locked until progressed through the realm.
|
||||
* @returns A clean VampireState with all default data.
|
||||
*/
|
||||
const initialVampireState = (): VampireState => {
|
||||
return {
|
||||
achievements: structuredClone(defaultVampireAchievements),
|
||||
awakening: { ...initialAwakening },
|
||||
baseClickPower: 1,
|
||||
bosses: structuredClone(defaultVampireBosses),
|
||||
equipment: structuredClone(defaultVampireEquipment),
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: {
|
||||
areas: defaultVampireExplorationAreas.map((area) => {
|
||||
return {
|
||||
id: area.id,
|
||||
status:
|
||||
area.zoneId === "vampire_haunted_catacombs"
|
||||
? ("available" as const)
|
||||
: ("locked" as const),
|
||||
};
|
||||
}),
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
lastTickAt: Date.now(),
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: structuredClone(defaultVampireQuests),
|
||||
siring: { ...initialSiring },
|
||||
thralls: structuredClone(defaultVampireThralls),
|
||||
totalBloodEarned: 0,
|
||||
upgrades: structuredClone(defaultVampireUpgrades),
|
||||
zones: structuredClone(defaultVampireZones),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an initial game state for a new player.
|
||||
* @param player - The player data from Discord OAuth.
|
||||
@@ -105,4 +246,9 @@ const initialGameState = (
|
||||
};
|
||||
};
|
||||
|
||||
export { initialExploration, initialGameState };
|
||||
export {
|
||||
initialExploration,
|
||||
initialGameState,
|
||||
initialGoddessState,
|
||||
initialVampireState,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
|
||||
import type { VampireAchievement } from "@elysium/types";
|
||||
|
||||
export const defaultVampireAchievements: Array<VampireAchievement> = [
|
||||
// ── Total Blood Earned milestones ─────────────────────────────────────────
|
||||
{
|
||||
condition: { amount: 1000, type: "totalBloodEarned" },
|
||||
description: "Spill the first thousand drops. Every hunt starts here.",
|
||||
icon: "🩸",
|
||||
id: "blood_thousand",
|
||||
name: "First Blood",
|
||||
reward: { ichor: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 10_000, type: "totalBloodEarned" },
|
||||
description: "Ten thousand drops flow through your domain — the hunger becomes familiar.",
|
||||
icon: "🔴",
|
||||
id: "blood_ten_thousand",
|
||||
name: "Crimson Tide",
|
||||
reward: { ichor: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 100_000, type: "totalBloodEarned" },
|
||||
description: "The rivers of blood run deep. Your domain is truly cursed now.",
|
||||
icon: "🌊",
|
||||
id: "blood_hundred_thousand",
|
||||
name: "Rivers of Crimson",
|
||||
reward: { ichor: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1_000_000, type: "totalBloodEarned" },
|
||||
description: "One million drops — the world bends its neck toward you.",
|
||||
icon: "👑",
|
||||
id: "blood_million",
|
||||
name: "Lord of the Hunt",
|
||||
reward: { ichor: 300, soulShards: 1 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 10_000_000, type: "totalBloodEarned" },
|
||||
description: "Ten million — a tide of crimson that nations would drown in.",
|
||||
icon: "🌙",
|
||||
id: "blood_ten_million",
|
||||
name: "Midnight Sovereign",
|
||||
reward: { ichor: 750, soulShards: 3 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 100_000_000, type: "totalBloodEarned" },
|
||||
description: "A hundred million drops. The darkness remembers your name.",
|
||||
icon: "⚫",
|
||||
id: "blood_hundred_million",
|
||||
name: "The Dark Eternal",
|
||||
reward: { ichor: 2000, soulShards: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1_000_000_000, type: "totalBloodEarned" },
|
||||
description: "One billion drops — a millennium of predation made manifest.",
|
||||
icon: "🕳️",
|
||||
id: "blood_billion",
|
||||
name: "Ancient Hunger",
|
||||
reward: { ichor: 5000, soulShards: 25 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// ── Vampire Bosses Defeated ───────────────────────────────────────────────
|
||||
{
|
||||
condition: { amount: 1, type: "vampireBossesDefeated" },
|
||||
description: "The first challenger falls. This is only the beginning.",
|
||||
icon: "⚔️",
|
||||
id: "boss_first",
|
||||
name: "First Kill",
|
||||
reward: { ichor: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 5, type: "vampireBossesDefeated" },
|
||||
description: "Five enemies put to rest. The name 'hunter' begins to fit.",
|
||||
icon: "🗡️",
|
||||
id: "boss_five",
|
||||
name: "Hunter",
|
||||
reward: { ichor: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 20, type: "vampireBossesDefeated" },
|
||||
description: "Twenty victories — the battlefield has become familiar ground.",
|
||||
icon: "⚰️",
|
||||
id: "boss_twenty",
|
||||
name: "Battle Hardened",
|
||||
reward: { ichor: 150 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 50, type: "vampireBossesDefeated" },
|
||||
description: "Fifty enemies broken. Even ancient horrors pause at your approach.",
|
||||
icon: "🩸",
|
||||
id: "boss_fifty",
|
||||
name: "Dread Predator",
|
||||
reward: { ichor: 400, soulShards: 2 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 72, type: "vampireBossesDefeated" },
|
||||
description: "All 72 vampire realm bosses have been defeated. The realm trembles.",
|
||||
icon: "🌑",
|
||||
id: "boss_all",
|
||||
name: "The Darkness Made Flesh",
|
||||
reward: { ichor: 1000, soulShards: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// ── Vampire Quests Completed ──────────────────────────────────────────────
|
||||
{
|
||||
condition: { amount: 1, type: "vampireQuestsCompleted" },
|
||||
description: "The first mission of many. The domain stirs in response.",
|
||||
icon: "📜",
|
||||
id: "quest_first",
|
||||
name: "Night Errand",
|
||||
reward: { ichor: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 10, type: "vampireQuestsCompleted" },
|
||||
description: "Ten missions carried out without failure. Reliability is its own power.",
|
||||
icon: "📋",
|
||||
id: "quest_ten",
|
||||
name: "Reliable Shadow",
|
||||
reward: { ichor: 30 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 30, type: "vampireQuestsCompleted" },
|
||||
description: "Thirty missions completed — your thralls have grown seasoned in the dark.",
|
||||
icon: "🕯️",
|
||||
id: "quest_thirty",
|
||||
name: "Veteran of the Night",
|
||||
reward: { ichor: 100 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 60, type: "vampireQuestsCompleted" },
|
||||
description: "Sixty missions. Your command reaches across the entire realm.",
|
||||
icon: "🌑",
|
||||
id: "quest_sixty",
|
||||
name: "Shadow Commander",
|
||||
reward: { ichor: 300, soulShards: 2 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 90, type: "vampireQuestsCompleted" },
|
||||
description: "Every quest in the vampire realm completed. The darkness answers your call.",
|
||||
icon: "👁️",
|
||||
id: "quest_all",
|
||||
name: "Voice of the Abyss",
|
||||
reward: { ichor: 800, soulShards: 8 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// ── Total Thrall Count ────────────────────────────────────────────────────
|
||||
{
|
||||
condition: { amount: 10, type: "thrallTotal" },
|
||||
description: "Ten souls bound to your will. The domain begins to take shape.",
|
||||
icon: "🧟",
|
||||
id: "thralls_ten",
|
||||
name: "First Flock",
|
||||
reward: { ichor: 10 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 50, type: "thrallTotal" },
|
||||
description: "Fifty thralls bound — a proper hunting force. The night fears you.",
|
||||
icon: "🧛",
|
||||
id: "thralls_fifty",
|
||||
name: "Pack Leader",
|
||||
reward: { ichor: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 150, type: "thrallTotal" },
|
||||
description: "One hundred and fifty thralls — a blood-drunk army in your service.",
|
||||
icon: "⚔️",
|
||||
id: "thralls_hundred_fifty",
|
||||
name: "Blood Army",
|
||||
reward: { ichor: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 400, type: "thrallTotal" },
|
||||
description: "Four hundred thralls — a force that could drown a city in shadow.",
|
||||
icon: "🌑",
|
||||
id: "thralls_four_hundred",
|
||||
name: "Tide of Darkness",
|
||||
reward: { ichor: 600, soulShards: 3 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 1000, type: "thrallTotal" },
|
||||
description: "One thousand bound souls. The vampire realm bows to your dominion.",
|
||||
icon: "👑",
|
||||
id: "thralls_thousand",
|
||||
name: "Sovereign of Thralls",
|
||||
reward: { ichor: 2000, soulShards: 15 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// ── Siring Count ──────────────────────────────────────────────────────────
|
||||
{
|
||||
condition: { amount: 1, type: "siringCount" },
|
||||
description: "The first siring. A new bloodline begins.",
|
||||
icon: "🌕",
|
||||
id: "siring_first",
|
||||
name: "The Sire",
|
||||
reward: { ichor: 50 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 3, type: "siringCount" },
|
||||
description: "Three sirings — the bloodline strengthens with each reset.",
|
||||
icon: "🌑",
|
||||
id: "siring_three",
|
||||
name: "Bloodline Builder",
|
||||
reward: { ichor: 200 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 7, type: "siringCount" },
|
||||
description: "Seven sirings — the ichor flows with the weight of accumulated power.",
|
||||
icon: "🩸",
|
||||
id: "siring_seven",
|
||||
name: "Ancient Sire",
|
||||
reward: { ichor: 500, soulShards: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 15, type: "siringCount" },
|
||||
description: "Fifteen sirings. Your bloodline is one of the oldest and most powerful.",
|
||||
icon: "⚫",
|
||||
id: "siring_fifteen",
|
||||
name: "Eternal Bloodline",
|
||||
reward: { ichor: 1500, soulShards: 15 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// ── Vampire Equipment Owned ───────────────────────────────────────────────
|
||||
{
|
||||
condition: { amount: 1, type: "vampireEquipmentOwned" },
|
||||
description: "The first relic of darkness in your possession. The collection begins.",
|
||||
icon: "💎",
|
||||
id: "equipment_first",
|
||||
name: "First Relic",
|
||||
reward: { ichor: 15 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 5, type: "vampireEquipmentOwned" },
|
||||
description: "Five relics — a proper arsenal for a vampire of note.",
|
||||
icon: "🗡️",
|
||||
id: "equipment_five",
|
||||
name: "Armed and Dangerous",
|
||||
reward: { ichor: 75 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 15, type: "vampireEquipmentOwned" },
|
||||
description: "Fifteen relics — your collection is the envy of lesser vampires.",
|
||||
icon: "⚰️",
|
||||
id: "equipment_fifteen",
|
||||
name: "Collector of Darkness",
|
||||
reward: { ichor: 300 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 30, type: "vampireEquipmentOwned" },
|
||||
description: "Thirty relics — your power radiates through every piece you carry.",
|
||||
icon: "👑",
|
||||
id: "equipment_thirty",
|
||||
name: "Master of Relics",
|
||||
reward: { ichor: 750, soulShards: 5 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
condition: { amount: 53, type: "vampireEquipmentOwned" },
|
||||
description: "All 53 vampire relics in your possession. The darkness is complete.",
|
||||
icon: "🌑",
|
||||
id: "equipment_all",
|
||||
name: "The Complete Darkness",
|
||||
reward: { ichor: 2000, soulShards: 20 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { AwakeningUpgrade } from "@elysium/types";
|
||||
|
||||
export const defaultVampireAwakeningUpgrades: Array<AwakeningUpgrade> = [
|
||||
// ── Blood income (from soul shards) ──────────────────────────────────────
|
||||
{
|
||||
category: "blood",
|
||||
cost: 10,
|
||||
description: "The awakened soul's hunger amplifies all blood income. All blood/s ×1.5.",
|
||||
id: "awakening_blood_1",
|
||||
multiplier: 1.5,
|
||||
name: "Soul Hunger I",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
cost: 50,
|
||||
description: "A second awakening sharpens the soul's drive to consume. All blood/s ×2.",
|
||||
id: "awakening_blood_2",
|
||||
multiplier: 2,
|
||||
name: "Soul Hunger II",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
cost: 200,
|
||||
description: "The awakened soul transcends ordinary hunger — all blood income triples. All blood/s ×3.",
|
||||
id: "awakening_blood_3",
|
||||
multiplier: 3,
|
||||
name: "Soul Hunger III",
|
||||
},
|
||||
// ── Combat power ──────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "combat",
|
||||
cost: 15,
|
||||
description: "The awakened soul's predatory edge carries through every thrall. All thrall combat power ×1.5.",
|
||||
id: "awakening_combat_1",
|
||||
multiplier: 1.5,
|
||||
name: "Awakened Predator I",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 75,
|
||||
description: "Soul shards resonate with battle instinct — combat power doubles. All thrall combat power ×2.",
|
||||
id: "awakening_combat_2",
|
||||
multiplier: 2,
|
||||
name: "Awakened Predator II",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 300,
|
||||
description: "Apex awakened combat mastery triples every thrall's fighting power. All thrall combat power ×3.",
|
||||
id: "awakening_combat_3",
|
||||
multiplier: 3,
|
||||
name: "Awakened Predator III",
|
||||
},
|
||||
// ── Siring threshold ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "siring_threshold",
|
||||
cost: 30,
|
||||
description: "Soul shards carry the memory of past sirings — the threshold lowers by 15%.",
|
||||
id: "awakening_threshold_1",
|
||||
multiplier: 0.85,
|
||||
name: "Soul Memory I",
|
||||
},
|
||||
{
|
||||
category: "siring_threshold",
|
||||
cost: 120,
|
||||
description: "The awakened soul remembers every siring — the threshold drops by a further 20%.",
|
||||
id: "awakening_threshold_2",
|
||||
multiplier: 0.8,
|
||||
name: "Soul Memory II",
|
||||
},
|
||||
{
|
||||
category: "siring_threshold",
|
||||
cost: 480,
|
||||
description: "Perfect soul memory collapses the siring threshold to a fraction of its original. Threshold ×0.7.",
|
||||
id: "awakening_threshold_3",
|
||||
multiplier: 0.7,
|
||||
name: "Soul Memory III",
|
||||
},
|
||||
// ── Siring ichor yield ────────────────────────────────────────────────────
|
||||
{
|
||||
category: "siring_ichor",
|
||||
cost: 25,
|
||||
description: "Soul shards amplify the ichor extracted during each siring. Ichor per siring ×1.5.",
|
||||
id: "awakening_siring_ichor_1",
|
||||
multiplier: 1.5,
|
||||
name: "Ichor Resonance I",
|
||||
},
|
||||
{
|
||||
category: "siring_ichor",
|
||||
cost: 100,
|
||||
description: "The resonance deepens — siring yields twice the ichor. Ichor per siring ×2.",
|
||||
id: "awakening_siring_ichor_2",
|
||||
multiplier: 2,
|
||||
name: "Ichor Resonance II",
|
||||
},
|
||||
{
|
||||
category: "siring_ichor",
|
||||
cost: 400,
|
||||
description: "Peak resonance — each siring now yields three times the ichor. Ichor per siring ×3.",
|
||||
id: "awakening_siring_ichor_3",
|
||||
multiplier: 3,
|
||||
name: "Ichor Resonance III",
|
||||
},
|
||||
// ── Soul shards meta ──────────────────────────────────────────────────────
|
||||
{
|
||||
category: "soulshards_meta",
|
||||
cost: 60,
|
||||
description: "The soul refines itself — future awakenings yield 50% more soul shards.",
|
||||
id: "awakening_meta_1",
|
||||
multiplier: 1.5,
|
||||
name: "Soul Refinement I",
|
||||
},
|
||||
{
|
||||
category: "soulshards_meta",
|
||||
cost: 250,
|
||||
description: "The awakened soul's self-improvement compounds — soul shard yields double.",
|
||||
id: "awakening_meta_2",
|
||||
multiplier: 2,
|
||||
name: "Soul Refinement II",
|
||||
},
|
||||
{
|
||||
category: "soulshards_meta",
|
||||
cost: 1000,
|
||||
description: "The apex of soul refinement — all future awakenings yield three times the soul shards.",
|
||||
id: "awakening_meta_3",
|
||||
multiplier: 3,
|
||||
name: "Soul Refinement III",
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { CraftingRecipe } from "@elysium/types";
|
||||
|
||||
/*
|
||||
* Note: In vampire context, "gold_income" bonus maps to blood income,
|
||||
* "essence_income" maps to ichor income, and "combat_power" maps to thrall combat power.
|
||||
*/
|
||||
export const defaultVampireCraftingRecipes: Array<CraftingRecipe> = [
|
||||
// ── Haunted Catacombs ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.1,
|
||||
},
|
||||
description: "Bone dust boiled with grave essence produces a thick extract that resonates with the catacombs' ancient hunger. Those who consume it briefly see in total darkness.",
|
||||
id: "bone_dust_extract",
|
||||
name: "Bone Dust Extract",
|
||||
requiredMaterials: [
|
||||
{ materialId: "bone_dust", quantity: 3 },
|
||||
{ materialId: "grave_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.1,
|
||||
},
|
||||
description: "Catacomb ash worked into a paste with grave essence, then applied to weapons before battle. The ash remembers every fight these tunnels have witnessed.",
|
||||
id: "catacomb_tonic",
|
||||
name: "Catacomb Tonic",
|
||||
requiredMaterials: [
|
||||
{ materialId: "catacomb_ash", quantity: 2 },
|
||||
{ materialId: "grave_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
// ── Blood Mire ────────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.1,
|
||||
},
|
||||
description: "Mire sludge filtered through blood moss produces a dense poultice that, when applied correctly, amplifies the feeding reflex across all thralls in range.",
|
||||
id: "mire_poultice",
|
||||
name: "Mire Poultice",
|
||||
requiredMaterials: [
|
||||
{ materialId: "mire_sludge", quantity: 3 },
|
||||
{ materialId: "blood_moss", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.1,
|
||||
},
|
||||
description: "Blood moss steeped in crimson reed sap makes a foul-smelling brew that is nevertheless extremely popular before fights — it dulls pain and sharpens reflex.",
|
||||
id: "blood_moss_brew",
|
||||
name: "Blood Moss Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "blood_moss", quantity: 3 },
|
||||
{ materialId: "crimson_reed", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
// ── Obsidian Keep ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.15,
|
||||
},
|
||||
description: "Obsidian chips ground into a paste with iron shavings make an abrasive compound used to hone weapons. The resulting edge carries a trace of the Keep's blood magic.",
|
||||
id: "obsidian_edge",
|
||||
name: "Obsidian Edge Compound",
|
||||
requiredMaterials: [
|
||||
{ materialId: "obsidian_chip", quantity: 3 },
|
||||
{ materialId: "iron_shaving", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.15,
|
||||
},
|
||||
description: "Keep mortar dissolved into a slurry with iron shavings creates a sealant that, when applied to the feeding chambers, prevents blood loss between hunts.",
|
||||
id: "keep_mortar_mix",
|
||||
name: "Keep Mortar Mix",
|
||||
requiredMaterials: [
|
||||
{ materialId: "keep_mortar", quantity: 1 },
|
||||
{ materialId: "iron_shaving", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
// ── Crimson Citadel ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.15,
|
||||
},
|
||||
description: "Citadel stone powder mixed with blood bronze filings creates a seal that, when pressed into the architecture of a feeding ground, amplifies the blood yield of the space.",
|
||||
id: "citadel_seal",
|
||||
name: "Citadel Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "citadel_stone", quantity: 2 },
|
||||
{ materialId: "blood_bronze", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.2,
|
||||
},
|
||||
description: "Crimson silk wrapped around weapons before battle absorbs moonlight during the process. Thralls armed with these wrapped weapons fight with unusual composure.",
|
||||
id: "crimson_silk_wrap",
|
||||
name: "Crimson Silk Wrap",
|
||||
requiredMaterials: [
|
||||
{ materialId: "crimson_silk", quantity: 1 },
|
||||
{ materialId: "blood_bronze", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
// ── Shadow Court ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.2,
|
||||
},
|
||||
description: "Shadow thread woven into a net and suspended over feeding grounds creates an obscuring field that encourages prey to walk toward the hunter.",
|
||||
id: "shadow_thread_weave",
|
||||
name: "Shadow Thread Weave",
|
||||
requiredMaterials: [
|
||||
{ materialId: "shadow_thread", quantity: 4 },
|
||||
{ materialId: "whisper_ink", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.2,
|
||||
},
|
||||
description: "Whisper ink recorded with secrets about the ichor trade and sealed with court wax. Reading it reveals techniques for extracting greater ichor yield during the siring rite.",
|
||||
id: "whisper_ink_tome",
|
||||
name: "Whisper Ink Tome",
|
||||
requiredMaterials: [
|
||||
{ materialId: "whisper_ink", quantity: 2 },
|
||||
{ materialId: "court_wax", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
// ── Plague Ossuary ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.2,
|
||||
},
|
||||
description: "Plague ash worked into a paste with ossuary resin and applied to thrall weapons before battle. The pestilence that lingers in the ash makes opponents hesitate.",
|
||||
id: "plague_ash_remedy",
|
||||
name: "Plague Ash Weapon Coat",
|
||||
requiredMaterials: [
|
||||
{ materialId: "plague_ash", quantity: 3 },
|
||||
{ materialId: "ossuary_resin", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.2,
|
||||
},
|
||||
description: "Infected bone ground down and mixed with ossuary resin creates a sealant for feeding vessels that prevents spoilage and stretches each harvest considerably further.",
|
||||
id: "ossuary_resin_coat",
|
||||
name: "Ossuary Preservation Coat",
|
||||
requiredMaterials: [
|
||||
{ materialId: "infected_bone", quantity: 2 },
|
||||
{ materialId: "ossuary_resin", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
// ── Ashen Wastes ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.25,
|
||||
},
|
||||
description: "Volatile compounds produced when volcanic ash and cinder crystals are combined make an excellent weapon coating — the resulting strike burns in ways cold steel cannot.",
|
||||
id: "volcanic_ash_bomb",
|
||||
name: "Volcanic Ash Bomb",
|
||||
requiredMaterials: [
|
||||
{ materialId: "volcanic_ash", quantity: 3 },
|
||||
{ materialId: "cinder_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.25,
|
||||
},
|
||||
description: "Ashen cloth soaked in volcanic ash produces a wrapping for the body that insulates against heat and disperses the blood-scent of the wearer, making them harder to detect.",
|
||||
id: "ashen_cloth_wrap",
|
||||
name: "Ashen Cloth Wrapping",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ashen_cloth", quantity: 2 },
|
||||
{ materialId: "volcanic_ash", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
// ── The Iron Gaol ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.3,
|
||||
},
|
||||
description: "Iron rivets combined with a length of chain link produce a weapon wrap that adds both weight and containment glyph resonance to every strike.",
|
||||
id: "iron_chain_shackle",
|
||||
name: "Iron Chain Shackle",
|
||||
requiredMaterials: [
|
||||
{ materialId: "iron_rivet", quantity: 3 },
|
||||
{ materialId: "chain_link", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.3,
|
||||
},
|
||||
description: "Gaol stone ground and packed with iron rivets into a floor-sealing mortar. The despair absorbed into the stone makes the feeding ground more effective at producing passive blood.",
|
||||
id: "gaol_stone_mortar",
|
||||
name: "Gaol Stone Mortar",
|
||||
requiredMaterials: [
|
||||
{ materialId: "gaol_stone", quantity: 1 },
|
||||
{ materialId: "iron_rivet", quantity: 4 },
|
||||
],
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
// ── Veilborn Hollow ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.3,
|
||||
},
|
||||
description: "Veil thread woven through the structure of a feeding ground creates small tears in the boundary between worlds. Blood that passes through these tears is somehow more potent.",
|
||||
id: "veil_thread_weave",
|
||||
name: "Veil Thread Weave",
|
||||
requiredMaterials: [
|
||||
{ materialId: "veil_thread", quantity: 4 },
|
||||
{ materialId: "hollow_crystal", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.3,
|
||||
},
|
||||
description: "Phantom dust mixed with hollow crystal powder creates a potion that, when consumed, allows thralls to partially phase during the first moments of a fight — before the enemy can react.",
|
||||
id: "phantom_dust_potion",
|
||||
name: "Phantom Dust Potion",
|
||||
requiredMaterials: [
|
||||
{ materialId: "phantom_dust", quantity: 1 },
|
||||
{ materialId: "hollow_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
// ── Moonless Moor ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.35,
|
||||
},
|
||||
description: "Moor peat rendered with fog essence produces a slow-burning fuel that warms the feeding ground whilst simultaneously obscuring its location from outsiders.",
|
||||
id: "moor_peat_tonic",
|
||||
name: "Moor Peat Fuel",
|
||||
requiredMaterials: [
|
||||
{ materialId: "moor_peat", quantity: 3 },
|
||||
{ materialId: "fog_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.35,
|
||||
},
|
||||
description: "A brew of night bloom petals steeped in fog essence produces a drink that heightens the predator's senses to impossible levels for a brief, battle-winning window.",
|
||||
id: "fog_essence_brew",
|
||||
name: "Fog Essence Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "fog_essence", quantity: 3 },
|
||||
{ materialId: "night_bloom", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
// ── The Sunken Crypt ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.4,
|
||||
},
|
||||
description: "Sunken stone coated in drowned silk becomes a permanent feeding vessel — the silk prevents evaporation and the stone's porous structure allows remarkable volume.",
|
||||
id: "sunken_stone_seal",
|
||||
name: "Sunken Stone Vessel",
|
||||
requiredMaterials: [
|
||||
{ materialId: "sunken_stone", quantity: 2 },
|
||||
{ materialId: "drowned_silk", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.4,
|
||||
},
|
||||
description: "Deep amber dissolved in a solvent derived from sunken stone — the resulting extract amplifies ichor yield during siring by resonating with the amber's preserved fragments.",
|
||||
id: "deep_amber_extract",
|
||||
name: "Deep Amber Extract",
|
||||
requiredMaterials: [
|
||||
{ materialId: "deep_amber", quantity: 1 },
|
||||
{ materialId: "sunken_stone", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
// ── Desecrated Sanctum ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.4,
|
||||
},
|
||||
description: "Defiled marble carved into a totem and inscribed with dark incense smoke. The desecrated memory in the marble makes it an effective focus for battle rites.",
|
||||
id: "defiled_marble_totem",
|
||||
name: "Defiled Marble Totem",
|
||||
requiredMaterials: [
|
||||
{ materialId: "defiled_marble", quantity: 3 },
|
||||
{ materialId: "dark_incense", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.4,
|
||||
},
|
||||
description: "Dark incense burned in a vessel made of sanctum glass creates a ritual smoke that saturates a feeding ground with the hunger of the desecrated, amplifying all blood yield.",
|
||||
id: "dark_incense_ritual",
|
||||
name: "Dark Incense Ritual",
|
||||
requiredMaterials: [
|
||||
{ materialId: "dark_incense", quantity: 2 },
|
||||
{ materialId: "sanctum_glass", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
// ── Carrion Peaks ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.45,
|
||||
},
|
||||
description: "Carrion bone worked into a talisman and inlaid with peak crystal shards creates a focus for the predator's instinct — thralls carrying it fight with the certainty of the high hunt.",
|
||||
id: "carrion_bone_talisman",
|
||||
name: "Carrion Bone Talisman",
|
||||
requiredMaterials: [
|
||||
{ materialId: "carrion_bone", quantity: 3 },
|
||||
{ materialId: "peak_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.45,
|
||||
},
|
||||
description: "Blood obsidian edges ground from peak crystal and bonded to carrion bone handles — weapons that are as much ritual object as instrument of predation.",
|
||||
id: "blood_obsidian_edge",
|
||||
name: "Blood Obsidian Edge",
|
||||
requiredMaterials: [
|
||||
{ materialId: "blood_obsidian", quantity: 1 },
|
||||
{ materialId: "peak_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
// ── The Bloodspire ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.5,
|
||||
},
|
||||
description: "Spire stone carved into a seal and inscribed with blood crystal resonance. When placed at the centre of a feeding ground, it draws blood from the surrounding area passively.",
|
||||
id: "spire_stone_seal",
|
||||
name: "Spire Stone Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "spire_stone", quantity: 3 },
|
||||
{ materialId: "blood_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.5,
|
||||
},
|
||||
description: "Ancient gore dissolved in a blood crystal suspension — a highly potent ichor catalyst that resonates with the Spire's pre-existing blood magic to enhance ichor production dramatically.",
|
||||
id: "blood_crystal_extract",
|
||||
name: "Blood Crystal Extract",
|
||||
requiredMaterials: [
|
||||
{ materialId: "blood_crystal", quantity: 3 },
|
||||
{ materialId: "ancient_gore", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
// ── Shroud of Eternity ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.5,
|
||||
},
|
||||
description: "Eternity thread woven through a feeding space creates a temporal fold that causes each feeding to last slightly longer than it should. The blood never quite finishes flowing.",
|
||||
id: "eternity_thread_weave",
|
||||
name: "Eternity Thread Weave",
|
||||
requiredMaterials: [
|
||||
{ materialId: "eternity_thread", quantity: 4 },
|
||||
{ materialId: "shroud_dust", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.5,
|
||||
},
|
||||
description: "Timeless amber dissolved and reset in a shroud dust medium creates a capsule that, when broken before battle, briefly accelerates the thrall's perception of time.",
|
||||
id: "timeless_amber_brew",
|
||||
name: "Timeless Amber Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "timeless_amber", quantity: 1 },
|
||||
{ materialId: "shroud_dust", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
// ── The Abyssal Vault ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.6,
|
||||
},
|
||||
description: "Abyssal stone inscribed with void crystal dust creates a seal that, when placed in a feeding ground, creates a pocket of absolute silence — prey within it cannot call for help.",
|
||||
id: "abyssal_stone_seal",
|
||||
name: "Abyssal Stone Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "abyssal_stone", quantity: 3 },
|
||||
{ materialId: "void_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "combat_power",
|
||||
value: 1.6,
|
||||
},
|
||||
description: "Void crystal ground and bonded to vault iron makes a weapon component that strikes with the force of absolute inevitability — opponents don't question whether they will fall, only when.",
|
||||
id: "void_crystal_totem",
|
||||
name: "Void Crystal Weapon Core",
|
||||
requiredMaterials: [
|
||||
{ materialId: "void_crystal", quantity: 2 },
|
||||
{ materialId: "vault_iron", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
// ── Court of Whispers ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.6,
|
||||
},
|
||||
description: "Whisper parchment inscribed with silent ink contains the distilled knowledge of the Court's ichor trade. Reading it aloud triggers a resonance that permanently enhances ichor yield.",
|
||||
id: "whisper_parchment_tome",
|
||||
name: "Whisper Parchment Tome",
|
||||
requiredMaterials: [
|
||||
{ materialId: "whisper_parchment", quantity: 2 },
|
||||
{ materialId: "silent_ink", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.6,
|
||||
},
|
||||
description: "Silent ink mixed with court crystal powder creates a medium for a feeding ritual that cannot be detected by anyone not already participating — the blood flows and no one outside knows.",
|
||||
id: "silent_ink_ritual",
|
||||
name: "Silent Ink Ritual",
|
||||
requiredMaterials: [
|
||||
{ materialId: "silent_ink", quantity: 1 },
|
||||
{ materialId: "court_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
// ── The Eternal Abyss ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: {
|
||||
type: "gold_income",
|
||||
value: 1.75,
|
||||
},
|
||||
description: "Void essence rendered in an eternal crystal medium produces a brew of impossible potency. Something about the combination makes every subsequent feeding feel like the first — and the first is always the best.",
|
||||
id: "void_essence_brew",
|
||||
name: "Void Essence Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "void_essence", quantity: 3 },
|
||||
{ materialId: "eternal_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
bonus: {
|
||||
type: "essence_income",
|
||||
value: 1.75,
|
||||
},
|
||||
description: "An eternal crystal seal made with primordial ash creates a focus for ichor resonance that has no upper bound — the older the vampire who sets it, the more it yields.",
|
||||
id: "eternal_crystal_seal",
|
||||
name: "Eternal Crystal Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "eternal_crystal", quantity: 3 },
|
||||
{ materialId: "primordial_ash", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { VampireEquipment } from "@elysium/types";
|
||||
|
||||
export const defaultVampireEquipment: Array<VampireEquipment> = [
|
||||
// ── Fangs — Common ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.08 },
|
||||
cost: { blood: 200, ichor: 0, soulShards: 0 },
|
||||
description: "A fragment of a broken fang, still sharp enough to count. Not impressive, but real.",
|
||||
equipped: false,
|
||||
id: "shard_fang",
|
||||
name: "Shard Fang",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "catacombs_hunter",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.1 },
|
||||
cost: { blood: 500, ichor: 0, soulShards: 0 },
|
||||
description: "A fang steeped in old blood until the metal absorbed the flavour. Every hunt feels more intentional wearing this.",
|
||||
equipped: false,
|
||||
id: "blood_fang",
|
||||
name: "Blood Fang",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "catacombs_hunter",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.08, combatMultiplier: 1.05 },
|
||||
cost: { blood: 1500, ichor: 0, soulShards: 0 },
|
||||
description: "Ground from a warlord's tooth, this fang has seen three centuries of campaigns. Its edge is still perfect.",
|
||||
equipped: false,
|
||||
id: "war_fang",
|
||||
name: "War Fang",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "blood_stalker",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.12 },
|
||||
cost: { blood: 4000, ichor: 0, soulShards: 0 },
|
||||
description: "Carved from volcanic obsidian, this fang channels the Keep's stored blood magic into every hunt.",
|
||||
equipped: false,
|
||||
id: "obsidian_fang",
|
||||
name: "Obsidian Fang",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "blood_stalker",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.15 },
|
||||
cost: { blood: 12_000, ichor: 2, soulShards: 0 },
|
||||
description: "A fang cut from the Citadel's bone-archive. It thrums with the accumulated authority of centuries of dynasty.",
|
||||
equipped: false,
|
||||
id: "crimson_fang",
|
||||
name: "Crimson Fang",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "crimson_regent",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.1, combatMultiplier: 1.08 },
|
||||
cost: { blood: 35_000, ichor: 5, soulShards: 0 },
|
||||
description: "This fang was honed in shadow — literally. The edge holds a darkness that does not catch light.",
|
||||
equipped: false,
|
||||
id: "shadow_fang",
|
||||
name: "Shadow Fang",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "crimson_regent",
|
||||
type: "fang",
|
||||
},
|
||||
// ── Fangs — Rare ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.18, combatMultiplier: 1.1 },
|
||||
description: "Cultivated in a plague environment over a decade. The pestilence in the material amplifies every hunt's yield.",
|
||||
equipped: false,
|
||||
id: "plague_fang",
|
||||
name: "Plague Fang",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "plague_bringer",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.2, combatMultiplier: 1.12 },
|
||||
description: "Hardened in the Ashen Wastes' perpetual fires. The material does not conduct heat. It conducts hunger.",
|
||||
equipped: false,
|
||||
id: "ashen_fang",
|
||||
name: "Ashen Fang",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "plague_bringer",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.15, combatMultiplier: 1.15 },
|
||||
description: "Forged in the Iron Gaol's containment forges. Each containment glyph etched into the metal amplifies combat efficiency.",
|
||||
equipped: false,
|
||||
id: "iron_fang",
|
||||
name: "Iron Fang",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "iron_jailer",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.22, combatMultiplier: 1.1 },
|
||||
description: "A fang woven from veil-thread and bonded crystal. It phases slightly during each hunt, allowing it to feed from multiple layers of existence simultaneously.",
|
||||
equipped: false,
|
||||
id: "veil_fang",
|
||||
name: "Veil Fang",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "iron_jailer",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.25, combatMultiplier: 1.12 },
|
||||
description: "Shaped in absolute darkness and attuned to bloodscent rather than sight. Works twice as well in places where eyes are useless.",
|
||||
equipped: false,
|
||||
id: "moonless_fang",
|
||||
name: "Moonless Fang",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "moonlit_predator",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.28, combatMultiplier: 1.1 },
|
||||
description: "Recovered from a sealed chamber in the sunken depths. The pressure has made it denser than anything forged at surface level.",
|
||||
equipped: false,
|
||||
id: "sunken_fang",
|
||||
name: "Sunken Fang",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "moonlit_predator",
|
||||
type: "fang",
|
||||
},
|
||||
// ── Fangs — Epic ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.35, combatMultiplier: 1.2 },
|
||||
description: "Salvaged from the Desecrated Sanctum's altar chamber. The sacred energy has not dissipated — it has inverted. This fang feeds on faith.",
|
||||
equipped: false,
|
||||
id: "sanctum_fang",
|
||||
name: "Sanctum Fang",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "sanctum_desecrator",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.4, combatMultiplier: 1.25 },
|
||||
description: "Cut from the remains of a great peak-predator. The thing this came from hunted elder vampires for sport. The fang remembers.",
|
||||
equipped: false,
|
||||
id: "carrion_fang",
|
||||
name: "Carrion Fang",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "sanctum_desecrator",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.45, combatMultiplier: 1.3 },
|
||||
description: "Grown from the Bloodspire's crystallised wall material — it is, in a literal sense, a fang of the building itself. The Spire does not stop growing, and neither does this fang's appetite.",
|
||||
equipped: false,
|
||||
id: "spire_fang",
|
||||
name: "Spire Fang",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "eternal_tyrant",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.5, combatMultiplier: 1.3 },
|
||||
description: "A fang shaped from eternity thread and hardened in the Shroud's temporal compression. Strikes made with it land slightly before the target expects them.",
|
||||
equipped: false,
|
||||
id: "shroud_fang",
|
||||
name: "Shroud Fang",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "eternal_tyrant",
|
||||
type: "fang",
|
||||
},
|
||||
// ── Fangs — Legendary ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.6, combatMultiplier: 1.4, ichorMultiplier: 1.2 },
|
||||
description: "Forged in the Vault's deepest chamber from void crystal and vault iron. It exists in a state of permanent readiness that does not require sharpening, maintenance, or sleep.",
|
||||
equipped: false,
|
||||
id: "abyss_fang",
|
||||
name: "Abyss Fang",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "void_sovereign",
|
||||
type: "fang",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.75, combatMultiplier: 1.5, ichorMultiplier: 1.3 },
|
||||
description: "A fang that came from the Eternal Abyss — or rather, from something that has always been there, watching. It feeds on everything and remembers every meal.",
|
||||
equipped: false,
|
||||
id: "eternal_fang",
|
||||
name: "Eternal Fang",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "void_sovereign",
|
||||
type: "fang",
|
||||
},
|
||||
// ── Shrouds — Common ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { combatMultiplier: 1.08 },
|
||||
cost: { blood: 200, ichor: 0, soulShards: 0 },
|
||||
description: "A worn burial cloth repurposed as armour. Humble, patched, and surprisingly effective at stopping things that should not be stopped.",
|
||||
equipped: false,
|
||||
id: "tattered_shroud",
|
||||
name: "Tattered Shroud",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "catacombs_hunter",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.05, combatMultiplier: 1.08 },
|
||||
cost: { blood: 500, ichor: 0, soulShards: 0 },
|
||||
description: "A cloak soaked in old blood until the fabric absorbed properties that new cloth simply does not have.",
|
||||
equipped: false,
|
||||
id: "blood_shroud",
|
||||
name: "Blood Shroud",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "catacombs_hunter",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { combatMultiplier: 1.1 },
|
||||
cost: { blood: 1500, ichor: 0, soulShards: 0 },
|
||||
description: "Cut from volcanic obsidian-fibre and stitched with iron thread. It does not stop blows — it returns them.",
|
||||
equipped: false,
|
||||
id: "obsidian_shroud",
|
||||
name: "Obsidian Shroud",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "blood_stalker",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.05, combatMultiplier: 1.12 },
|
||||
cost: { blood: 4000, ichor: 0, soulShards: 0 },
|
||||
description: "Woven from threads dyed in the Citadel's blood-tanneries. The crimson never fades. Neither does the authority it implies.",
|
||||
equipped: false,
|
||||
id: "crimson_shroud",
|
||||
name: "Crimson Shroud",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "blood_stalker",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.08, combatMultiplier: 1.12 },
|
||||
cost: { blood: 12_000, ichor: 2, soulShards: 0 },
|
||||
description: "Woven entirely from shadow thread. A skilled observer would say it moves before the wearer does — a less skilled observer would simply not notice the wearer at all.",
|
||||
equipped: false,
|
||||
id: "shadow_shroud",
|
||||
name: "Shadow Shroud",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "crimson_regent",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.08, combatMultiplier: 1.15 },
|
||||
cost: { blood: 35_000, ichor: 5, soulShards: 0 },
|
||||
description: "Treated with plague compounds until the fabric has developed its own kind of patience. Wearing it keeps opponents at arm's length, quite literally.",
|
||||
equipped: false,
|
||||
id: "plague_shroud",
|
||||
name: "Plague Shroud",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "crimson_regent",
|
||||
type: "shroud",
|
||||
},
|
||||
// ── Shrouds — Rare ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.1, combatMultiplier: 1.2 },
|
||||
description: "Woven from ashen cloth and cinder-crystal thread. It does not burn. Opponents who try to burn the wearer discover this too late.",
|
||||
equipped: false,
|
||||
id: "ashen_shroud",
|
||||
name: "Ashen Shroud",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "plague_bringer",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.1, combatMultiplier: 1.22 },
|
||||
description: "Woven from chain-link thread recovered from the Gaol's deepest holding cells. Each link carries a containment glyph that now works against those who attack the wearer.",
|
||||
equipped: false,
|
||||
id: "iron_shroud",
|
||||
name: "Iron Shroud",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "plague_bringer",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.15, combatMultiplier: 1.2 },
|
||||
description: "Woven from veil thread and phantom-dust infused silk. It flickers between solid and not quite solid, making it very difficult to land a decisive blow against the wearer.",
|
||||
equipped: false,
|
||||
id: "veil_shroud",
|
||||
name: "Veil Shroud",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "iron_jailer",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.12, combatMultiplier: 1.25 },
|
||||
description: "Made from moor peat-treated fabric, this shroud absorbs and dissipates kinetic energy in ways that no one has been able to explain satisfactorily.",
|
||||
equipped: false,
|
||||
id: "moor_shroud",
|
||||
name: "Moor Shroud",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "iron_jailer",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.12, combatMultiplier: 1.28 },
|
||||
description: "Woven from drowned silk and sunken stone fibre. The pressure of the depths has been incorporated into every thread — this garment is under constant compression.",
|
||||
equipped: false,
|
||||
id: "sunken_shroud",
|
||||
name: "Sunken Shroud",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "moonlit_predator",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.15, combatMultiplier: 1.3 },
|
||||
description: "Salvaged from the Sanctum's vestry — garments that were once sacred and have since been repurposed, without apology, into something entirely different.",
|
||||
equipped: false,
|
||||
id: "sanctum_shroud",
|
||||
name: "Sanctum Shroud",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "moonlit_predator",
|
||||
type: "shroud",
|
||||
},
|
||||
// ── Shrouds — Epic ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.2, combatMultiplier: 1.35 },
|
||||
description: "Woven from carrion bone fragments and peak crystal thread. This garment was assembled at altitude, in conditions where most vampires would not survive, by a craftsperson who clearly had opinions about structural integrity.",
|
||||
equipped: false,
|
||||
id: "carrion_shroud",
|
||||
name: "Carrion Shroud",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "sanctum_desecrator",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.25, combatMultiplier: 1.4 },
|
||||
description: "The Bloodspire's architects would recognise their own work in this garment. It was made from the same crystallised blood-material as the building, and it follows the same impossible logic.",
|
||||
equipped: false,
|
||||
id: "spire_shroud",
|
||||
name: "Spire Shroud",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "sanctum_desecrator",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.3, combatMultiplier: 1.45 },
|
||||
description: "Woven from eternity thread and shroud dust, this garment exists slightly out of sync with the present moment. Blows land where the wearer was, not where the wearer is.",
|
||||
equipped: false,
|
||||
id: "eternity_shroud",
|
||||
name: "Eternity Shroud",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "eternal_tyrant",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.3, combatMultiplier: 1.5, ichorMultiplier: 1.1 },
|
||||
description: "The garment of someone who has been to the edge of the known world and found the edge wanting. It absorbs damage from an existential weariness that precedes the arrival of the blow.",
|
||||
equipped: false,
|
||||
id: "abyss_shroud",
|
||||
name: "Abyss Shroud",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "eternal_tyrant",
|
||||
type: "shroud",
|
||||
},
|
||||
// ── Shrouds — Legendary ───────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.4, combatMultiplier: 1.55, ichorMultiplier: 1.2 },
|
||||
description: "Woven from the Court's most closely held thread — shadow and whisper and silence all at once. To wear this is to become genuinely difficult to locate, let alone fight.",
|
||||
equipped: false,
|
||||
id: "whisper_shroud",
|
||||
name: "Whisper Shroud",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "void_sovereign",
|
||||
type: "shroud",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.5, combatMultiplier: 1.7, ichorMultiplier: 1.3 },
|
||||
description: "A shroud woven from the fabric of the Eternal Abyss — the void itself, shaped into something that can be worn. It does not protect the wearer. It convinces the universe not to bother attacking.",
|
||||
equipped: false,
|
||||
id: "eternal_shroud",
|
||||
name: "Eternal Shroud",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "void_sovereign",
|
||||
type: "shroud",
|
||||
},
|
||||
// ── Talismans — Common ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.06, combatMultiplier: 1.06 },
|
||||
cost: { blood: 200, ichor: 0, soulShards: 0 },
|
||||
description: "A talisman carved from catacomb bone. Every vampire starts somewhere. Most of them start here.",
|
||||
equipped: false,
|
||||
id: "bone_talisman",
|
||||
name: "Bone Talisman",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "catacombs_hunter",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.1 },
|
||||
cost: { blood: 500, ichor: 0, soulShards: 0 },
|
||||
description: "A talisman sealed with old blood until the material has become as much blood as bone. It resonates with the hunt.",
|
||||
equipped: false,
|
||||
id: "blood_talisman",
|
||||
name: "Blood Talisman",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "blood_stalker",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.1, combatMultiplier: 1.05 },
|
||||
cost: { blood: 1500, ichor: 0, soulShards: 0 },
|
||||
description: "Carved from obsidian chip and iron shaving bonded together. The resulting piece is heavier than it looks and radiates a faint warmth.",
|
||||
equipped: false,
|
||||
id: "obsidian_talisman",
|
||||
name: "Obsidian Talisman",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "blood_stalker",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.12, combatMultiplier: 1.06 },
|
||||
cost: { blood: 4000, ichor: 0, soulShards: 0 },
|
||||
description: "A talisman carrying the Citadel's seal — the weight of centuries of dynasty compressed into a small, heavy object.",
|
||||
equipped: false,
|
||||
id: "crimson_talisman",
|
||||
name: "Crimson Talisman",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "crimson_regent",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.1, combatMultiplier: 1.1 },
|
||||
cost: { blood: 12_000, ichor: 2, soulShards: 0 },
|
||||
description: "A talisman sealed with court wax and whisper ink. It carries a secret, but will not tell you what it is.",
|
||||
equipped: false,
|
||||
id: "shadow_talisman",
|
||||
name: "Shadow Talisman",
|
||||
owned: false,
|
||||
rarity: "common",
|
||||
setId: "crimson_regent",
|
||||
type: "talisman",
|
||||
},
|
||||
// ── Talismans — Rare ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.15, combatMultiplier: 1.12 },
|
||||
description: "A talisman cultivated in the Ossuary's most contaminated wing. It smells wrong. It works very well.",
|
||||
equipped: false,
|
||||
id: "plague_talisman",
|
||||
name: "Plague Talisman",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "plague_bringer",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.18, combatMultiplier: 1.12 },
|
||||
description: "A talisman of volcanic ash and cinder crystal, formed in the Wastes' fire. It does not cool down.",
|
||||
equipped: false,
|
||||
id: "ashen_talisman",
|
||||
name: "Ashen Talisman",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "plague_bringer",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.15, combatMultiplier: 1.18 },
|
||||
description: "Forged from a chain link and an iron rivet, inscribed with every containment glyph used in the Gaol. The talisman contains the wearer's enemies.",
|
||||
equipped: false,
|
||||
id: "iron_talisman",
|
||||
name: "Iron Talisman",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "iron_jailer",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.2, combatMultiplier: 1.15 },
|
||||
description: "A hollow crystal sealed with phantom dust and veil thread. Looking into it, you see the same location you are standing in, but empty — and something looking back.",
|
||||
equipped: false,
|
||||
id: "veil_talisman",
|
||||
name: "Veil Talisman",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "iron_jailer",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.22, combatMultiplier: 1.15 },
|
||||
description: "Made from compressed moor peat and night bloom extract. The resulting piece does not emit any light whatsoever, which is somehow more alarming than if it glowed.",
|
||||
equipped: false,
|
||||
id: "moor_talisman",
|
||||
name: "Moor Talisman",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "moonlit_predator",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.25, combatMultiplier: 1.18 },
|
||||
description: "A deep amber talisman recovered from the Sunken Crypt. The thing preserved inside it is still moving. Very slowly.",
|
||||
equipped: false,
|
||||
id: "sunken_talisman",
|
||||
name: "Sunken Talisman",
|
||||
owned: false,
|
||||
rarity: "rare",
|
||||
setId: "moonlit_predator",
|
||||
type: "talisman",
|
||||
},
|
||||
// ── Talismans — Epic ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.3, combatMultiplier: 1.22, ichorMultiplier: 1.1 },
|
||||
description: "A talisman cut from defiled marble and sealed with dark incense. It carries the Sanctum's inverted purpose — it does not protect the wearer from the dark. It amplifies it.",
|
||||
equipped: false,
|
||||
id: "sanctum_talisman",
|
||||
name: "Sanctum Talisman",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "sanctum_desecrator",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.35, combatMultiplier: 1.25, ichorMultiplier: 1.1 },
|
||||
description: "Blood obsidian carved into a talisman at the summit of Carrion Peaks. Something about altitude and blood obsidian together creates a resonance that neither material possesses alone.",
|
||||
equipped: false,
|
||||
id: "carrion_talisman",
|
||||
name: "Carrion Talisman",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "sanctum_desecrator",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.4, combatMultiplier: 1.3, ichorMultiplier: 1.15 },
|
||||
description: "A blood crystal and spire stone talisman that pulses in rhythm with the Bloodspire's beating heart — if the Spire has one. It seems, somehow, likely.",
|
||||
equipped: false,
|
||||
id: "spire_talisman",
|
||||
name: "Spire Talisman",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "eternal_tyrant",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.45, combatMultiplier: 1.3, ichorMultiplier: 1.2 },
|
||||
description: "A timeless amber talisman from the Shroud — the moment sealed inside it is not identifiable because it is from outside of time. The talisman itself has given up waiting for it to end.",
|
||||
equipped: false,
|
||||
id: "eternity_talisman",
|
||||
name: "Eternity Talisman",
|
||||
owned: false,
|
||||
rarity: "epic",
|
||||
setId: "eternal_tyrant",
|
||||
type: "talisman",
|
||||
},
|
||||
// ── Talismans — Legendary ─────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.55, combatMultiplier: 1.4, ichorMultiplier: 1.3 },
|
||||
description: "Forged from void crystal in absolute vacuum. The talisman does not interact with the physical world on a philosophical level. On a practical level, it amplifies everything.",
|
||||
equipped: false,
|
||||
id: "abyss_talisman",
|
||||
name: "Abyss Talisman",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "void_sovereign",
|
||||
type: "talisman",
|
||||
},
|
||||
{
|
||||
bonus: { bloodMultiplier: 1.6, combatMultiplier: 1.45, ichorMultiplier: 1.35 },
|
||||
description: "Forged from silent ink and court crystal in the Court of Whispers' deepest sanctum. It carries every secret the Court has ever kept, and it will not tell you any of them. But it will use them on your behalf.",
|
||||
equipped: false,
|
||||
id: "whisper_talisman",
|
||||
name: "Whisper Talisman",
|
||||
owned: false,
|
||||
rarity: "legendary",
|
||||
setId: "void_sovereign",
|
||||
type: "talisman",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { VampireEquipmentSet } from "@elysium/types";
|
||||
|
||||
export const defaultVampireEquipmentSets: Array<VampireEquipmentSet> = [
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { bloodMultiplier: 1.15 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { combatMultiplier: 1.1 },
|
||||
},
|
||||
description: "The starter relics of a newly awakened vampire — mismatched, imperfect, and entirely adequate for the catacombs. Every legend begins with gear this humble.",
|
||||
id: "catacombs_hunter",
|
||||
name: "Catacomb Hunter",
|
||||
pieces: [ "shard_fang", "blood_fang", "tattered_shroud", "blood_shroud", "bone_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { bloodMultiplier: 1.2 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { combatMultiplier: 1.15 },
|
||||
},
|
||||
description: "Equipment forged in the fires of early conquest — in the mire's depths and the obsidian corridors. Functional, battle-tested, and smelling faintly of old blood.",
|
||||
id: "blood_stalker",
|
||||
name: "Blood Stalker",
|
||||
pieces: [ "war_fang", "obsidian_fang", "obsidian_shroud", "crimson_shroud", "blood_talisman", "obsidian_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { bloodMultiplier: 1.25 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { ichorMultiplier: 1.2 },
|
||||
},
|
||||
description: "The arms of a vampire who has learned to move through courts as easily as through darkness. These pieces announce arrival before the wearer does.",
|
||||
id: "crimson_regent",
|
||||
name: "Crimson Regent",
|
||||
pieces: [ "crimson_fang", "shadow_fang", "shadow_shroud", "plague_shroud", "crimson_talisman", "shadow_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { combatMultiplier: 1.3 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { bloodMultiplier: 1.2 },
|
||||
},
|
||||
description: "Equipment sourced from the most dangerous zones of the middle realm — places where even other vampires refuse to hunt. The gear carries the memory of every survival it enabled.",
|
||||
id: "plague_bringer",
|
||||
name: "Plague Bringer",
|
||||
pieces: [ "plague_fang", "ashen_fang", "ashen_shroud", "iron_shroud", "plague_talisman", "ashen_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { combatMultiplier: 1.35 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { bloodMultiplier: 1.25 },
|
||||
},
|
||||
description: "The arms of a vampire who has broken open prisons and walked through veils. These pieces have seen the inside of places most vampires only hear about in old stories.",
|
||||
id: "iron_jailer",
|
||||
name: "Iron Jailer",
|
||||
pieces: [ "iron_fang", "veil_fang", "veil_shroud", "moor_shroud", "iron_talisman", "veil_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { bloodMultiplier: 1.3 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { combatMultiplier: 1.3 },
|
||||
},
|
||||
description: "Equipment forged in the moonless reaches and recovered from sunken depths. The pieces were each retrieved at significant cost, which they repay with significant interest.",
|
||||
id: "moonlit_predator",
|
||||
name: "Moonlit Predator",
|
||||
pieces: [ "moonless_fang", "sunken_fang", "sunken_shroud", "sanctum_shroud", "moor_talisman", "sunken_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { combatMultiplier: 1.4 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { ichorMultiplier: 1.3 },
|
||||
},
|
||||
description: "The regalia of desecration and apex predation — taken from places where even the concept of sanctuary has been dismantled. Each piece is a monument to the absence of mercy.",
|
||||
id: "sanctum_desecrator",
|
||||
name: "Sanctum Desecrator",
|
||||
pieces: [ "sanctum_fang", "carrion_fang", "carrion_shroud", "spire_shroud", "sanctum_talisman", "carrion_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { bloodMultiplier: 1.4 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { combatMultiplier: 1.45 },
|
||||
},
|
||||
description: "The arms of a vampire who has conquered both time and blood — relics of the Bloodspire and the Shroud. These pieces are older than the zones they came from.",
|
||||
id: "eternal_tyrant",
|
||||
name: "Eternal Tyrant",
|
||||
pieces: [ "spire_fang", "shroud_fang", "eternity_shroud", "abyss_shroud", "spire_talisman", "eternity_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
2: { ichorMultiplier: 1.5 },
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- numeric keys
|
||||
3: { bloodMultiplier: 1.5 },
|
||||
},
|
||||
description: "The complete arms of a vampire who has stood at the edge of the void and returned. These pieces no longer belong to any zone. They belong to whatever you have become.",
|
||||
id: "void_sovereign",
|
||||
name: "Void Sovereign",
|
||||
pieces: [ "abyss_fang", "eternal_fang", "whisper_shroud", "eternal_shroud", "abyss_talisman", "whisper_talisman" ],
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { Material } from "@elysium/types";
|
||||
|
||||
export const defaultVampireMaterials: Array<Material> = [
|
||||
// ── Haunted Catacombs ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Dust ground from the bones of vampires who rose, fought, and fell in these tunnels. It carries the faintest trace of their hunger.",
|
||||
id: "bone_dust",
|
||||
name: "Bone Dust",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
description: "The residue of a life that chose darkness. It pools in the lowest reaches of the catacombs, slowly thickening over centuries.",
|
||||
id: "grave_essence",
|
||||
name: "Grave Essence",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
description: "Fine grey ash that accumulates wherever the undead have spent long centuries in stasis. Not quite earth, not quite flesh. Something in between.",
|
||||
id: "catacomb_ash",
|
||||
name: "Catacomb Ash",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
// ── Blood Mire ────────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Thick, crimson-tinted mud drawn from the deepest channels of the mire. It does not dry out. It does not wash off. It does not forget.",
|
||||
id: "mire_sludge",
|
||||
name: "Mire Sludge",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
description: "A flat-bladed moss that grows exclusively on surfaces saturated with old blood. Herbalists who have tried to study it have stopped trying.",
|
||||
id: "blood_moss",
|
||||
name: "Blood Moss",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
description: "A hollow reed that grows where the mire runs deepest, with a faint red tint throughout its stem. If cut, it bleeds.",
|
||||
id: "crimson_reed",
|
||||
name: "Crimson Reed",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
// ── Obsidian Keep ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A sharp shard of the volcanic stone used to build the Keep. Each chip holds a fragment of the blood magic sealed into the walls during construction.",
|
||||
id: "obsidian_chip",
|
||||
name: "Obsidian Chip",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
description: "Iron filings scraped from the Keep's ancient weapons and restraints. Cold to the touch, even near fire. Even near blood.",
|
||||
id: "iron_shaving",
|
||||
name: "Iron Shaving",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
description: "The bonding agent used to seal the Keep's stones together — mixed with ash, iron powder, and something that should have been left out. It cures permanently.",
|
||||
id: "keep_mortar",
|
||||
name: "Keep Mortar",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
// ── Crimson Citadel ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Polished stone quarried from the Citadel's foundations. Every piece has been touched by so many vampire lords that it practically radiates authority.",
|
||||
id: "citadel_stone",
|
||||
name: "Citadel Stone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
description: "An alloy forged in blood-tempered furnaces, harder than ordinary bronze and carrying a subtle crimson sheen. The Citadel's armourers guard the recipe.",
|
||||
id: "blood_bronze",
|
||||
name: "Blood Bronze",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
description: "Silk woven from threads that were dyed with diluted vampire essence and then dried for a century. The fabric changes colour subtly in moonlight.",
|
||||
id: "crimson_silk",
|
||||
name: "Crimson Silk",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
// ── Shadow Court ──────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Thread spun from shadow itself — a process that requires both technical skill and a complete willingness to let go of daylight. Woven garments made from it are essentially invisible.",
|
||||
id: "shadow_thread",
|
||||
name: "Shadow Thread",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
description: "Ink prepared from whispered secrets — literally. The Court's scribes capture spoken confidences in a phial and render them down into pigment. Every document written with it is, technically, a confession.",
|
||||
id: "whisper_ink",
|
||||
name: "Whisper Ink",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
description: "A heavy black wax used to seal the Court's most sensitive correspondences. Once set, it can only be broken by the vampire who pressed it. Forgeries have been attempted. None have survived the attempt.",
|
||||
id: "court_wax",
|
||||
name: "Court Wax",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
// ── Plague Ossuary ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Grey ash remaining after the Ossuary's plague fires have consumed what they were fed. Mildly corrosive. Handle with care, and perhaps with gloves.",
|
||||
id: "plague_ash",
|
||||
name: "Plague Ash",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
description: "Bone harvested from vampires taken by the Ossuary's endemic pestilence. The infection did not die with them. It merely changed hosts.",
|
||||
id: "infected_bone",
|
||||
name: "Infected Bone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
description: "A thick, pale resin that oozes from the Ossuary's walls in places where plague-magic has been concentrated longest. It hardens into a surprisingly effective sealant.",
|
||||
id: "ossuary_resin",
|
||||
name: "Ossuary Resin",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
// ── Ashen Wastes ──────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Ash falling perpetually from the sky above the Wastes — the remains of a war that never finished burning. It is surprisingly good for preservation.",
|
||||
id: "volcanic_ash",
|
||||
name: "Volcanic Ash",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed where magical fire burned long enough to change the nature of the ground beneath it. They retain heat indefinitely.",
|
||||
id: "cinder_crystal",
|
||||
name: "Cinder Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
description: "Cloth woven in the Wastes and saturated with ash over generations. It does not burn. It does not stain. It does not soften.",
|
||||
id: "ashen_cloth",
|
||||
name: "Ashen Cloth",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
// ── The Iron Gaol ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The iron pins and fasteners used throughout the Gaol's construction. Each one is inscribed with a containment glyph. They do not loosen with time.",
|
||||
id: "iron_rivet",
|
||||
name: "Iron Rivet",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
description: "A single link from one of the Gaol's binding chains. Strong enough to hold an elder vampire. Heavier than it looks. Always cold.",
|
||||
id: "chain_link",
|
||||
name: "Chain Link",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
description: "The stone quarried to build the Gaol's cells — dense, cold, and impregnated with centuries of accumulated despair. It absorbs magic rather than conducting it.",
|
||||
id: "gaol_stone",
|
||||
name: "Gaol Stone",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
// ── Veilborn Hollow ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Thread spun from the Veil itself — a substance that exists partially in the shadow-realm and partially in the real world. Objects made with it are somewhat difficult to focus on.",
|
||||
id: "veil_thread",
|
||||
name: "Veil Thread",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed at the point where the Veil touches the physical world — each one containing a frozen moment from the shadow-realm. Looking into them for too long is inadvisable.",
|
||||
id: "hollow_crystal",
|
||||
name: "Hollow Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
description: "The physical residue of a spirit that has fully crossed the Veil — fine, weightless particles that drift upward rather than falling. They make excellent catalyst material.",
|
||||
id: "phantom_dust",
|
||||
name: "Phantom Dust",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
// ── Moonless Moor ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Dark, saturated peat from the deepest parts of the Moor. It burns slowly and produces a smoke that seems to attract predators rather than repel them.",
|
||||
id: "moor_peat",
|
||||
name: "Moor Peat",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
description: "The Moor's perpetual fog condensed and collected. It does not evaporate in warmth, which is how you know it is not ordinary fog.",
|
||||
id: "fog_essence",
|
||||
name: "Fog Essence",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
description: "A rare plant that flowers only in absolute darkness. Its bloom is bioluminescent, which is the only way anyone has ever found one.",
|
||||
id: "night_bloom",
|
||||
name: "Night Bloom",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
// ── The Sunken Crypt ──────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Stone recovered from the deepest chambers — porous, dark, and reeking of salt water and old blood. Everything sealed in these chambers has soaked into it.",
|
||||
id: "sunken_stone",
|
||||
name: "Sunken Stone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
description: "Silk preserved in the crypt's submerged chambers for so long that it has taken on properties of neither cloth nor water. Soft, cold, and permanent.",
|
||||
id: "drowned_silk",
|
||||
name: "Drowned Silk",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
description: "Amber formed from resin that seeped into the crypt's lower levels and hardened around fragments of vampire essence. Each piece traps something that was still alive when it solidified.",
|
||||
id: "deep_amber",
|
||||
name: "Deep Amber",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
// ── Desecrated Sanctum ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Polished marble torn from the Sanctum's original construction — its sacred inscriptions scraped away, but the stone remembers. It resists dark enchantments more than it should.",
|
||||
id: "defiled_marble",
|
||||
name: "Defiled Marble",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Fragments of the Sanctum's original windows — glass that was made to hold sacred light. Now it holds nothing, and the emptiness feels intentional.",
|
||||
id: "sanctum_glass",
|
||||
name: "Sanctum Glass",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Incense burned in rituals designed to invert the Sanctum's sacred purpose. The smoke still rises the wrong way — downward.",
|
||||
id: "dark_incense",
|
||||
name: "Dark Incense",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
// ── Carrion Peaks ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Bone fragments from creatures that have lived and died on the Peaks for generations — stripped clean, bleached white, and still faintly warm.",
|
||||
id: "carrion_bone",
|
||||
name: "Carrion Bone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
description: "Crystals found only in the Peaks' highest reaches — formed by a convergence of altitude, cold, and old hunting magic. Sharp enough to cut through standard vampire hide.",
|
||||
id: "peak_crystal",
|
||||
name: "Peak Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
description: "Obsidian that has absorbed vampire blood through direct contact during battles at the Peaks' summits. The two materials have merged into something neither purely mineral nor purely vital.",
|
||||
id: "blood_obsidian",
|
||||
name: "Blood Obsidian",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
// ── The Bloodspire ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The crystallised blood that forms the Spire's outer walls. Dense as stone, warm as fresh blood. It grows back if broken off.",
|
||||
id: "spire_stone",
|
||||
name: "Spire Stone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
description: "Crystals grown at the Spire's interior junctions — formed where the architecture deliberately folds blood-magic into the structure of the building. Each one pulses faintly.",
|
||||
id: "blood_crystal",
|
||||
name: "Blood Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
description: "Residue harvested from the Spire's deepest chambers — a thick, dark ichor that predates even the building that contains it. It does not react to any known magical reagent. It reacts to intent.",
|
||||
id: "ancient_gore",
|
||||
name: "Ancient Gore",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
// ── Shroud of Eternity ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Thread woven from the Shroud's temporal fabric — each strand has already lived through several possible futures and settled on none of them. Things made from it feel slightly out of phase.",
|
||||
id: "eternity_thread",
|
||||
name: "Eternity Thread",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
description: "Dust collected from the Shroud's boundary regions — the physical remnant of time that moved too slowly and eventually stopped. It drifts in currents that do not correspond to any wind.",
|
||||
id: "shroud_dust",
|
||||
name: "Shroud Dust",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
description: "Amber formed in the Shroud's temporal anomalies — trapping moments that exist outside of normal time. The things preserved inside are still happening.",
|
||||
id: "timeless_amber",
|
||||
name: "Timeless Amber",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
// ── The Abyssal Vault ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Stone from the Vault's outer walls — quarried from a place that exists below the normal underground, in a layer of the world that does not have a name.",
|
||||
id: "abyssal_stone",
|
||||
name: "Abyssal Stone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed in absolute void — places within the Vault where nothing has ever existed. Their interiors are genuinely empty in a way that normal empty space is not.",
|
||||
id: "void_crystal",
|
||||
name: "Void Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
description: "Iron refined in the Vault's deepest forges — as cold as absolute zero, as hard as any known material. It does not rust. It does not bend. It does not forgive.",
|
||||
id: "vault_iron",
|
||||
name: "Vault Iron",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
// ── Court of Whispers ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Parchment prepared from the skin of failed spies — a Court tradition that serves both as record and deterrent. Every document written on it contains the memory of its source.",
|
||||
id: "whisper_parchment",
|
||||
name: "Whisper Parchment",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed where the Court's intelligence network has concentrated the most secrets in the least space. They vibrate at a frequency only very old vampires can hear.",
|
||||
id: "court_crystal",
|
||||
name: "Court Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
description: "Ink rendered from secrets so dangerous that even writing them down is a risk. The Court uses it for its most sensitive documents. The ink knows what it says.",
|
||||
id: "silent_ink",
|
||||
name: "Silent Ink",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
// ── The Eternal Abyss ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The primal substance that exists at the bottom of the vampire world — neither matter nor energy, but something that predates both. Handling it requires understanding it, which may be impossible.",
|
||||
id: "void_essence",
|
||||
name: "Void Essence",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed at the intersection of the vampire realm and whatever exists beyond it. Each one contains a fragment of something genuinely ancient — older than the first vampire, older than the concept of blood.",
|
||||
id: "eternal_crystal",
|
||||
name: "Eternal Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
description: "Ash from things that existed before the concept of fire. It does not look like ordinary ash. It does not behave like ordinary ash. It simply is.",
|
||||
id: "primordial_ash",
|
||||
name: "Primordial Ash",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { SiringUpgrade } from "@elysium/types";
|
||||
|
||||
export const defaultVampireSiringUpgrades: Array<SiringUpgrade> = [
|
||||
// ── Blood income ─────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "blood",
|
||||
description: "The first drop of ichor transforms your blood instinct. All blood/s ×1.25.",
|
||||
ichorCost: 5,
|
||||
id: "siring_blood_1",
|
||||
multiplier: 1.25,
|
||||
name: "Ichor Awakening I",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "Sustained siring deepens the hunger that drives every thrall. All blood/s ×1.5.",
|
||||
ichorCost: 15,
|
||||
id: "siring_blood_2",
|
||||
multiplier: 1.5,
|
||||
name: "Ichor Awakening II",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "Each siring sharpens your command over the blood flow. All blood/s ×2.",
|
||||
ichorCost: 40,
|
||||
id: "siring_blood_3",
|
||||
multiplier: 2,
|
||||
name: "Ichor Awakening III",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "The bloodline resonates across every hunt and harvest. All blood/s ×5.",
|
||||
ichorCost: 120,
|
||||
id: "siring_blood_4",
|
||||
multiplier: 5,
|
||||
name: "Ichor Awakening IV",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "Total mastery of the siring-blood bond multiplies all income tenfold. All blood/s ×10.",
|
||||
ichorCost: 350,
|
||||
id: "siring_blood_5",
|
||||
multiplier: 10,
|
||||
name: "Ichor Awakening V",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "The accumulated weight of many sirings floods every vein in your domain. All blood/s ×25.",
|
||||
ichorCost: 1000,
|
||||
id: "siring_blood_6",
|
||||
multiplier: 25,
|
||||
name: "Ichor Awakening VI",
|
||||
},
|
||||
// ── Thrall strength ───────────────────────────────────────────────────────
|
||||
{
|
||||
category: "thralls",
|
||||
description: "Sired blood flows through your thralls, amplifying their natural power. All thrall blood/s ×1.5.",
|
||||
ichorCost: 8,
|
||||
id: "siring_thralls_1",
|
||||
multiplier: 1.5,
|
||||
name: "Bloodline Bond I",
|
||||
},
|
||||
{
|
||||
category: "thralls",
|
||||
description: "The bond between sire and thrall deepens, multiplying their output. All thrall blood/s ×2.",
|
||||
ichorCost: 25,
|
||||
id: "siring_thralls_2",
|
||||
multiplier: 2,
|
||||
name: "Bloodline Bond II",
|
||||
},
|
||||
{
|
||||
category: "thralls",
|
||||
description: "Every thrall in your bloodline fights and works with supernatural coordination. All thrall blood/s ×3.",
|
||||
ichorCost: 75,
|
||||
id: "siring_thralls_3",
|
||||
multiplier: 3,
|
||||
name: "Bloodline Bond III",
|
||||
},
|
||||
{
|
||||
category: "thralls",
|
||||
description: "The siring bond reaches its apex — every thrall becomes an extension of your will. All thrall blood/s ×5.",
|
||||
ichorCost: 200,
|
||||
id: "siring_thralls_4",
|
||||
multiplier: 5,
|
||||
name: "Bloodline Bond IV",
|
||||
},
|
||||
// ── Combat power ──────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "combat",
|
||||
description: "Sired instincts sharpen your thralls' fighting edge. All thrall combat power ×1.5.",
|
||||
ichorCost: 12,
|
||||
id: "siring_combat_1",
|
||||
multiplier: 1.5,
|
||||
name: "Dark Predator I",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "The predator's cunning passed through siring doubles your combat effectiveness. All thrall combat power ×2.",
|
||||
ichorCost: 45,
|
||||
id: "siring_combat_2",
|
||||
multiplier: 2,
|
||||
name: "Dark Predator II",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Centuries of accumulated battle memory flood into your line. All thrall combat power ×3.",
|
||||
ichorCost: 150,
|
||||
id: "siring_combat_3",
|
||||
multiplier: 3,
|
||||
name: "Dark Predator III",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "The ultimate expression of vampire combat mastery through the siring ritual. All thrall combat power ×5.",
|
||||
ichorCost: 500,
|
||||
id: "siring_combat_4",
|
||||
multiplier: 5,
|
||||
name: "Dark Predator IV",
|
||||
},
|
||||
// ── Ichor yield ───────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "ichor",
|
||||
description: "The ritual of siring becomes more efficient, preserving greater ichor yield. Ichor per siring ×1.5.",
|
||||
ichorCost: 20,
|
||||
id: "siring_ichor_1",
|
||||
multiplier: 1.5,
|
||||
name: "Refined Siring I",
|
||||
},
|
||||
{
|
||||
category: "ichor",
|
||||
description: "Deeper siring mastery extracts twice the ichor from every reset. Ichor per siring ×2.",
|
||||
ichorCost: 60,
|
||||
id: "siring_ichor_2",
|
||||
multiplier: 2,
|
||||
name: "Refined Siring II",
|
||||
},
|
||||
{
|
||||
category: "ichor",
|
||||
description: "The siring ritual refined to its peak triples the ichor yield at reset. Ichor per siring ×3.",
|
||||
ichorCost: 180,
|
||||
id: "siring_ichor_3",
|
||||
multiplier: 3,
|
||||
name: "Refined Siring III",
|
||||
},
|
||||
// ── Utility ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
category: "utility",
|
||||
description: "Siring instinct reduces the blood threshold needed for the next siring by 10%.",
|
||||
ichorCost: 30,
|
||||
id: "siring_threshold_1",
|
||||
multiplier: 0.9,
|
||||
name: "Blood Efficiency I",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Further refinement lowers the siring threshold by an additional 15%.",
|
||||
ichorCost: 90,
|
||||
id: "siring_threshold_2",
|
||||
multiplier: 0.85,
|
||||
name: "Blood Efficiency II",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "The siring rite becomes almost effortless — threshold reduced by another 20%.",
|
||||
ichorCost: 270,
|
||||
id: "siring_threshold_3",
|
||||
multiplier: 0.8,
|
||||
name: "Blood Efficiency III",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Peak efficiency — the blood threshold for siring is reduced by a further 25%.",
|
||||
ichorCost: 800,
|
||||
id: "siring_threshold_4",
|
||||
multiplier: 0.75,
|
||||
name: "Blood Efficiency IV",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "An ancient siring ritual accelerates the arrival of the first thrall class after each siring.",
|
||||
ichorCost: 50,
|
||||
id: "siring_quick_start_1",
|
||||
multiplier: 1.5,
|
||||
name: "Quick Fledglings I",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "The first fledglings after siring arrive faster and work harder for longer.",
|
||||
ichorCost: 150,
|
||||
id: "siring_quick_start_2",
|
||||
multiplier: 2,
|
||||
name: "Quick Fledglings II",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Your siring bloodline passively preserves a fraction of your thrall efficiency across resets.",
|
||||
ichorCost: 250,
|
||||
id: "siring_persistence_1",
|
||||
multiplier: 1.25,
|
||||
name: "Bloodline Memory I",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "The bloodline memory deepens — even more efficiency is preserved through each siring.",
|
||||
ichorCost: 750,
|
||||
id: "siring_persistence_2",
|
||||
multiplier: 1.5,
|
||||
name: "Bloodline Memory II",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
import type { VampireThrall } from "@elysium/types";
|
||||
|
||||
export const defaultVampireThralls: Array<VampireThrall> = [
|
||||
// ── Fledgling (5 tiers) ───────────────────────────────────────────────────
|
||||
{
|
||||
baseCost: 1,
|
||||
bloodPerSecond: 0.1,
|
||||
class: "fledgling",
|
||||
combatPower: 1,
|
||||
count: 0,
|
||||
ichorPerSecond: 0,
|
||||
id: "fledgling_1",
|
||||
level: 1,
|
||||
name: "Fledgling",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
baseCost: 8,
|
||||
bloodPerSecond: 0.5,
|
||||
class: "fledgling",
|
||||
combatPower: 3,
|
||||
count: 0,
|
||||
ichorPerSecond: 0,
|
||||
id: "fledgling_2",
|
||||
level: 2,
|
||||
name: "Hungry Fledgling",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 80,
|
||||
bloodPerSecond: 2,
|
||||
class: "fledgling",
|
||||
combatPower: 8,
|
||||
count: 0,
|
||||
ichorPerSecond: 0.01,
|
||||
id: "fledgling_3",
|
||||
level: 3,
|
||||
name: "Feral Fledgling",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 700,
|
||||
bloodPerSecond: 7,
|
||||
class: "fledgling",
|
||||
combatPower: 20,
|
||||
count: 0,
|
||||
ichorPerSecond: 0.02,
|
||||
id: "fledgling_4",
|
||||
level: 4,
|
||||
name: "Blooded Fledgling",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 5000,
|
||||
bloodPerSecond: 20,
|
||||
class: "fledgling",
|
||||
combatPower: 50,
|
||||
count: 0,
|
||||
ichorPerSecond: 0.05,
|
||||
id: "fledgling_5",
|
||||
level: 5,
|
||||
name: "Seasoned Fledgling",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Revenant (5 tiers) ────────────────────────────────────────────────────
|
||||
{
|
||||
baseCost: 35_000,
|
||||
bloodPerSecond: 50,
|
||||
class: "revenant",
|
||||
combatPower: 120,
|
||||
count: 0,
|
||||
ichorPerSecond: 0.1,
|
||||
id: "revenant_1",
|
||||
level: 6,
|
||||
name: "Revenant",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 250_000,
|
||||
bloodPerSecond: 150,
|
||||
class: "revenant",
|
||||
combatPower: 300,
|
||||
count: 0,
|
||||
ichorPerSecond: 0.2,
|
||||
id: "revenant_2",
|
||||
level: 7,
|
||||
name: "Battle Revenant",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 2_000_000,
|
||||
bloodPerSecond: 400,
|
||||
class: "revenant",
|
||||
combatPower: 750,
|
||||
count: 0,
|
||||
ichorPerSecond: 0.5,
|
||||
id: "revenant_3",
|
||||
level: 8,
|
||||
name: "Ancient Revenant",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 15_000_000,
|
||||
bloodPerSecond: 1000,
|
||||
class: "revenant",
|
||||
combatPower: 1800,
|
||||
count: 0,
|
||||
ichorPerSecond: 1,
|
||||
id: "revenant_4",
|
||||
level: 9,
|
||||
name: "Elite Revenant",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 120_000_000,
|
||||
bloodPerSecond: 2500,
|
||||
class: "revenant",
|
||||
combatPower: 4000,
|
||||
count: 0,
|
||||
ichorPerSecond: 2,
|
||||
id: "revenant_5",
|
||||
level: 10,
|
||||
name: "Undying Revenant",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Shade (6 tiers) ───────────────────────────────────────────────────────
|
||||
{
|
||||
baseCost: 800_000_000,
|
||||
bloodPerSecond: 6000,
|
||||
class: "shade",
|
||||
combatPower: 9000,
|
||||
count: 0,
|
||||
ichorPerSecond: 5,
|
||||
id: "shade_1",
|
||||
level: 11,
|
||||
name: "Shade",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 6_000_000_000,
|
||||
bloodPerSecond: 15_000,
|
||||
class: "shade",
|
||||
combatPower: 20_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 10,
|
||||
id: "shade_2",
|
||||
level: 12,
|
||||
name: "Shadow Shade",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 45_000_000_000,
|
||||
bloodPerSecond: 35_000,
|
||||
class: "shade",
|
||||
combatPower: 45_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 20,
|
||||
id: "shade_3",
|
||||
level: 13,
|
||||
name: "Veil Shade",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 350_000_000_000,
|
||||
bloodPerSecond: 80_000,
|
||||
class: "shade",
|
||||
combatPower: 100_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 40,
|
||||
id: "shade_4",
|
||||
level: 14,
|
||||
name: "Phantom Shade",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 2_700_000_000_000,
|
||||
bloodPerSecond: 180_000,
|
||||
class: "shade",
|
||||
combatPower: 220_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 80,
|
||||
id: "shade_5",
|
||||
level: 15,
|
||||
name: "Wraith Shade",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 21_000_000_000_000,
|
||||
bloodPerSecond: 400_000,
|
||||
class: "shade",
|
||||
combatPower: 480_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 150,
|
||||
id: "shade_6",
|
||||
level: 16,
|
||||
name: "Eternal Shade",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Bloodbound (6 tiers) ──────────────────────────────────────────────────
|
||||
{
|
||||
baseCost: 160_000_000_000_000,
|
||||
bloodPerSecond: 900_000,
|
||||
class: "bloodbound",
|
||||
combatPower: 1_050_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 300,
|
||||
id: "bloodbound_1",
|
||||
level: 17,
|
||||
name: "Bloodbound",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1_250_000_000_000_000,
|
||||
bloodPerSecond: 2_000_000,
|
||||
class: "bloodbound",
|
||||
combatPower: 2_300_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 600,
|
||||
id: "bloodbound_2",
|
||||
level: 18,
|
||||
name: "Crimson Bloodbound",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 10_000_000_000_000_000,
|
||||
bloodPerSecond: 4_500_000,
|
||||
class: "bloodbound",
|
||||
combatPower: 5_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 1200,
|
||||
id: "bloodbound_3",
|
||||
level: 19,
|
||||
name: "Elder Bloodbound",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 80_000_000_000_000_000,
|
||||
bloodPerSecond: 10_000_000,
|
||||
class: "bloodbound",
|
||||
combatPower: 11_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 2500,
|
||||
id: "bloodbound_4",
|
||||
level: 20,
|
||||
name: "Oath Bloodbound",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 650_000_000_000_000_000,
|
||||
bloodPerSecond: 22_000_000,
|
||||
class: "bloodbound",
|
||||
combatPower: 24_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 5000,
|
||||
id: "bloodbound_5",
|
||||
level: 21,
|
||||
name: "Ancient Bloodbound",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 5_200_000_000_000_000_000,
|
||||
bloodPerSecond: 50_000_000,
|
||||
class: "bloodbound",
|
||||
combatPower: 54_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 10_000,
|
||||
id: "bloodbound_6",
|
||||
level: 22,
|
||||
name: "Eternal Bloodbound",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Wraith (5 tiers) ──────────────────────────────────────────────────────
|
||||
{
|
||||
baseCost: 4e19,
|
||||
bloodPerSecond: 110_000_000,
|
||||
class: "wraith",
|
||||
combatPower: 120_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 20_000,
|
||||
id: "wraith_1",
|
||||
level: 23,
|
||||
name: "Wraith",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 3.5e20,
|
||||
bloodPerSecond: 250_000_000,
|
||||
class: "wraith",
|
||||
combatPower: 270_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 40_000,
|
||||
id: "wraith_2",
|
||||
level: 24,
|
||||
name: "Banshee Wraith",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 2.8e21,
|
||||
bloodPerSecond: 560_000_000,
|
||||
class: "wraith",
|
||||
combatPower: 600_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 80_000,
|
||||
id: "wraith_3",
|
||||
level: 25,
|
||||
name: "Spectral Wraith",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 2.2e22,
|
||||
bloodPerSecond: 1_250_000_000,
|
||||
class: "wraith",
|
||||
combatPower: 1_350_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 160_000,
|
||||
id: "wraith_4",
|
||||
level: 26,
|
||||
name: "Elder Wraith",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1.8e23,
|
||||
bloodPerSecond: 2_800_000_000,
|
||||
class: "wraith",
|
||||
combatPower: 3_000_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 320_000,
|
||||
id: "wraith_5",
|
||||
level: 27,
|
||||
name: "Eternal Wraith",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Ancient (5 tiers) ─────────────────────────────────────────────────────
|
||||
{
|
||||
baseCost: 1.5e24,
|
||||
bloodPerSecond: 6_500_000_000,
|
||||
class: "ancient",
|
||||
combatPower: 7_000_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 650_000,
|
||||
id: "ancient_1",
|
||||
level: 28,
|
||||
name: "Ancient",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1.2e25,
|
||||
bloodPerSecond: 15_000_000_000,
|
||||
class: "ancient",
|
||||
combatPower: 16_000_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 1_300_000,
|
||||
id: "ancient_2",
|
||||
level: 29,
|
||||
name: "Bloodline Ancient",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 1e26,
|
||||
bloodPerSecond: 35_000_000_000,
|
||||
class: "ancient",
|
||||
combatPower: 37_000_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 2_600_000,
|
||||
id: "ancient_3",
|
||||
level: 30,
|
||||
name: "Elder Ancient",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 8e26,
|
||||
bloodPerSecond: 80_000_000_000,
|
||||
class: "ancient",
|
||||
combatPower: 85_000_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 5_200_000,
|
||||
id: "ancient_4",
|
||||
level: 31,
|
||||
name: "Apex Ancient",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
baseCost: 6.5e27,
|
||||
bloodPerSecond: 180_000_000_000,
|
||||
class: "ancient",
|
||||
combatPower: 200_000_000_000,
|
||||
count: 0,
|
||||
ichorPerSecond: 10_000_000,
|
||||
id: "ancient_5",
|
||||
level: 32,
|
||||
name: "Eternal Ancient",
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Data file */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { VampireUpgrade } from "@elysium/types";
|
||||
|
||||
export const defaultVampireUpgrades: Array<VampireUpgrade> = [
|
||||
// ── Blood Income (18) ─────────────────────────────────────────────────────
|
||||
{
|
||||
costBlood: 50,
|
||||
costIchor: 0,
|
||||
costSoulShards: 0,
|
||||
description: "The first taste of blood sharpens the hunt. All blood/s ×1.25.",
|
||||
id: "blood_hunt_1",
|
||||
multiplier: 1.25,
|
||||
name: "Blood Hunt I",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costBlood: 200,
|
||||
costIchor: 0,
|
||||
costSoulShards: 0,
|
||||
description: "A sustained hunting rhythm doubles the efficiency of every harvest. All blood/s ×1.5.",
|
||||
id: "blood_hunt_2",
|
||||
multiplier: 1.5,
|
||||
name: "Blood Hunt II",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costBlood: 1000,
|
||||
costIchor: 1,
|
||||
costSoulShards: 0,
|
||||
description: "The hunt becomes ritual — every drop flows with purpose. All blood/s ×2.",
|
||||
id: "blood_hunt_3",
|
||||
multiplier: 2,
|
||||
name: "Blood Hunt III",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costBlood: 5000,
|
||||
costIchor: 2,
|
||||
costSoulShards: 0,
|
||||
description: "The ritual deepens. Every vein in your domain opens wider. All blood/s ×3.",
|
||||
id: "blood_hunt_4",
|
||||
multiplier: 3,
|
||||
name: "Blood Hunt IV",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costBlood: 25_000,
|
||||
costIchor: 5,
|
||||
costSoulShards: 0,
|
||||
description: "A hunter at full power commands the blood itself to flow. All blood/s ×5.",
|
||||
id: "blood_hunt_5",
|
||||
multiplier: 5,
|
||||
name: "Blood Hunt V",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000,
|
||||
costIchor: 10,
|
||||
costSoulShards: 0,
|
||||
description: "Mastery of the hunt makes every moment a moment of feeding. All blood/s ×2.",
|
||||
id: "blood_mastery_1",
|
||||
multiplier: 2,
|
||||
name: "Blood Mastery I",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 500_000,
|
||||
costIchor: 25,
|
||||
costSoulShards: 0,
|
||||
description: "The second level of mastery reaches into the blood-flow itself. All blood/s ×3.",
|
||||
id: "blood_mastery_2",
|
||||
multiplier: 3,
|
||||
name: "Blood Mastery II",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 2_500_000,
|
||||
costIchor: 50,
|
||||
costSoulShards: 1,
|
||||
description: "The third level of mastery amplifies all income fivefold. All blood/s ×5.",
|
||||
id: "blood_mastery_3",
|
||||
multiplier: 5,
|
||||
name: "Blood Mastery III",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 12_500_000,
|
||||
costIchor: 100,
|
||||
costSoulShards: 2,
|
||||
description: "Total blood mastery — the hunger commands and the world obeys. All blood/s ×10.",
|
||||
id: "blood_mastery_4",
|
||||
multiplier: 10,
|
||||
name: "Blood Mastery IV",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 75_000_000,
|
||||
costIchor: 200,
|
||||
costSoulShards: 3,
|
||||
description: "A rising tide of crimson engulfs the entire domain. All blood/s ×5.",
|
||||
id: "crimson_tide_1",
|
||||
multiplier: 5,
|
||||
name: "Crimson Tide I",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 500_000_000,
|
||||
costIchor: 500,
|
||||
costSoulShards: 5,
|
||||
description: "The tide becomes a flood — no corner of your domain goes unwatered. All blood/s ×10.",
|
||||
id: "crimson_tide_2",
|
||||
multiplier: 10,
|
||||
name: "Crimson Tide II",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 3_000_000_000,
|
||||
costIchor: 1000,
|
||||
costSoulShards: 10,
|
||||
description: "The flood becomes eternal — the blood never stops flowing. All blood/s ×25.",
|
||||
id: "crimson_tide_3",
|
||||
multiplier: 25,
|
||||
name: "Crimson Tide III",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 20_000_000_000,
|
||||
costIchor: 2000,
|
||||
costSoulShards: 15,
|
||||
description: "The eternal thirst drives every thrall beyond their limits. All blood/s ×10.",
|
||||
id: "eternal_thirst_1",
|
||||
multiplier: 10,
|
||||
name: "Eternal Thirst I",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 150_000_000_000,
|
||||
costIchor: 5000,
|
||||
costSoulShards: 25,
|
||||
description: "The second degree of eternal thirst saturates every hunting ground. All blood/s ×25.",
|
||||
id: "eternal_thirst_2",
|
||||
multiplier: 25,
|
||||
name: "Eternal Thirst II",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_000_000_000_000,
|
||||
costIchor: 10_000,
|
||||
costSoulShards: 50,
|
||||
description: "The final degree — the domain itself becomes a feeding ground. All blood/s ×50.",
|
||||
id: "eternal_thirst_3",
|
||||
multiplier: 50,
|
||||
name: "Eternal Thirst III",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 8_000_000_000_000,
|
||||
costIchor: 25_000,
|
||||
costSoulShards: 75,
|
||||
description: "Absolute sovereignty over blood — the vampire's birthright, fully claimed. All blood/s ×25.",
|
||||
id: "blood_sovereignty_1",
|
||||
multiplier: 25,
|
||||
name: "Blood Sovereignty I",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 60_000_000_000_000,
|
||||
costIchor: 50_000,
|
||||
costSoulShards: 100,
|
||||
description: "The sovereign's command extends to every drop in every vessel in every corner of the realm. All blood/s ×50.",
|
||||
id: "blood_sovereignty_2",
|
||||
multiplier: 50,
|
||||
name: "Blood Sovereignty II",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 500_000_000_000_000,
|
||||
costIchor: 100_000,
|
||||
costSoulShards: 150,
|
||||
description: "The apex of blood sovereignty — creation itself bows to your thirst. All blood/s ×100.",
|
||||
id: "blood_sovereignty_3",
|
||||
multiplier: 100,
|
||||
name: "Blood Sovereignty III",
|
||||
purchased: false,
|
||||
target: "blood",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Thrall Upgrades (12, 2 per class) ────────────────────────────────────
|
||||
{
|
||||
costBlood: 150,
|
||||
costIchor: 0,
|
||||
costSoulShards: 0,
|
||||
description: "The fledglings learn to hunt more efficiently. Fledgling blood/s ×2.",
|
||||
id: "thrall_fledgling_1",
|
||||
multiplier: 2,
|
||||
name: "Fledgling Training I",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "fledgling",
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
costBlood: 2000,
|
||||
costIchor: 2,
|
||||
costSoulShards: 0,
|
||||
description: "Advanced fledgling conditioning multiplies their contribution. Fledgling blood/s ×4.",
|
||||
id: "thrall_fledgling_2",
|
||||
multiplier: 4,
|
||||
name: "Fledgling Training II",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "fledgling",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 75_000,
|
||||
costIchor: 5,
|
||||
costSoulShards: 0,
|
||||
description: "The revenant's battle instinct is sharpened to a fine edge. Revenant blood/s ×2.",
|
||||
id: "thrall_revenant_1",
|
||||
multiplier: 2,
|
||||
name: "Revenant Conditioning I",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "revenant",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_500_000,
|
||||
costIchor: 15,
|
||||
costSoulShards: 0,
|
||||
description: "Elite revenant training doubles their already formidable output. Revenant blood/s ×4.",
|
||||
id: "thrall_revenant_2",
|
||||
multiplier: 4,
|
||||
name: "Revenant Conditioning II",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "revenant",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 5_000_000,
|
||||
costIchor: 30,
|
||||
costSoulShards: 0,
|
||||
description: "The shade's shadow-walking technique is refined, producing greater yield. Shade blood/s ×2.",
|
||||
id: "thrall_shade_1",
|
||||
multiplier: 2,
|
||||
name: "Shadow Arts I",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "shade",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000_000,
|
||||
costIchor: 75,
|
||||
costSoulShards: 1,
|
||||
description: "The shade's shadow-step accelerates their harvest considerably. Shade blood/s ×4.",
|
||||
id: "thrall_shade_2",
|
||||
multiplier: 4,
|
||||
name: "Shadow Arts II",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "shade",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 500_000_000,
|
||||
costIchor: 150,
|
||||
costSoulShards: 2,
|
||||
description: "The bloodbound oath is renewed with greater intensity. Bloodbound blood/s ×2.",
|
||||
id: "thrall_bloodbound_1",
|
||||
multiplier: 2,
|
||||
name: "Oath Renewal I",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "bloodbound",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 10_000_000_000,
|
||||
costIchor: 350,
|
||||
costSoulShards: 4,
|
||||
description: "The strongest oath forges an unbreakable bond that multiplies all output. Bloodbound blood/s ×4.",
|
||||
id: "thrall_bloodbound_2",
|
||||
multiplier: 4,
|
||||
name: "Oath Renewal II",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "bloodbound",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 50_000_000_000,
|
||||
costIchor: 700,
|
||||
costSoulShards: 8,
|
||||
description: "The wraith's ichor-feeding technique enters a new phase of efficiency. Wraith blood/s ×2.",
|
||||
id: "thrall_wraith_1",
|
||||
multiplier: 2,
|
||||
name: "Wraith Mastery I",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "wraith",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_000_000_000_000,
|
||||
costIchor: 1500,
|
||||
costSoulShards: 15,
|
||||
description: "Total wraith mastery — they exist between states, harvesting from both. Wraith blood/s ×4.",
|
||||
id: "thrall_wraith_2",
|
||||
multiplier: 4,
|
||||
name: "Wraith Mastery II",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "wraith",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 5_000_000_000_000,
|
||||
costIchor: 3000,
|
||||
costSoulShards: 25,
|
||||
description: "The ancient thralls remember techniques from before the oldest living vampires. Ancient blood/s ×2.",
|
||||
id: "thrall_ancient_1",
|
||||
multiplier: 2,
|
||||
name: "Ancient Wisdom I",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "ancient",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000_000_000_000,
|
||||
costIchor: 7500,
|
||||
costSoulShards: 50,
|
||||
description: "The ancient thralls operate at the peak of millennia of refinement. Ancient blood/s ×4.",
|
||||
id: "thrall_ancient_2",
|
||||
multiplier: 4,
|
||||
name: "Ancient Wisdom II",
|
||||
purchased: false,
|
||||
target: "thrall",
|
||||
thrallId: "ancient",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Global Upgrades (10) ──────────────────────────────────────────────────
|
||||
{
|
||||
costBlood: 10_000,
|
||||
costIchor: 5,
|
||||
costSoulShards: 0,
|
||||
description: "All thralls synchronise their feeding — a whole greater than its parts. All blood/s ×1.5.",
|
||||
id: "vampire_synergy_1",
|
||||
multiplier: 1.5,
|
||||
name: "Vampire Synergy I",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000,
|
||||
costIchor: 20,
|
||||
costSoulShards: 0,
|
||||
description: "The synergy deepens — every domain element reinforces every other. All blood/s ×2.",
|
||||
id: "vampire_synergy_2",
|
||||
multiplier: 2,
|
||||
name: "Vampire Synergy II",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_000_000,
|
||||
costIchor: 50,
|
||||
costSoulShards: 1,
|
||||
description: "Full synchronisation across the vampire domain triples all income. All blood/s ×3.",
|
||||
id: "vampire_synergy_3",
|
||||
multiplier: 3,
|
||||
name: "Vampire Synergy III",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 10_000_000,
|
||||
costIchor: 100,
|
||||
costSoulShards: 3,
|
||||
description: "Perfect harmonic coordination across all vampire forces. All blood/s ×5.",
|
||||
id: "vampire_synergy_4",
|
||||
multiplier: 5,
|
||||
name: "Vampire Synergy IV",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000_000,
|
||||
costIchor: 200,
|
||||
costSoulShards: 5,
|
||||
description: "The apex of vampire domain synchronisation — a flawless, unified darkness. All blood/s ×10.",
|
||||
id: "vampire_synergy_5",
|
||||
multiplier: 10,
|
||||
name: "Vampire Synergy V",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 500_000_000,
|
||||
costIchor: 500,
|
||||
costSoulShards: 8,
|
||||
description: "An ancient covenant between predator and prey amplifies all output. All blood/s ×5.",
|
||||
id: "dark_covenant_1",
|
||||
multiplier: 5,
|
||||
name: "Dark Covenant I",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 5_000_000_000,
|
||||
costIchor: 1500,
|
||||
costSoulShards: 15,
|
||||
description: "The covenant deepens into something approaching a law of nature. All blood/s ×10.",
|
||||
id: "dark_covenant_2",
|
||||
multiplier: 10,
|
||||
name: "Dark Covenant II",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 50_000_000_000,
|
||||
costIchor: 5000,
|
||||
costSoulShards: 30,
|
||||
description: "The final covenant makes the domain itself hunger on your behalf. All blood/s ×25.",
|
||||
id: "dark_covenant_3",
|
||||
multiplier: 25,
|
||||
name: "Dark Covenant III",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 500_000_000_000,
|
||||
costIchor: 15_000,
|
||||
costSoulShards: 50,
|
||||
description: "The first step of blood ascension transcends the covenant. All blood/s ×25.",
|
||||
id: "blood_ascension_1",
|
||||
multiplier: 25,
|
||||
name: "Blood Ascension I",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 5_000_000_000_000,
|
||||
costIchor: 50_000,
|
||||
costSoulShards: 100,
|
||||
description: "The final blood ascension — you become the very concept of the hunt. All blood/s ×50.",
|
||||
id: "blood_ascension_2",
|
||||
multiplier: 50,
|
||||
name: "Blood Ascension II",
|
||||
purchased: false,
|
||||
target: "global",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Siring Upgrades (8) ───────────────────────────────────────────────────
|
||||
{
|
||||
costBlood: 50_000,
|
||||
costIchor: 10,
|
||||
costSoulShards: 0,
|
||||
description: "The first step toward mastering the siring rite. Siring blood threshold ×0.9.",
|
||||
id: "siring_mastery_1",
|
||||
multiplier: 0.9,
|
||||
name: "Siring Mastery I",
|
||||
purchased: false,
|
||||
target: "siring",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 500_000,
|
||||
costIchor: 30,
|
||||
costSoulShards: 0,
|
||||
description: "Deeper mastery of the siring rite reduces the cost further. Siring blood threshold ×0.85.",
|
||||
id: "siring_mastery_2",
|
||||
multiplier: 0.85,
|
||||
name: "Siring Mastery II",
|
||||
purchased: false,
|
||||
target: "siring",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 5_000_000,
|
||||
costIchor: 75,
|
||||
costSoulShards: 1,
|
||||
description: "The ritual becomes instinctive, requiring less sacrifice each time. Siring blood threshold ×0.8.",
|
||||
id: "siring_mastery_3",
|
||||
multiplier: 0.8,
|
||||
name: "Siring Mastery III",
|
||||
purchased: false,
|
||||
target: "siring",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 50_000_000,
|
||||
costIchor: 200,
|
||||
costSoulShards: 3,
|
||||
description: "Apex siring mastery — the bloodline answers with a fraction of the effort. Siring blood threshold ×0.75.",
|
||||
id: "siring_mastery_4",
|
||||
multiplier: 0.75,
|
||||
name: "Siring Mastery IV",
|
||||
purchased: false,
|
||||
target: "siring",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_000_000_000,
|
||||
costIchor: 500,
|
||||
costSoulShards: 5,
|
||||
description: "The bloodline's power carries a multiplier into every siring. Siring production multiplier ×1.5.",
|
||||
id: "bloodline_power_1",
|
||||
multiplier: 1.5,
|
||||
name: "Bloodline Power I",
|
||||
purchased: false,
|
||||
target: "siring",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 10_000_000_000,
|
||||
costIchor: 1500,
|
||||
costSoulShards: 10,
|
||||
description: "The bloodline power doubles its influence over each new generation. Siring production multiplier ×2.",
|
||||
id: "bloodline_power_2",
|
||||
multiplier: 2,
|
||||
name: "Bloodline Power II",
|
||||
purchased: false,
|
||||
target: "siring",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000_000_000,
|
||||
costIchor: 5000,
|
||||
costSoulShards: 20,
|
||||
description: "The bloodline power reaches its third stage — threefold production. Siring production multiplier ×3.",
|
||||
id: "bloodline_power_3",
|
||||
multiplier: 3,
|
||||
name: "Bloodline Power III",
|
||||
purchased: false,
|
||||
target: "siring",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_000_000_000_000,
|
||||
costIchor: 15_000,
|
||||
costSoulShards: 40,
|
||||
description: "The apex bloodline power — fivefold production carries into every generation. Siring production multiplier ×5.",
|
||||
id: "bloodline_power_4",
|
||||
multiplier: 5,
|
||||
name: "Bloodline Power IV",
|
||||
purchased: false,
|
||||
target: "siring",
|
||||
unlocked: false,
|
||||
},
|
||||
// ── Boss Upgrades (10) ────────────────────────────────────────────────────
|
||||
{
|
||||
costBlood: 1000,
|
||||
costIchor: 1,
|
||||
costSoulShards: 0,
|
||||
description: "The hunter's instinct sharpens with each victory. Thrall combat power ×1.25.",
|
||||
id: "hunter_instinct_1",
|
||||
multiplier: 1.25,
|
||||
name: "Hunter Instinct I",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 10_000,
|
||||
costIchor: 5,
|
||||
costSoulShards: 0,
|
||||
description: "The instinct becomes method — every fight becomes a calculated feeding. Thrall combat power ×1.5.",
|
||||
id: "hunter_instinct_2",
|
||||
multiplier: 1.5,
|
||||
name: "Hunter Instinct II",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000,
|
||||
costIchor: 15,
|
||||
costSoulShards: 0,
|
||||
description: "Method becomes mastery — the hunt reaches a new precision. Thrall combat power ×2.",
|
||||
id: "hunter_instinct_3",
|
||||
multiplier: 2,
|
||||
name: "Hunter Instinct III",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_000_000,
|
||||
costIchor: 40,
|
||||
costSoulShards: 1,
|
||||
description: "Peak hunting mastery — every thrall becomes a precision instrument of predation. Thrall combat power ×3.",
|
||||
id: "hunter_instinct_4",
|
||||
multiplier: 3,
|
||||
name: "Hunter Instinct IV",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 10_000_000,
|
||||
costIchor: 100,
|
||||
costSoulShards: 2,
|
||||
description: "The predator's sense detects weaknesses no eye can see. Thrall combat power ×2.",
|
||||
id: "predator_sense_1",
|
||||
multiplier: 2,
|
||||
name: "Predator Sense I",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000_000,
|
||||
costIchor: 300,
|
||||
costSoulShards: 5,
|
||||
description: "The predator's sense reaches across zones — no prey is truly safe. Thrall combat power ×3.",
|
||||
id: "predator_sense_2",
|
||||
multiplier: 3,
|
||||
name: "Predator Sense II",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_000_000_000,
|
||||
costIchor: 1000,
|
||||
costSoulShards: 10,
|
||||
description: "The apex predator sense collapses all opposition with surgical efficiency. Thrall combat power ×5.",
|
||||
id: "predator_sense_3",
|
||||
multiplier: 5,
|
||||
name: "Predator Sense III",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 10_000_000_000,
|
||||
costIchor: 3000,
|
||||
costSoulShards: 20,
|
||||
description: "The first apex predator upgrade transcends ordinary vampire combat. Thrall combat power ×5.",
|
||||
id: "apex_predator_1",
|
||||
multiplier: 5,
|
||||
name: "Apex Predator I",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 100_000_000_000,
|
||||
costIchor: 10_000,
|
||||
costSoulShards: 40,
|
||||
description: "The second apex level — the domain produces warriors of legendary power. Thrall combat power ×10.",
|
||||
id: "apex_predator_2",
|
||||
multiplier: 10,
|
||||
name: "Apex Predator II",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
{
|
||||
costBlood: 1_000_000_000_000,
|
||||
costIchor: 30_000,
|
||||
costSoulShards: 75,
|
||||
description: "The final apex level — combat power that can shatter ancient evils. Thrall combat power ×25.",
|
||||
id: "apex_predator_3",
|
||||
multiplier: 25,
|
||||
name: "Apex Predator III",
|
||||
purchased: false,
|
||||
target: "boss",
|
||||
unlocked: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file Game data definitions.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { VampireZone } from "@elysium/types";
|
||||
|
||||
export const defaultVampireZones: Array<VampireZone> = [
|
||||
{
|
||||
description:
|
||||
"Ancient burial catacombs stretching deep beneath a forgotten city. The dead here did not rest peacefully — they were turned, bound, and set to guard secrets that no longer have owners. A good place to learn what it means to hunt.",
|
||||
emoji: "🪦",
|
||||
id: "vampire_haunted_catacombs",
|
||||
name: "Haunted Catacombs",
|
||||
status: "unlocked",
|
||||
unlockBossId: null,
|
||||
unlockQuestId: null,
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A vast wetland soaked in centuries of spilled blood. The crimson mire never dries. The things that live here have adapted to drink the land itself, and they are not welcoming to rivals.",
|
||||
emoji: "🩸",
|
||||
id: "vampire_blood_mire",
|
||||
name: "Blood Mire",
|
||||
status: "locked",
|
||||
unlockBossId: "catacomb_lord",
|
||||
unlockQuestId: "catacombs_depths",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A fortress carved entirely from volcanic obsidian, its walls absorbing light and returning nothing. The soldiers who garrison it have not needed sleep or food in longer than anyone can remember. The Keep does not fall. It merely waits.",
|
||||
emoji: "🏯",
|
||||
id: "vampire_obsidian_keep",
|
||||
name: "Obsidian Keep",
|
||||
status: "locked",
|
||||
unlockBossId: "mire_sovereign",
|
||||
unlockQuestId: "mire_ritual",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The seat of an ancient vampire dynasty whose lineage predates recorded history. Every stone of the Crimson Citadel has been touched by blood magic. The court still convenes here, conducting business in a language of subtle power that outsiders rarely survive learning.",
|
||||
emoji: "🏰",
|
||||
id: "vampire_crimson_citadel",
|
||||
name: "Crimson Citadel",
|
||||
status: "locked",
|
||||
unlockBossId: "keep_overlord",
|
||||
unlockQuestId: "keep_conquest",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A court that exists entirely in shadow — its members, its politics, its location. Alliances here are written in whispers and sealed with betrayal. The most dangerous thing about the Shadow Court is that you never know you have lost until it is already over.",
|
||||
emoji: "🌑",
|
||||
id: "vampire_shadow_court",
|
||||
name: "Shadow Court",
|
||||
status: "locked",
|
||||
unlockBossId: "citadel_lord",
|
||||
unlockQuestId: "citadel_siege",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A charnel ground where plague vampires inter their dead — not out of respect, but to harvest what remains. The pestilence that festers here is not disease in any mortal sense. It is patient, intelligent, and perpetually hungry for new hosts.",
|
||||
emoji: "💀",
|
||||
id: "vampire_plague_ossuary",
|
||||
name: "Plague Ossuary",
|
||||
status: "locked",
|
||||
unlockBossId: "shadow_monarch",
|
||||
unlockQuestId: "shadow_ascension",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A desolate expanse where ancient vampire wars reduced everything to ash and cinder. Nothing grows here. The revenants who patrol it have forgotten what they were fighting for; they have not forgotten how to fight.",
|
||||
emoji: "🌋",
|
||||
id: "vampire_ashen_wastes",
|
||||
name: "Ashen Wastes",
|
||||
status: "locked",
|
||||
unlockBossId: "ossuary_overlord",
|
||||
unlockQuestId: "ossuary_purification",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An inescapable prison built to contain the most dangerous vampires ever captured. The wards that hold them were set by a power long since dead, and they are beginning to fade. The prisoners have had centuries to plan their response.",
|
||||
emoji: "⛓️",
|
||||
id: "vampire_iron_gaol",
|
||||
name: "The Iron Gaol",
|
||||
status: "locked",
|
||||
unlockBossId: "wastes_warden",
|
||||
unlockQuestId: "ash_domination",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A sunken vale where the boundary between the living world and the shadow-realm grows thin. The creatures born here are neither fully present nor entirely absent — half-real entities that flicker in and out of visibility, leaving cold air and blood in their wake.",
|
||||
emoji: "🕯️",
|
||||
id: "vampire_veilborn_hollow",
|
||||
name: "Veilborn Hollow",
|
||||
status: "locked",
|
||||
unlockBossId: "gaol_master",
|
||||
unlockQuestId: "gaol_liberation",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An endless expanse of fog-shrouded moorland where the moon has not risen in generations. The things that hunt here have evolved to navigate by blood-scent alone. The moor remembers every predator that has ever walked its surface.",
|
||||
emoji: "🌫️",
|
||||
id: "vampire_moonless_moor",
|
||||
name: "Moonless Moor",
|
||||
status: "locked",
|
||||
unlockBossId: "veilborn_herald",
|
||||
unlockQuestId: "veil_mastery",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A vast necropolis built atop a flooded underground sea. The crypts stretch downward into pitch-black water, and the things sealed in the deepest chambers have been there since before the city above them was built. They are waiting for the water to recede.",
|
||||
emoji: "🌊",
|
||||
id: "vampire_sunken_crypt",
|
||||
name: "The Sunken Crypt",
|
||||
status: "locked",
|
||||
unlockBossId: "moonless_warden",
|
||||
unlockQuestId: "moor_conquest",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A temple that was once a place of worship, now systematically stripped of every symbol, every relic, every remnant of its original purpose. The desecrators who occupy it did not destroy these things out of malice. They needed the space for something worse.",
|
||||
emoji: "⛪",
|
||||
id: "vampire_desecrated_sanctum",
|
||||
name: "Desecrated Sanctum",
|
||||
status: "locked",
|
||||
unlockBossId: "crypt_ancient",
|
||||
unlockQuestId: "sunken_conquest",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A range of jagged peaks where great winged predators nest and hunt. The updrafts here carry the scent of blood from miles away. The oldest residents have been here so long that the mountains have shaped themselves around them.",
|
||||
emoji: "🦅",
|
||||
id: "vampire_carrion_peaks",
|
||||
name: "Carrion Peaks",
|
||||
status: "locked",
|
||||
unlockBossId: "sanctum_sovereign",
|
||||
unlockQuestId: "sanctum_subjugation",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An impossible spire of crystallised blood that rises from the earth with no natural explanation. The architecture inside follows no logic familiar to mortal builders — rooms that don't connect to the rooms above them, staircases that lead to walls, windows that look into other windows. It was built this way deliberately.",
|
||||
emoji: "🗼",
|
||||
id: "vampire_bloodspire",
|
||||
name: "The Bloodspire",
|
||||
status: "locked",
|
||||
unlockBossId: "carrion_lord",
|
||||
unlockQuestId: "peaks_conquest",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"A region where time moves strangely — not stopped, but reluctant. Vampires who spend too long here find that their memories of the past and their anticipation of the future begin to blur together. Only the present moment retains its edges. The shroud does not let go easily.",
|
||||
emoji: "⏳",
|
||||
id: "vampire_shroud_of_eternity",
|
||||
name: "Shroud of Eternity",
|
||||
status: "locked",
|
||||
unlockBossId: "bloodspire_tyrant",
|
||||
unlockQuestId: "spire_conquest",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"An underground treasury sealed by a vampire lord who believed the only way to keep something safe was to make retrieving it more dangerous than it was worth. They were correct. Every soul who has broken in has paid the price. The vault keeps everything it takes.",
|
||||
emoji: "🔒",
|
||||
id: "vampire_abyssal_vault",
|
||||
name: "The Abyssal Vault",
|
||||
status: "locked",
|
||||
unlockBossId: "eternal_sovereign",
|
||||
unlockQuestId: "shroud_conquest",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The seat of the most ancient vampire intelligence network in existence. Nothing that happens in the vampire world goes unreported here. The Court of Whispers does not conduct violence — it simply arranges for others to conduct it at the optimal moment.",
|
||||
emoji: "🕊️",
|
||||
id: "vampire_court_of_whispers",
|
||||
name: "Court of Whispers",
|
||||
status: "locked",
|
||||
unlockBossId: "abyssal_warden",
|
||||
unlockQuestId: "vault_conquest",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"The place where the vampire world ends — or rather, where it keeps going into something that has no name. The Eternal Abyss is not empty. It is full of things that predate the first vampire, the first turning, the first death. They are older than the concept of hunger, and they are always hungry.",
|
||||
emoji: "🌌",
|
||||
id: "vampire_eternal_abyss",
|
||||
name: "The Eternal Abyss",
|
||||
status: "locked",
|
||||
unlockBossId: "court_sovereign",
|
||||
unlockQuestId: "court_conquest",
|
||||
},
|
||||
];
|
||||
@@ -12,16 +12,28 @@ 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 { consecrationRouter } from "./routes/consecration.js";
|
||||
import { craftRouter } from "./routes/craft.js";
|
||||
import { debugRouter } from "./routes/debug.js";
|
||||
import { enlightenmentRouter } from "./routes/enlightenment.js";
|
||||
import { exploreRouter } from "./routes/explore.js";
|
||||
import { frontendRouter } from "./routes/frontend.js";
|
||||
import { gameRouter } from "./routes/game.js";
|
||||
import { goddessBossRouter } from "./routes/goddessBoss.js";
|
||||
import { goddessCraftRouter } from "./routes/goddessCraft.js";
|
||||
import { goddessExploreRouter } from "./routes/goddessExplore.js";
|
||||
import { goddessUpgradeRouter } from "./routes/goddessUpgrade.js";
|
||||
import { leaderboardRouter } from "./routes/leaderboards.js";
|
||||
import { prestigeRouter } from "./routes/prestige.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
import { siringRouter } from "./routes/siring.js";
|
||||
import { timersRouter } from "./routes/timers.js";
|
||||
import { transcendenceRouter } from "./routes/transcendence.js";
|
||||
import { vampireAwakeningRouter } from "./routes/vampireAwakening.js";
|
||||
import { vampireBossRouter } from "./routes/vampireBoss.js";
|
||||
import { vampireCraftRouter } from "./routes/vampireCraft.js";
|
||||
import { vampireExploreRouter } from "./routes/vampireExplore.js";
|
||||
import { vampireUpgradeRouter } from "./routes/vampireUpgrade.js";
|
||||
import { connectGateway } from "./services/gateway.js";
|
||||
import { logger } from "./services/logger.js";
|
||||
|
||||
@@ -48,6 +60,18 @@ app.route("/craft", craftRouter);
|
||||
app.route("/prestige", prestigeRouter);
|
||||
app.route("/transcendence", transcendenceRouter);
|
||||
app.route("/apotheosis", apotheosisRouter);
|
||||
app.route("/goddess-boss", goddessBossRouter);
|
||||
app.route("/consecration", consecrationRouter);
|
||||
app.route("/enlightenment", enlightenmentRouter);
|
||||
app.route("/goddess-upgrade", goddessUpgradeRouter);
|
||||
app.route("/goddess-craft", goddessCraftRouter);
|
||||
app.route("/goddess-explore", goddessExploreRouter);
|
||||
app.route("/vampire-boss", vampireBossRouter);
|
||||
app.route("/siring", siringRouter);
|
||||
app.route("/vampire-awakening", vampireAwakeningRouter);
|
||||
app.route("/vampire-upgrade", vampireUpgradeRouter);
|
||||
app.route("/vampire-craft", vampireCraftRouter);
|
||||
app.route("/vampire-explore", vampireExploreRouter);
|
||||
app.route("/leaderboards", leaderboardRouter);
|
||||
app.route("/profile", profileRouter);
|
||||
app.route("/timers", timersRouter);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file Consecration routes handling consecration resets and divinity upgrade purchases.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultConsecrationUpgrades } from "../data/goddessConsecrationUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import {
|
||||
buildPostConsecrationState,
|
||||
calculateConsecrationThreshold,
|
||||
computeConsecrationDivinityMultipliers,
|
||||
isEligibleForConsecration,
|
||||
} from "../services/consecration.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
BuyConsecrationUpgradeRequest,
|
||||
ConsecrationResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const consecrationRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
consecrationRouter.use("*", authMiddleware);
|
||||
|
||||
consecrationRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (!isEligibleForConsecration(state)) {
|
||||
const thresholdMultiplier
|
||||
= state.goddess.enlightenment.stardustConsecrationThresholdMultiplier;
|
||||
const required = calculateConsecrationThreshold(
|
||||
state.goddess.consecration.count,
|
||||
thresholdMultiplier,
|
||||
);
|
||||
return context.json(
|
||||
{
|
||||
error: `Not eligible for consecration — earn ${required.toLocaleString()} total prayers first`,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const { divinityEarned, updatedGoddess } = buildPostConsecrationState(state);
|
||||
|
||||
const updatedConsecrationCount = updatedGoddess.consecration.count;
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
goddess: updatedGoddess,
|
||||
resources: {
|
||||
...state.resources,
|
||||
prayers: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("consecration", 1, { discordId, updatedConsecrationCount });
|
||||
|
||||
const response: ConsecrationResponse = {
|
||||
divinityEarned: divinityEarned,
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||
newConsecrationCount: updatedConsecrationCount,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"consecration",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
consecrationRouter.post("/buy-upgrade", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuyConsecrationUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultConsecrationUpgrades.find((consecrationUpgrade) => {
|
||||
return consecrationUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown consecration upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, divinity } = state.goddess.consecration;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (divinity < upgrade.divinityCost) {
|
||||
return context.json({ error: "Not enough divinity" }, 400);
|
||||
}
|
||||
|
||||
const updatedDivinity = divinity - upgrade.divinityCost;
|
||||
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedMultipliers = computeConsecrationDivinityMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
goddess: {
|
||||
...state.goddess,
|
||||
consecration: {
|
||||
...state.goddess.consecration,
|
||||
divinity: updatedDivinity,
|
||||
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...updatedMultipliers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("consecration_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
return context.json({
|
||||
divinityRemaining: updatedDivinity,
|
||||
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...updatedMultipliers,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"consecration_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { consecrationRouter };
|
||||
+330
-19
@@ -18,11 +18,31 @@ import { defaultAdventurers } from "../data/adventurers.js";
|
||||
import { defaultBosses } from "../data/bosses.js";
|
||||
import { defaultEquipment } from "../data/equipment.js";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { defaultGoddessAchievements } from "../data/goddessAchievements.js";
|
||||
import { defaultGoddessBosses } from "../data/goddessBosses.js";
|
||||
import { defaultGoddessDisciples } from "../data/goddessDisciples.js";
|
||||
import { defaultGoddessEquipment } from "../data/goddessEquipment.js";
|
||||
import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.js";
|
||||
import { defaultGoddessQuests } from "../data/goddessQuests.js";
|
||||
import { defaultGoddessUpgrades } from "../data/goddessUpgrades.js";
|
||||
import { defaultGoddessZones } from "../data/goddessZones.js";
|
||||
import {
|
||||
initialGameState,
|
||||
initialGoddessState,
|
||||
initialVampireState,
|
||||
} from "../data/initialState.js";
|
||||
import { defaultQuests } from "../data/quests.js";
|
||||
import { defaultRecipes } from "../data/recipes.js";
|
||||
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||
import { defaultUpgrades } from "../data/upgrades.js";
|
||||
import { defaultVampireAchievements } from "../data/vampireAchievements.js";
|
||||
import { defaultVampireBosses } from "../data/vampireBosses.js";
|
||||
import { defaultVampireEquipment } from "../data/vampireEquipment.js";
|
||||
import { defaultVampireExplorationAreas } from "../data/vampireExplorations.js";
|
||||
import { defaultVampireQuests } from "../data/vampireQuests.js";
|
||||
import { defaultVampireThralls } from "../data/vampireThralls.js";
|
||||
import { defaultVampireUpgrades } from "../data/vampireUpgrades.js";
|
||||
import { defaultVampireZones } from "../data/vampireZones.js";
|
||||
import { defaultZones } from "../data/zones.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
@@ -567,6 +587,60 @@ const injectMissingExplorationAreas = (state: GameState): number => {
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Injects any goddess exploration areas from the defaults that are missing from
|
||||
* the player's goddess exploration state, seeding each new area as locked.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of goddess exploration areas that were added.
|
||||
*/
|
||||
const injectMissingGoddessExplorationAreas = (state: GameState): number => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.goddess === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const existingIds = new Set(state.goddess.exploration.areas.map((area) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return area.id;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const area of defaultGoddessExplorationAreas) {
|
||||
if (!existingIds.has(area.id)) {
|
||||
state.goddess.exploration.areas.push({ id: area.id, status: "locked" });
|
||||
added = added + 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Injects any vampire exploration areas from the defaults that are missing from
|
||||
* the player's vampire exploration state, seeding each new area as locked.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of vampire exploration areas that were added.
|
||||
*/
|
||||
const injectMissingVampireExplorationAreas = (state: GameState): number => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.vampire === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const existingIds = new Set(state.vampire.exploration.areas.map((area) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return area.id;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const area of defaultVampireExplorationAreas) {
|
||||
if (!existingIds.has(area.id)) {
|
||||
state.vampire.exploration.areas.push({ id: area.id, status: "locked" });
|
||||
added = added + 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Patches rewards on existing quests whose reward lists have grown since the
|
||||
* save was created (e.g. A new upgrade added as a reward to an old quest).
|
||||
@@ -967,27 +1041,44 @@ const recomputeCraftingMultipliers = (state: GameState): number => {
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns Counts of how many entries were added or patched per content type.
|
||||
*/
|
||||
/* eslint-disable-next-line max-statements -- Sync function requires one operation per content type */
|
||||
const syncNewContent = (
|
||||
state: GameState,
|
||||
): {
|
||||
achievementsAdded: number;
|
||||
achievementsPatched: number;
|
||||
adventurersAdded: number;
|
||||
adventurerStatsPatched: number;
|
||||
bossesAdded: number;
|
||||
bossesPatched: number;
|
||||
bossRewardsPatched: number;
|
||||
craftingRecipesReapplied: number;
|
||||
equipmentAdded: number;
|
||||
equipmentPatched: number;
|
||||
explorationAreasAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
questsPatched: number;
|
||||
upgradesAdded: number;
|
||||
upgradesPatched: number;
|
||||
zonesAdded: number;
|
||||
zonesPatched: number;
|
||||
achievementsAdded: number;
|
||||
achievementsPatched: number;
|
||||
adventurersAdded: number;
|
||||
adventurerStatsPatched: number;
|
||||
bossesAdded: number;
|
||||
bossesPatched: number;
|
||||
bossRewardsPatched: number;
|
||||
craftingRecipesReapplied: number;
|
||||
equipmentAdded: number;
|
||||
equipmentPatched: number;
|
||||
explorationAreasAdded: number;
|
||||
goddessAchievementsAdded: number;
|
||||
goddessBossesAdded: number;
|
||||
goddessDiscipesAdded: number;
|
||||
goddessEquipmentAdded: number;
|
||||
goddessExplorationAreasAdded: number;
|
||||
goddessQuestsAdded: number;
|
||||
goddessUpgradesAdded: number;
|
||||
goddessZonesAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
questsPatched: number;
|
||||
upgradesAdded: number;
|
||||
upgradesPatched: number;
|
||||
vampireAchievementsAdded: number;
|
||||
vampireBossesAdded: number;
|
||||
vampireEquipmentAdded: number;
|
||||
vampireExplorationAreasAdded: number;
|
||||
vampireQuestsAdded: number;
|
||||
vampireThrallsAdded: number;
|
||||
vampireUpgradesAdded: number;
|
||||
vampireZonesAdded: number;
|
||||
zonesAdded: number;
|
||||
zonesPatched: number;
|
||||
} => {
|
||||
const adventurerStatsPatched = patchAdventurerStats(state);
|
||||
const questsPatched = patchQuestStats(state);
|
||||
@@ -1007,6 +1098,61 @@ const syncNewContent = (
|
||||
const questsAdded = injectMissingEntries(state.quests, defaultQuests);
|
||||
const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades);
|
||||
const zonesAdded = injectMissingEntries(state.zones, defaultZones);
|
||||
|
||||
// Inject missing goddess content for players who have completed Apotheosis
|
||||
let goddessAchievementsAdded = 0;
|
||||
let goddessBossesAdded = 0;
|
||||
let goddessDiscipesAdded = 0;
|
||||
let goddessEquipmentAdded = 0;
|
||||
let goddessExplorationAreasAdded = 0;
|
||||
let goddessQuestsAdded = 0;
|
||||
let goddessUpgradesAdded = 0;
|
||||
let goddessZonesAdded = 0;
|
||||
if (state.goddess) {
|
||||
goddessAchievementsAdded
|
||||
= injectMissingEntries(state.goddess.achievements, defaultGoddessAchievements);
|
||||
goddessBossesAdded
|
||||
= injectMissingEntries(state.goddess.bosses, defaultGoddessBosses);
|
||||
goddessDiscipesAdded
|
||||
= injectMissingEntries(state.goddess.disciples, defaultGoddessDisciples);
|
||||
goddessEquipmentAdded
|
||||
= injectMissingEntries(state.goddess.equipment, defaultGoddessEquipment);
|
||||
goddessExplorationAreasAdded = injectMissingGoddessExplorationAreas(state);
|
||||
goddessQuestsAdded
|
||||
= injectMissingEntries(state.goddess.quests, defaultGoddessQuests);
|
||||
goddessUpgradesAdded
|
||||
= injectMissingEntries(state.goddess.upgrades, defaultGoddessUpgrades);
|
||||
goddessZonesAdded
|
||||
= injectMissingEntries(state.goddess.zones, defaultGoddessZones);
|
||||
}
|
||||
|
||||
// Inject missing vampire content for players who have entered the Vampire realm
|
||||
let vampireAchievementsAdded = 0;
|
||||
let vampireBossesAdded = 0;
|
||||
let vampireEquipmentAdded = 0;
|
||||
let vampireExplorationAreasAdded = 0;
|
||||
let vampireQuestsAdded = 0;
|
||||
let vampireThrallsAdded = 0;
|
||||
let vampireUpgradesAdded = 0;
|
||||
let vampireZonesAdded = 0;
|
||||
if (state.vampire) {
|
||||
vampireAchievementsAdded
|
||||
= injectMissingEntries(state.vampire.achievements, defaultVampireAchievements);
|
||||
vampireBossesAdded
|
||||
= injectMissingEntries(state.vampire.bosses, defaultVampireBosses);
|
||||
vampireEquipmentAdded
|
||||
= injectMissingEntries(state.vampire.equipment, defaultVampireEquipment);
|
||||
vampireExplorationAreasAdded = injectMissingVampireExplorationAreas(state);
|
||||
vampireQuestsAdded
|
||||
= injectMissingEntries(state.vampire.quests, defaultVampireQuests);
|
||||
vampireThrallsAdded
|
||||
= injectMissingEntries(state.vampire.thralls, defaultVampireThralls);
|
||||
vampireUpgradesAdded
|
||||
= injectMissingEntries(state.vampire.upgrades, defaultVampireUpgrades);
|
||||
vampireZonesAdded
|
||||
= injectMissingEntries(state.vampire.zones, defaultVampireZones);
|
||||
}
|
||||
|
||||
return {
|
||||
achievementsAdded,
|
||||
achievementsPatched,
|
||||
@@ -1019,11 +1165,27 @@ const syncNewContent = (
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
goddessAchievementsAdded,
|
||||
goddessBossesAdded,
|
||||
goddessDiscipesAdded,
|
||||
goddessEquipmentAdded,
|
||||
goddessExplorationAreasAdded,
|
||||
goddessQuestsAdded,
|
||||
goddessUpgradesAdded,
|
||||
goddessZonesAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
vampireAchievementsAdded,
|
||||
vampireBossesAdded,
|
||||
vampireEquipmentAdded,
|
||||
vampireExplorationAreasAdded,
|
||||
vampireQuestsAdded,
|
||||
vampireThrallsAdded,
|
||||
vampireUpgradesAdded,
|
||||
vampireZonesAdded,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
};
|
||||
@@ -1120,11 +1282,27 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
goddessAchievementsAdded,
|
||||
goddessBossesAdded,
|
||||
goddessDiscipesAdded,
|
||||
goddessEquipmentAdded,
|
||||
goddessExplorationAreasAdded,
|
||||
goddessQuestsAdded,
|
||||
goddessUpgradesAdded,
|
||||
goddessZonesAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
vampireAchievementsAdded,
|
||||
vampireBossesAdded,
|
||||
vampireEquipmentAdded,
|
||||
vampireExplorationAreasAdded,
|
||||
vampireQuestsAdded,
|
||||
vampireThrallsAdded,
|
||||
vampireUpgradesAdded,
|
||||
vampireZonesAdded,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
} = syncNewContent(state);
|
||||
@@ -1154,6 +1332,14 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
goddessAchievementsAdded,
|
||||
goddessBossesAdded,
|
||||
goddessDiscipesAdded,
|
||||
goddessEquipmentAdded,
|
||||
goddessExplorationAreasAdded,
|
||||
goddessQuestsAdded,
|
||||
goddessUpgradesAdded,
|
||||
goddessZonesAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
@@ -1161,6 +1347,14 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
state,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
vampireAchievementsAdded,
|
||||
vampireBossesAdded,
|
||||
vampireEquipmentAdded,
|
||||
vampireExplorationAreasAdded,
|
||||
vampireQuestsAdded,
|
||||
vampireThrallsAdded,
|
||||
vampireUpgradesAdded,
|
||||
vampireZonesAdded,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
});
|
||||
@@ -1175,6 +1369,123 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
}
|
||||
});
|
||||
|
||||
debugRouter.post("/grant-apotheosis", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; double-cast required */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
const updatedState: GameState
|
||||
= (state.apotheosis?.count ?? 0) >= 1
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
apotheosis: { count: 1 },
|
||||
goddess: initialGoddessState(),
|
||||
};
|
||||
|
||||
if (updatedState !== state) {
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature
|
||||
= secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(updatedState), secret);
|
||||
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
loginBonus: null,
|
||||
loginStreak: 0,
|
||||
offlineEssence: 0,
|
||||
offlineGold: 0,
|
||||
offlineSeconds: 0,
|
||||
schemaOutdated: false,
|
||||
signature: signature,
|
||||
state: updatedState,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"debug_grant_apotheosis",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
debugRouter.post("/grant-eternal-sovereignty", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; double-cast required */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
const updatedState: GameState
|
||||
= (state.vampire?.eternalSovereignty.count ?? 0) >= 1
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
vampire: state.vampire
|
||||
? { ...state.vampire, eternalSovereignty: { count: 1 } }
|
||||
: { ...initialVampireState(), eternalSovereignty: { count: 1 } },
|
||||
};
|
||||
|
||||
if (updatedState !== state) {
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature
|
||||
= secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(updatedState), secret);
|
||||
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
loginBonus: null,
|
||||
loginStreak: 0,
|
||||
offlineEssence: 0,
|
||||
offlineGold: 0,
|
||||
offlineSeconds: 0,
|
||||
schemaOutdated: false,
|
||||
signature: signature,
|
||||
state: updatedState,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"debug_grant_eternal_sovereignty",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
debugRouter.post("/hard-reset", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file Enlightenment routes handling enlightenment resets and stardust upgrade purchases.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultEnlightenmentUpgrades } from "../data/goddessEnlightenmentUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import {
|
||||
buildPostEnlightenmentState,
|
||||
computeEnlightenmentMultipliers,
|
||||
isEligibleForEnlightenment,
|
||||
} from "../services/enlightenment.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
BuyEnlightenmentUpgradeRequest,
|
||||
EnlightenmentResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const enlightenmentRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
enlightenmentRouter.use("*", authMiddleware);
|
||||
|
||||
enlightenmentRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (!isEligibleForEnlightenment(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Not eligible for enlightenment — defeat the Divine Heart Sovereign first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const { stardustEarned, updatedGoddess } = buildPostEnlightenmentState(state);
|
||||
|
||||
const updatedEnlightenmentCount = updatedGoddess.enlightenment.count;
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
goddess: updatedGoddess,
|
||||
resources: {
|
||||
...state.resources,
|
||||
prayers: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("enlightenment", 1, { discordId, updatedEnlightenmentCount });
|
||||
|
||||
const response: EnlightenmentResponse = {
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||
newEnlightenmentCount: updatedEnlightenmentCount,
|
||||
stardustEarned: stardustEarned,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"enlightenment",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
enlightenmentRouter.post("/buy-upgrade", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuyEnlightenmentUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultEnlightenmentUpgrades.find((enlightenmentUpgrade) => {
|
||||
return enlightenmentUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown enlightenment upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, stardust } = state.goddess.enlightenment;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (stardust < upgrade.cost) {
|
||||
return context.json({ error: "Not enough stardust" }, 400);
|
||||
}
|
||||
|
||||
const updatedStardust = stardust - upgrade.cost;
|
||||
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedMultipliers = computeEnlightenmentMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
goddess: {
|
||||
...state.goddess,
|
||||
enlightenment: {
|
||||
...state.goddess.enlightenment,
|
||||
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
stardust: updatedStardust,
|
||||
...updatedMultipliers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("enlightenment_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
return context.json({
|
||||
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
stardustRemaining: updatedStardust,
|
||||
...updatedMultipliers,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"enlightenment_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { enlightenmentRouter };
|
||||
@@ -720,6 +720,341 @@ const validateAndSanitize = (
|
||||
dailyChallengesSpread = { dailyChallenges: previous.dailyChallenges };
|
||||
}
|
||||
|
||||
/*
|
||||
* Goddess state: preserve server-only currencies (divinity, stardust, prayers) at
|
||||
* previous values, and apply the same forward-only rules to bosses/quests/achievements
|
||||
* and exploration materials that the mortal realm uses.
|
||||
* Prayers income will be computed and allowed to grow once Chunk 7 adds goddess tick logic.
|
||||
*/
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 145 -- @preserve */
|
||||
let goddessSpread: object = {};
|
||||
const previousGoddess = previous.goddess;
|
||||
const incomingGoddess = incoming.goddess;
|
||||
if (!incomingGoddess && previousGoddess) {
|
||||
goddessSpread = { goddess: previousGoddess };
|
||||
} else if (incomingGoddess) {
|
||||
const goddessBosses = incomingGoddess.bosses.map((boss) => {
|
||||
const matchingBoss = previousGoddess?.bosses.find((storedBoss) => {
|
||||
return storedBoss.id === boss.id;
|
||||
});
|
||||
if (!matchingBoss) {
|
||||
return boss;
|
||||
}
|
||||
if (matchingBoss.status === "defeated" && boss.status !== "defeated") {
|
||||
return { ...boss, currentHp: 0, status: "defeated" as const };
|
||||
}
|
||||
return boss;
|
||||
});
|
||||
const goddessQuests = incomingGoddess.quests.map((quest) => {
|
||||
const matchingQuest = previousGoddess?.quests.find((storedQuest) => {
|
||||
return storedQuest.id === quest.id;
|
||||
});
|
||||
if (!matchingQuest) {
|
||||
return quest;
|
||||
}
|
||||
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
|
||||
if (matchingQuest.status === "completed" && quest.status !== "completed") {
|
||||
return { ...matchingQuest };
|
||||
}
|
||||
return quest;
|
||||
});
|
||||
// eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability
|
||||
const goddessAchievements = incomingGoddess.achievements.map((achievement) => {
|
||||
const matchingAchievement = previousGoddess?.achievements.find(
|
||||
(storedAchievement) => {
|
||||
return storedAchievement.id === achievement.id;
|
||||
},
|
||||
);
|
||||
if (!matchingAchievement) {
|
||||
return achievement;
|
||||
}
|
||||
const wasUnlocked = matchingAchievement.unlockedAt !== null;
|
||||
const isNowNull = achievement.unlockedAt === null;
|
||||
if (wasUnlocked && isNowNull) {
|
||||
return { ...achievement, unlockedAt: matchingAchievement.unlockedAt };
|
||||
}
|
||||
const isFuture
|
||||
= achievement.unlockedAt !== null && achievement.unlockedAt > now;
|
||||
if (isFuture) {
|
||||
const safeUnlockedAt = matchingAchievement.unlockedAt ?? null;
|
||||
return { ...achievement, unlockedAt: safeUnlockedAt };
|
||||
}
|
||||
return achievement;
|
||||
});
|
||||
const previousGoddessExploration = previousGoddess?.exploration;
|
||||
let goddessExploration = incomingGoddess.exploration;
|
||||
if (previousGoddessExploration) {
|
||||
const previousMaterialMap = new Map(
|
||||
previousGoddessExploration.materials.map((mat) => {
|
||||
return [ mat.materialId, mat.quantity ] as const;
|
||||
}),
|
||||
);
|
||||
// eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability
|
||||
const materials = incomingGoddess.exploration.materials.map((material) => {
|
||||
const previousQuantity
|
||||
= previousMaterialMap.get(material.materialId) ?? 0;
|
||||
return {
|
||||
...material,
|
||||
quantity: Math.min(material.quantity, previousQuantity),
|
||||
};
|
||||
});
|
||||
const goddessRecipeIds = [
|
||||
...new Set([
|
||||
...previousGoddessExploration.craftedRecipeIds,
|
||||
...incomingGoddess.exploration.craftedRecipeIds,
|
||||
]),
|
||||
];
|
||||
goddessExploration = {
|
||||
...incomingGoddess.exploration,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
craftedCombatMultiplier: previousGoddessExploration.craftedCombatMultiplier,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
craftedDivinityMultiplier: previousGoddessExploration.craftedDivinityMultiplier,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
craftedPrayersMultiplier: previousGoddessExploration.craftedPrayersMultiplier,
|
||||
craftedRecipeIds: goddessRecipeIds,
|
||||
materials: materials,
|
||||
};
|
||||
}
|
||||
const consecration = previousGoddess
|
||||
? {
|
||||
...incomingGoddess.consecration,
|
||||
count: Math.min(
|
||||
incomingGoddess.consecration.count,
|
||||
previousGoddess.consecration.count,
|
||||
),
|
||||
divinity: Math.min(
|
||||
incomingGoddess.consecration.divinity,
|
||||
previousGoddess.consecration.divinity,
|
||||
),
|
||||
|
||||
productionMultiplier: previousGoddess.consecration.productionMultiplier,
|
||||
}
|
||||
: incomingGoddess.consecration;
|
||||
const enlightenment = previousGoddess
|
||||
? {
|
||||
...incomingGoddess.enlightenment,
|
||||
count: Math.min(
|
||||
incomingGoddess.enlightenment.count,
|
||||
previousGoddess.enlightenment.count,
|
||||
),
|
||||
stardust: Math.min(
|
||||
incomingGoddess.enlightenment.stardust,
|
||||
previousGoddess.enlightenment.stardust,
|
||||
),
|
||||
stardustCombatMultiplier:
|
||||
previousGoddess.enlightenment.stardustCombatMultiplier,
|
||||
|
||||
stardustConsecrationDivinityMultiplier:
|
||||
previousGoddess.enlightenment.stardustConsecrationDivinityMultiplier,
|
||||
|
||||
stardustConsecrationThresholdMultiplier:
|
||||
previousGoddess.enlightenment.stardustConsecrationThresholdMultiplier,
|
||||
stardustMetaMultiplier:
|
||||
previousGoddess.enlightenment.stardustMetaMultiplier,
|
||||
stardustPrayersMultiplier:
|
||||
previousGoddess.enlightenment.stardustPrayersMultiplier,
|
||||
}
|
||||
: incomingGoddess.enlightenment;
|
||||
goddessSpread = {
|
||||
goddess: {
|
||||
...incomingGoddess,
|
||||
achievements: goddessAchievements,
|
||||
bosses: goddessBosses,
|
||||
consecration: consecration,
|
||||
enlightenment: enlightenment,
|
||||
exploration: goddessExploration,
|
||||
lifetimeBossesDefeated: Math.min(
|
||||
incomingGoddess.lifetimeBossesDefeated,
|
||||
previousGoddess?.lifetimeBossesDefeated ?? 0,
|
||||
),
|
||||
lifetimePrayersEarned: Math.min(
|
||||
incomingGoddess.lifetimePrayersEarned,
|
||||
previousGoddess?.lifetimePrayersEarned ?? 0,
|
||||
),
|
||||
lifetimeQuestsCompleted: Math.min(
|
||||
incomingGoddess.lifetimeQuestsCompleted,
|
||||
previousGoddess?.lifetimeQuestsCompleted ?? 0,
|
||||
),
|
||||
quests: goddessQuests,
|
||||
totalPrayersEarned: Math.min(
|
||||
incomingGoddess.totalPrayersEarned,
|
||||
previousGoddess?.totalPrayersEarned ?? 0,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Vampire state: preserve server-only currencies (ichor, soul shards, blood) at
|
||||
* previous values, and apply the same forward-only rules to bosses/quests/achievements
|
||||
* and exploration materials that the mortal and goddess realms use.
|
||||
* Blood income will be computed and allowed to grow once Chunk 7 adds vampire tick logic.
|
||||
*/
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 160 -- @preserve */
|
||||
let vampireSpread: object = {};
|
||||
const previousVampire = previous.vampire;
|
||||
const incomingVampire = incoming.vampire;
|
||||
if (!incomingVampire && previousVampire) {
|
||||
vampireSpread = { vampire: previousVampire };
|
||||
} else if (incomingVampire) {
|
||||
const vampireBosses = incomingVampire.bosses.map((boss) => {
|
||||
const matchingBoss = previousVampire?.bosses.find((storedBoss) => {
|
||||
return storedBoss.id === boss.id;
|
||||
});
|
||||
if (!matchingBoss) {
|
||||
return boss;
|
||||
}
|
||||
if (matchingBoss.status === "defeated" && boss.status !== "defeated") {
|
||||
return { ...boss, currentHp: 0, status: "defeated" as const };
|
||||
}
|
||||
return boss;
|
||||
});
|
||||
const vampireQuests = incomingVampire.quests.map((quest) => {
|
||||
const matchingQuest = previousVampire?.quests.find((storedQuest) => {
|
||||
return storedQuest.id === quest.id;
|
||||
});
|
||||
if (!matchingQuest) {
|
||||
return quest;
|
||||
}
|
||||
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
|
||||
if (matchingQuest.status === "completed" && quest.status !== "completed") {
|
||||
return { ...matchingQuest };
|
||||
}
|
||||
return quest;
|
||||
});
|
||||
// eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability
|
||||
const vampireAchievements = incomingVampire.achievements.map((achievement) => {
|
||||
const matchingAchievement = previousVampire?.achievements.find(
|
||||
(storedAchievement) => {
|
||||
return storedAchievement.id === achievement.id;
|
||||
},
|
||||
);
|
||||
if (!matchingAchievement) {
|
||||
return achievement;
|
||||
}
|
||||
const wasUnlocked = matchingAchievement.unlockedAt !== null;
|
||||
const isNowNull = achievement.unlockedAt === null;
|
||||
if (wasUnlocked && isNowNull) {
|
||||
return { ...achievement, unlockedAt: matchingAchievement.unlockedAt };
|
||||
}
|
||||
const isFuture
|
||||
= achievement.unlockedAt !== null && achievement.unlockedAt > now;
|
||||
if (isFuture) {
|
||||
const safeUnlockedAt = matchingAchievement.unlockedAt ?? null;
|
||||
return { ...achievement, unlockedAt: safeUnlockedAt };
|
||||
}
|
||||
return achievement;
|
||||
});
|
||||
const previousVampireExploration = previousVampire?.exploration;
|
||||
let vampireExploration = incomingVampire.exploration;
|
||||
if (previousVampireExploration) {
|
||||
const previousMaterialMap = new Map(
|
||||
previousVampireExploration.materials.map((mat) => {
|
||||
return [ mat.materialId, mat.quantity ] as const;
|
||||
}),
|
||||
);
|
||||
// eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability
|
||||
const materials = incomingVampire.exploration.materials.map((material) => {
|
||||
const previousQuantity
|
||||
= previousMaterialMap.get(material.materialId) ?? 0;
|
||||
return {
|
||||
...material,
|
||||
quantity: Math.min(material.quantity, previousQuantity),
|
||||
};
|
||||
});
|
||||
const vampireRecipeIds = [
|
||||
...new Set([
|
||||
...previousVampireExploration.craftedRecipeIds,
|
||||
...incomingVampire.exploration.craftedRecipeIds,
|
||||
]),
|
||||
];
|
||||
vampireExploration = {
|
||||
...incomingVampire.exploration,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
craftedBloodMultiplier: previousVampireExploration.craftedBloodMultiplier,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
craftedCombatMultiplier: previousVampireExploration.craftedCombatMultiplier,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
craftedIchorMultiplier: previousVampireExploration.craftedIchorMultiplier,
|
||||
craftedRecipeIds: vampireRecipeIds,
|
||||
materials: materials,
|
||||
};
|
||||
}
|
||||
const siring = previousVampire
|
||||
? {
|
||||
...incomingVampire.siring,
|
||||
count: Math.min(
|
||||
incomingVampire.siring.count,
|
||||
previousVampire.siring.count,
|
||||
),
|
||||
ichor: Math.min(
|
||||
incomingVampire.siring.ichor,
|
||||
previousVampire.siring.ichor,
|
||||
),
|
||||
productionMultiplier: previousVampire.siring.productionMultiplier,
|
||||
}
|
||||
: incomingVampire.siring;
|
||||
const awakening = previousVampire
|
||||
? {
|
||||
...incomingVampire.awakening,
|
||||
count: Math.min(
|
||||
incomingVampire.awakening.count,
|
||||
previousVampire.awakening.count,
|
||||
),
|
||||
soulShards: Math.min(
|
||||
incomingVampire.awakening.soulShards,
|
||||
previousVampire.awakening.soulShards,
|
||||
),
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
soulShardsBloodMultiplier: previousVampire.awakening.soulShardsBloodMultiplier,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
soulShardsCombatMultiplier: previousVampire.awakening.soulShardsCombatMultiplier,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
soulShardsMetaMultiplier: previousVampire.awakening.soulShardsMetaMultiplier,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
soulShardsSiringIchorMultiplier: previousVampire.awakening.soulShardsSiringIchorMultiplier,
|
||||
// eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability
|
||||
soulShardsSiringThresholdMultiplier: previousVampire.awakening.soulShardsSiringThresholdMultiplier,
|
||||
}
|
||||
: incomingVampire.awakening;
|
||||
vampireSpread = {
|
||||
vampire: {
|
||||
...incomingVampire,
|
||||
achievements: vampireAchievements,
|
||||
awakening: awakening,
|
||||
bosses: vampireBosses,
|
||||
eternalSovereignty: {
|
||||
count: Math.min(
|
||||
incomingVampire.eternalSovereignty.count,
|
||||
previousVampire?.eternalSovereignty.count ?? 0,
|
||||
),
|
||||
},
|
||||
exploration: vampireExploration,
|
||||
lifetimeBloodEarned: Math.min(
|
||||
incomingVampire.lifetimeBloodEarned,
|
||||
previousVampire?.lifetimeBloodEarned ?? 0,
|
||||
),
|
||||
lifetimeBossesDefeated: Math.min(
|
||||
incomingVampire.lifetimeBossesDefeated,
|
||||
previousVampire?.lifetimeBossesDefeated ?? 0,
|
||||
),
|
||||
lifetimeQuestsCompleted: Math.min(
|
||||
incomingVampire.lifetimeQuestsCompleted,
|
||||
previousVampire?.lifetimeQuestsCompleted ?? 0,
|
||||
),
|
||||
quests: vampireQuests,
|
||||
siring: siring,
|
||||
totalBloodEarned: Math.min(
|
||||
incomingVampire.totalBloodEarned,
|
||||
previousVampire?.totalBloodEarned ?? 0,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
achievements,
|
||||
@@ -733,6 +1068,8 @@ const validateAndSanitize = (
|
||||
...explorationSpread,
|
||||
...storySpread,
|
||||
...dailyChallengesSpread,
|
||||
...goddessSpread,
|
||||
...vampireSpread,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* @file Goddess boss challenge route handling divine combat mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Boss handler requires many steps */
|
||||
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
||||
import { createHmac } from "node:crypto";
|
||||
import {
|
||||
computeGoddessSetBonuses,
|
||||
type GameState,
|
||||
type GoddessBossChallengeResponse,
|
||||
} from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { defaultGoddessBosses } from "../data/goddessBosses.js";
|
||||
import { defaultGoddessEquipmentSets } from "../data/goddessEquipmentSets.js";
|
||||
import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
|
||||
/**
|
||||
* Computes the HMAC-SHA256 of data using the given secret.
|
||||
* @param data - The data string to sign.
|
||||
* @param secret - The HMAC secret key.
|
||||
* @returns The hex-encoded HMAC digest.
|
||||
*/
|
||||
const computeHmac = (data: string, secret: string): string => {
|
||||
return createHmac("sha256", secret).update(data).
|
||||
digest("hex");
|
||||
};
|
||||
|
||||
const goddessBossRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
goddessBossRouter.use("*", authMiddleware);
|
||||
|
||||
const calculateDiscipleStats = (
|
||||
goddess: NonNullable<GameState["goddess"]>,
|
||||
): { partyDPS: number; partyMaxHp: number } => {
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of goddess.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply consecration production multiplier as a combat boost
|
||||
const consecrationCombatMultiplier
|
||||
= goddess.consecration.divinityCombatMultiplier ?? 1;
|
||||
const { stardustCombatMultiplier } = goddess.enlightenment;
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equipmentCombatMultiplier = goddess.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equippedItemIds = goddess.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const { combatMultiplier: setCombatMultiplier } = computeGoddessSetBonuses(
|
||||
equippedItemIds,
|
||||
defaultGoddessEquipmentSets,
|
||||
);
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyMaxHp = 0;
|
||||
|
||||
for (const disciple of goddess.disciples) {
|
||||
if (disciple.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let discipleMultiplier = 1;
|
||||
for (const upgrade of goddess.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "disciple"
|
||||
&& upgrade.discipleId === disciple.id
|
||||
) {
|
||||
discipleMultiplier = discipleMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const discipleContribution
|
||||
= disciple.combatPower
|
||||
* disciple.count
|
||||
* discipleMultiplier
|
||||
* globalMultiplier;
|
||||
partyDPS = partyDPS + discipleContribution;
|
||||
|
||||
const discipleHp = disciple.level * 50 * disciple.count;
|
||||
partyMaxHp = partyMaxHp + discipleHp;
|
||||
}
|
||||
|
||||
const { craftedCombatMultiplier } = goddess.exploration;
|
||||
|
||||
partyDPS = partyDPS
|
||||
* equipmentCombatMultiplier
|
||||
* setCombatMultiplier
|
||||
* consecrationCombatMultiplier
|
||||
* stardustCombatMultiplier
|
||||
* craftedCombatMultiplier;
|
||||
|
||||
return { partyDPS, partyMaxHp };
|
||||
};
|
||||
|
||||
goddessBossRouter.post("/challenge", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<{ bossId: string }>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.bossId) {
|
||||
return context.json({ error: "Invalid request body" }, 400);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
const boss = goddess.bosses.find((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
|
||||
if (!boss) {
|
||||
return context.json({ error: "Boss not found" }, 404);
|
||||
}
|
||||
|
||||
if (boss.status !== "available" && boss.status !== "in_progress") {
|
||||
return context.json({ error: "Boss is not currently available" }, 400);
|
||||
}
|
||||
|
||||
if (boss.consecrationRequirement > goddess.consecration.count) {
|
||||
return context.json({ error: "Consecration requirement not met" }, 403);
|
||||
}
|
||||
|
||||
const { partyDPS, partyMaxHp } = calculateDiscipleStats(goddess);
|
||||
|
||||
if (
|
||||
partyDPS === 0
|
||||
|| partyMaxHp === 0
|
||||
|| !Number.isFinite(partyDPS)
|
||||
|| !Number.isFinite(partyMaxHp)
|
||||
) {
|
||||
return context.json(
|
||||
{ error: "Your disciples have no combat power" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const bossHpBefore = boss.currentHp;
|
||||
const bossDPS = boss.damagePerSecond;
|
||||
|
||||
const timeToKillBoss = bossHpBefore / partyDPS;
|
||||
const timeToKillParty = partyMaxHp / bossDPS;
|
||||
|
||||
const won = timeToKillBoss <= timeToKillParty;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let partyHpRemaining: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let bossHpAtBattleEnd: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let bossUpdatedHp: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||
let rewards: GoddessBossChallengeResponse["rewards"];
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||
let casualties: GoddessBossChallengeResponse["casualties"];
|
||||
|
||||
if (won) {
|
||||
bossHpAtBattleEnd = 0;
|
||||
bossUpdatedHp = 0;
|
||||
const bossDamageDealt = bossDPS * timeToKillBoss;
|
||||
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
|
||||
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
// eslint-disable-next-line unicorn/consistent-destructuring -- mutation requires direct property access on state.resources
|
||||
state.resources.prayers = (state.resources.prayers ?? 0) + boss.prayersReward;
|
||||
goddess.totalPrayersEarned
|
||||
= goddess.totalPrayersEarned + boss.prayersReward;
|
||||
goddess.lifetimePrayersEarned
|
||||
= goddess.lifetimePrayersEarned + boss.prayersReward;
|
||||
goddess.consecration.divinity
|
||||
= goddess.consecration.divinity + boss.divinityReward;
|
||||
goddess.enlightenment.stardust
|
||||
= goddess.enlightenment.stardust + boss.stardustReward;
|
||||
goddess.lifetimeBossesDefeated
|
||||
= goddess.lifetimeBossesDefeated + 1;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
const upgrade = goddess.upgrades.find((u) => {
|
||||
return u.id === upgradeId;
|
||||
});
|
||||
if (upgrade) {
|
||||
upgrade.unlocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Grant equipment rewards — auto-equip if the slot is currently empty
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 14 -- @preserve */
|
||||
for (const equipmentId of boss.equipmentRewards) {
|
||||
const equipment = goddess.equipment.find((item) => {
|
||||
return item.id === equipmentId;
|
||||
});
|
||||
if (equipment) {
|
||||
equipment.owned = true;
|
||||
const slotAlreadyEquipped = goddess.equipment.some((item) => {
|
||||
return item.type === equipment.type && item.equipped;
|
||||
});
|
||||
if (!slotAlreadyEquipped) {
|
||||
equipment.equipped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock next boss in the same zone
|
||||
const zoneBosses = goddess.bosses.filter((b) => {
|
||||
return b.zoneId === boss.zoneId;
|
||||
});
|
||||
const zoneIndex = zoneBosses.findIndex((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
|
||||
if (
|
||||
nextZoneBoss
|
||||
&& nextZoneBoss.consecrationRequirement <= goddess.consecration.count
|
||||
) {
|
||||
const nextBossInState = goddess.bosses.find((b) => {
|
||||
return b.id === nextZoneBoss.id;
|
||||
});
|
||||
if (nextBossInState) {
|
||||
nextBossInState.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock zones whose conditions are now both satisfied
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
for (const zone of goddess.zones) {
|
||||
if (zone.status === "unlocked") {
|
||||
continue;
|
||||
}
|
||||
if (zone.unlockBossId !== body.bossId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const questSatisfied
|
||||
= zone.unlockQuestId === null
|
||||
|| goddess.quests.some((q) => {
|
||||
return q.id === zone.unlockQuestId && q.status === "completed";
|
||||
});
|
||||
if (!questSatisfied) {
|
||||
continue;
|
||||
}
|
||||
zone.status = "unlocked";
|
||||
|
||||
// Unlock exploration areas for the newly unlocked zone
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 9 -- @preserve */
|
||||
for (const area of goddess.exploration.areas) {
|
||||
const areaDefinition = defaultGoddessExplorationAreas.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
|
||||
area.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
const updatedZoneBosses = goddess.bosses.filter((b) => {
|
||||
return b.zoneId === zone.id;
|
||||
});
|
||||
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
||||
if (
|
||||
firstUpdatedBoss
|
||||
&& firstUpdatedBoss.consecrationRequirement <= goddess.consecration.count
|
||||
) {
|
||||
firstUpdatedBoss.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
// First-kill divinity bounty — only awarded once
|
||||
const staticBoss = defaultGoddessBosses.find((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const bountyDivinity
|
||||
= boss.bountyDivinityClaimed === true
|
||||
? 0
|
||||
: staticBoss?.bountyDivinity ?? 0;
|
||||
if (bountyDivinity > 0) {
|
||||
boss.bountyDivinityClaimed = true;
|
||||
}
|
||||
goddess.consecration.divinity
|
||||
= goddess.consecration.divinity + bountyDivinity;
|
||||
|
||||
rewards = {
|
||||
bountyDivinity: bountyDivinity,
|
||||
divinity: boss.divinityReward,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
prayers: boss.prayersReward,
|
||||
stardust: boss.stardustReward,
|
||||
upgradeIds: boss.upgradeRewards,
|
||||
};
|
||||
} else {
|
||||
const partyDamageDealt = partyDPS * timeToKillParty;
|
||||
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
|
||||
bossUpdatedHp = boss.maxHp;
|
||||
partyHpRemaining = 0;
|
||||
|
||||
boss.status = "available";
|
||||
boss.currentHp = boss.maxHp;
|
||||
|
||||
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
||||
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
||||
|
||||
casualties = [];
|
||||
for (const disciple of goddess.disciples) {
|
||||
if (disciple.count === 0) {
|
||||
continue;
|
||||
}
|
||||
const killed = Math.floor(disciple.count * casualtyFraction);
|
||||
if (killed > 0) {
|
||||
disciple.count = Math.max(1, disciple.count - killed);
|
||||
|
||||
casualties.push({ discipleId: disciple.id, killed: killed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const updatedSignature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
|
||||
const { bossId } = body;
|
||||
void logger.metric("goddess_boss_challenge", 1, { bossId, discordId, won });
|
||||
|
||||
const bossMaxHp = boss.maxHp;
|
||||
const bossNewHp = bossUpdatedHp;
|
||||
const response: GoddessBossChallengeResponse = {
|
||||
bossDPS,
|
||||
bossHpAtBattleEnd,
|
||||
bossHpBefore,
|
||||
bossMaxHp,
|
||||
bossNewHp,
|
||||
partyDPS,
|
||||
partyHpRemaining,
|
||||
partyMaxHp,
|
||||
won,
|
||||
};
|
||||
if (rewards !== undefined) {
|
||||
response.rewards = rewards;
|
||||
}
|
||||
if (casualties !== undefined) {
|
||||
response.casualties = casualties;
|
||||
}
|
||||
if (updatedSignature !== undefined) {
|
||||
response.signature = updatedSignature;
|
||||
}
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_boss_challenge",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { goddessBossRouter };
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @file Goddess crafting route handling divine recipe crafting.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||
/* eslint-disable complexity -- Route handler has inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultGoddessCraftingRecipes } from "../data/goddessCrafting.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
GoddessCraftRequest,
|
||||
GoddessCraftResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const goddessCraftRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
goddessCraftRouter.use("*", authMiddleware);
|
||||
|
||||
const recomputeGoddessCraftedMultipliers = (
|
||||
craftedRecipeIds: Array<string>,
|
||||
): {
|
||||
craftedPrayersMultiplier: number;
|
||||
craftedDivinityMultiplier: number;
|
||||
craftedCombatMultiplier: number;
|
||||
} => {
|
||||
return {
|
||||
craftedCombatMultiplier: defaultGoddessCraftingRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
|
||||
}).reduce((mult, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return mult * recipe.bonus.value;
|
||||
}, 1),
|
||||
craftedDivinityMultiplier: defaultGoddessCraftingRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
|
||||
}).reduce((mult, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return mult * recipe.bonus.value;
|
||||
}, 1),
|
||||
craftedPrayersMultiplier: defaultGoddessCraftingRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
|
||||
}).reduce((mult, recipe) => {
|
||||
return mult * recipe.bonus.value;
|
||||
}, 1),
|
||||
};
|
||||
};
|
||||
|
||||
goddessCraftRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<GoddessCraftRequest>();
|
||||
|
||||
const { recipeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!recipeId) {
|
||||
return context.json({ error: "recipeId is required" }, 400);
|
||||
}
|
||||
|
||||
const recipe = defaultGoddessCraftingRecipes.find((r) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (state.goddess.exploration.craftedRecipeIds.includes(recipeId)) {
|
||||
return context.json({ error: "Recipe already crafted" }, 400);
|
||||
}
|
||||
|
||||
// Verify the player has all required sacred materials
|
||||
for (const requirement of recipe.requiredMaterials) {
|
||||
const material = state.goddess.exploration.materials.find((m) => {
|
||||
return 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 sacred materials
|
||||
for (const requirement of recipe.requiredMaterials) {
|
||||
const material = state.goddess.exploration.materials.find((m) => {
|
||||
return m.materialId === requirement.materialId;
|
||||
});
|
||||
if (material) {
|
||||
material.quantity = material.quantity - requirement.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// Add recipe and recompute all multipliers from scratch
|
||||
|
||||
state.goddess.exploration.craftedRecipeIds.push(recipeId);
|
||||
|
||||
const updatedMultipliers = recomputeGoddessCraftedMultipliers(
|
||||
state.goddess.exploration.craftedRecipeIds,
|
||||
);
|
||||
state.goddess.exploration.craftedPrayersMultiplier
|
||||
= updatedMultipliers.craftedPrayersMultiplier;
|
||||
state.goddess.exploration.craftedDivinityMultiplier
|
||||
= updatedMultipliers.craftedDivinityMultiplier;
|
||||
state.goddess.exploration.craftedCombatMultiplier
|
||||
= updatedMultipliers.craftedCombatMultiplier;
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("goddess_recipe_crafted", 1, { discordId, recipeId });
|
||||
|
||||
const bonusType = recipe.bonus.type;
|
||||
const bonusValue = recipe.bonus.value;
|
||||
|
||||
const { materials } = state.goddess.exploration;
|
||||
const {
|
||||
craftedPrayersMultiplier,
|
||||
craftedDivinityMultiplier,
|
||||
craftedCombatMultiplier,
|
||||
} = updatedMultipliers;
|
||||
|
||||
const response: GoddessCraftResponse = {
|
||||
bonusType,
|
||||
bonusValue,
|
||||
craftedCombatMultiplier,
|
||||
craftedDivinityMultiplier,
|
||||
craftedPrayersMultiplier,
|
||||
materials,
|
||||
recipeId,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_craft",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { goddessCraftRouter };
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* @file Goddess exploration routes handling divine area exploration mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable max-lines -- Route file requires multiple handlers */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
GoddessExploreClaimableResponse,
|
||||
GoddessExploreCollectEventResult,
|
||||
GoddessExploreCollectRequest,
|
||||
GoddessExploreCollectResponse,
|
||||
GoddessExploreStartRequest,
|
||||
GoddessExploreStartResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const goddessExploreRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
goddessExploreRouter.use("*", authMiddleware);
|
||||
|
||||
const nothingProbability = 0.2;
|
||||
|
||||
const nothingMessages = [
|
||||
"Your disciples searched every corner of the divine realm but found nothing of value.",
|
||||
"The sacred area yielded nothing remarkable this time.",
|
||||
"Your disciples returned empty-handed from the divine realm.",
|
||||
"A wasted journey — the sacred area proved barren.",
|
||||
"Nothing to show for the devotion. Perhaps next time.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random "nothing found" message.
|
||||
* @returns A random message string.
|
||||
*/
|
||||
const pickNothingMessage = (): string => {
|
||||
const index = Math.floor(Math.random() * nothingMessages.length);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||
};
|
||||
|
||||
goddessExploreRouter.get("/claimable", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const areaId = context.req.query("areaId");
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultGoddessExplorationAreas.find((a) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
const response: GoddessExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
const area = state.goddess.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
|
||||
if (!area || area.status !== "in_progress") {
|
||||
const response: GoddessExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
const claimable = Date.now() >= expiresAt;
|
||||
const response: GoddessExploreClaimableResponse = { claimable };
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_explore_claimable",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
goddessExploreRouter.post("/start", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<GoddessExploreStartRequest>();
|
||||
|
||||
const { areaId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultGoddessExplorationAreas.find((a) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const zone = state.goddess.zones.find((z) => {
|
||||
return z.id === explorationArea.zoneId;
|
||||
});
|
||||
if (!zone || zone.status !== "unlocked") {
|
||||
return context.json({ error: "Zone is not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const area = state.goddess.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!area) {
|
||||
return context.json(
|
||||
{ error: "Exploration area not found in state" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const anyInProgress = state.goddess.exploration.areas.some((a) => {
|
||||
return a.status === "in_progress";
|
||||
});
|
||||
if (anyInProgress) {
|
||||
return context.json(
|
||||
{ error: "An exploration is already in progress" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (area.status === "locked") {
|
||||
return context.json({ error: "Exploration area is locked" }, 400);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||
area.status = "in_progress";
|
||||
area.startedAt = now;
|
||||
area.endsAt = endsAt;
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const response: GoddessExploreStartResponse = {
|
||||
areaId,
|
||||
endsAt,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_explore_start",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
goddessExploreRouter.post("/collect", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<GoddessExploreCollectRequest>();
|
||||
|
||||
const { areaId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultGoddessExplorationAreas.find((a) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const area = state.goddess.exploration.areas.find((a) => {
|
||||
return 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();
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
|
||||
if (now < expiresAt) {
|
||||
return context.json({ error: "Exploration is not yet complete" }, 400);
|
||||
}
|
||||
|
||||
area.status = "available";
|
||||
area.completedOnce = true;
|
||||
|
||||
// 20% chance of finding nothing
|
||||
if (Math.random() < nothingProbability) {
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const response: GoddessExploreCollectResponse = {
|
||||
event: null,
|
||||
foundNothing: true,
|
||||
materialsFound: [],
|
||||
nothingMessage: pickNothingMessage(),
|
||||
};
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// Pick a random event
|
||||
const eventIndex = Math.floor(
|
||||
Math.random() * explorationArea.events.length,
|
||||
);
|
||||
const event = explorationArea.events[eventIndex];
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!event) {
|
||||
return context.json({ error: "No events available" }, 500);
|
||||
}
|
||||
|
||||
// Apply event effects and build the result summary
|
||||
let prayersChange = 0;
|
||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||
|
||||
if (event.effect.type === "prayers_gain") {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.prayers = (state.resources.prayers ?? 0) + amount;
|
||||
state.goddess.totalPrayersEarned = state.goddess.totalPrayersEarned + amount;
|
||||
prayersChange = amount;
|
||||
} else if (event.effect.type === "prayers_loss") {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const amount = Math.min(state.resources.prayers ?? 0, event.effect.amount ?? 0);
|
||||
state.resources.prayers = (state.resources.prayers ?? 0) - amount;
|
||||
prayersChange = -amount;
|
||||
} else if (event.effect.type === "divinity_gain") {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.goddess.consecration.divinity = state.goddess.consecration.divinity + amount;
|
||||
} else if (event.effect.type === "sacred_material_gain") {
|
||||
const { materialId } = event.effect;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const quantity = event.effect.quantity ?? 1;
|
||||
if (materialId !== undefined && materialId !== "") {
|
||||
const existing = state.goddess.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.goddess.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
materialGained = { materialId, quantity };
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 13 -- @preserve */
|
||||
}
|
||||
} else if (event.effect.type === "disciple_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const fraction = event.effect.fraction ?? 0.05;
|
||||
for (const disciple of state.goddess.disciples) {
|
||||
const lost = Math.floor(disciple.count * fraction);
|
||||
if (lost > 0) {
|
||||
disciple.count = Math.max(0, disciple.count - lost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let discipleLostCount = 0;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
if (event.effect.type === "disciple_loss") {
|
||||
const fraction = event.effect.fraction ?? 0.05;
|
||||
for (const disciple of state.goddess.disciples) {
|
||||
const lost = Math.floor(disciple.count * fraction);
|
||||
discipleLostCount = discipleLostCount + lost;
|
||||
}
|
||||
}
|
||||
|
||||
const eventResult: GoddessExploreCollectEventResult = {
|
||||
discipleLostCount: discipleLostCount,
|
||||
materialGained: materialGained,
|
||||
prayersChange: prayersChange,
|
||||
text: event.text,
|
||||
};
|
||||
|
||||
// Roll for sacred material drops from possibleMaterials (weighted random selection)
|
||||
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
||||
|
||||
if (explorationArea.possibleMaterials.length > 0) {
|
||||
let totalWeight = 0;
|
||||
for (const materialDrop of explorationArea.possibleMaterials) {
|
||||
totalWeight = totalWeight + materialDrop.weight;
|
||||
}
|
||||
let roll = Math.random() * totalWeight;
|
||||
|
||||
for (const possible of explorationArea.possibleMaterials) {
|
||||
roll = roll - possible.weight;
|
||||
if (roll <= 0) {
|
||||
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
||||
const range = maxMinDiff + 1;
|
||||
const randomOffset = Math.floor(Math.random() * range);
|
||||
const quantity = randomOffset + possible.minQuantity;
|
||||
const { materialId } = possible;
|
||||
|
||||
const existing = state.goddess.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.goddess.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
|
||||
materialsFound.push({ materialId, quantity });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const response: GoddessExploreCollectResponse = {
|
||||
event: eventResult,
|
||||
foundNothing: false,
|
||||
materialsFound: materialsFound,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_explore_collect",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { goddessExploreRouter };
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file Goddess upgrade purchase route.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||
/* eslint-disable complexity -- Route handler has inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultGoddessUpgrades } from "../data/goddessUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
BuyGoddessUpgradeRequest,
|
||||
BuyGoddessUpgradeResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const goddessUpgradeRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
goddessUpgradeRouter.use("*", authMiddleware);
|
||||
|
||||
goddessUpgradeRouter.post("/buy", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuyGoddessUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgradeTemplate = defaultGoddessUpgrades.find((goddessUpgrade) => {
|
||||
return goddessUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgradeTemplate) {
|
||||
return context.json({ error: "Unknown goddess upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.goddess) {
|
||||
return context.json({ error: "Goddess realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = state.goddess.upgrades.find((u) => {
|
||||
return u.id === upgradeId;
|
||||
});
|
||||
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Upgrade not found in goddess state" }, 404);
|
||||
}
|
||||
|
||||
if (!upgrade.unlocked) {
|
||||
return context.json({ error: "Upgrade is not yet unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (upgrade.purchased) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
const currentPrayers = state.resources.prayers ?? 0;
|
||||
const currentDivinity = state.goddess.consecration.divinity;
|
||||
const currentStardust = state.goddess.enlightenment.stardust;
|
||||
|
||||
if (currentPrayers < upgradeTemplate.costPrayers) {
|
||||
return context.json({ error: "Not enough prayers" }, 400);
|
||||
}
|
||||
|
||||
if (currentDivinity < upgradeTemplate.costDivinity) {
|
||||
return context.json({ error: "Not enough divinity" }, 400);
|
||||
}
|
||||
|
||||
if (currentStardust < upgradeTemplate.costStardust) {
|
||||
return context.json({ error: "Not enough stardust" }, 400);
|
||||
}
|
||||
|
||||
upgrade.purchased = true;
|
||||
|
||||
const updatedPrayers = currentPrayers - upgradeTemplate.costPrayers;
|
||||
const updatedDivinity = currentDivinity - upgradeTemplate.costDivinity;
|
||||
const updatedStardust = currentStardust - upgradeTemplate.costStardust;
|
||||
|
||||
state.resources.prayers = updatedPrayers;
|
||||
state.goddess.consecration.divinity = updatedDivinity;
|
||||
state.goddess.enlightenment.stardust = updatedStardust;
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("goddess_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
|
||||
const response: BuyGoddessUpgradeResponse = {
|
||||
divinityRemaining: updatedDivinity,
|
||||
prayersRemaining: updatedPrayers,
|
||||
stardustRemaining: updatedStardust,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"goddess_upgrade_buy",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { goddessUpgradeRouter };
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file Siring routes handling siring resets and ichor upgrade purchases.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultVampireSiringUpgrades } from "../data/vampireSiringUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import {
|
||||
buildPostSiringState,
|
||||
calculateSiringThreshold,
|
||||
computeSiringIchorMultipliers,
|
||||
computeSiringThresholdMultiplier,
|
||||
isEligibleForSiring,
|
||||
} from "../services/siring.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
BuySiringUpgradeRequest,
|
||||
BuySiringUpgradeResponse,
|
||||
GameState,
|
||||
SiringResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
const siringRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
siringRouter.use("*", authMiddleware);
|
||||
|
||||
siringRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (!isEligibleForSiring(state)) {
|
||||
const threshold = calculateSiringThreshold(
|
||||
state.vampire.siring.count,
|
||||
computeSiringThresholdMultiplier(state.vampire.siring.purchasedUpgradeIds) * state.vampire.awakening.soulShardsSiringThresholdMultiplier,
|
||||
);
|
||||
return context.json(
|
||||
{
|
||||
error: `Not eligible for siring — earn ${threshold.toLocaleString()} total blood first`,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const { ichorEarned, updatedVampire } = buildPostSiringState(state);
|
||||
|
||||
const updatedSiringCount = updatedVampire.siring.count;
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
resources: { ...state.resources, blood: 0 },
|
||||
vampire: updatedVampire,
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("siring", 1, { discordId, updatedSiringCount });
|
||||
|
||||
const response: SiringResponse = {
|
||||
ichorEarned: ichorEarned,
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
|
||||
newSiringCount: updatedSiringCount,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"siring",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
siringRouter.post("/buy-upgrade", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuySiringUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultVampireSiringUpgrades.find((siringUpgrade) => {
|
||||
return siringUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown siring upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, ichor } = state.vampire.siring;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (ichor < upgrade.ichorCost) {
|
||||
return context.json({ error: "Not enough ichor" }, 400);
|
||||
}
|
||||
|
||||
const updatedIchor = ichor - upgrade.ichorCost;
|
||||
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedMultipliers = computeSiringIchorMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
vampire: {
|
||||
...state.vampire,
|
||||
siring: {
|
||||
...state.vampire.siring,
|
||||
ichor: updatedIchor,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...updatedMultipliers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("siring_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 6 -- @preserve */
|
||||
const response: BuySiringUpgradeResponse = {
|
||||
ichorBloodMultiplier: updatedMultipliers.ichorBloodMultiplier ?? 1,
|
||||
ichorCombatMultiplier: updatedMultipliers.ichorCombatMultiplier ?? 1,
|
||||
ichorRemaining: updatedIchor,
|
||||
ichorThrallsMultiplier: updatedMultipliers.ichorThrallsMultiplier ?? 1,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"siring_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { siringRouter };
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file Vampire Awakening routes handling awakening resets and soul shard upgrade purchases.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultVampireAwakeningUpgrades } from "../data/vampireAwakeningUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import {
|
||||
buildPostAwakeningState,
|
||||
computeAwakeningMultipliers,
|
||||
isEligibleForAwakening,
|
||||
} from "../services/awakening.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
AwakeningResponse,
|
||||
BuyAwakeningUpgradeRequest,
|
||||
BuyAwakeningUpgradeResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const vampireAwakeningRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
vampireAwakeningRouter.use("*", authMiddleware);
|
||||
|
||||
vampireAwakeningRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (!isEligibleForAwakening(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Not eligible for awakening — defeat the Eternal Darkness first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const { soulShardsEarned, updatedVampire } = buildPostAwakeningState(state);
|
||||
|
||||
const updatedAwakeningCount = updatedVampire.awakening.count;
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
resources: { ...state.resources, blood: 0, ichor: 0 },
|
||||
vampire: updatedVampire,
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("vampire_awakening", 1, { discordId, updatedAwakeningCount });
|
||||
|
||||
const response: AwakeningResponse = {
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API field name matches server response
|
||||
newAwakeningCount: updatedAwakeningCount,
|
||||
soulShardsEarned: soulShardsEarned,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"vampire_awakening",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
vampireAwakeningRouter.post("/buy-upgrade", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuyAwakeningUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultVampireAwakeningUpgrades.find((awakeningUpgrade) => {
|
||||
return awakeningUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown awakening upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, soulShards } = state.vampire.awakening;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (soulShards < upgrade.cost) {
|
||||
return context.json({ error: "Not enough soul shards" }, 400);
|
||||
}
|
||||
|
||||
const updatedSoulShards = soulShards - upgrade.cost;
|
||||
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedMultipliers = computeAwakeningMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
vampire: {
|
||||
...state.vampire,
|
||||
awakening: {
|
||||
...state.vampire.awakening,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
soulShards: updatedSoulShards,
|
||||
...updatedMultipliers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("vampire_awakening_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
|
||||
const response: BuyAwakeningUpgradeResponse = {
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
soulShardsBloodMultiplier: updatedMultipliers.soulShardsBloodMultiplier,
|
||||
soulShardsCombatMultiplier: updatedMultipliers.soulShardsCombatMultiplier,
|
||||
soulShardsMetaMultiplier: updatedMultipliers.soulShardsMetaMultiplier,
|
||||
soulShardsRemaining: updatedSoulShards,
|
||||
soulShardsSiringIchorMultiplier: updatedMultipliers.soulShardsSiringIchorMultiplier,
|
||||
soulShardsSiringThresholdMultiplier: updatedMultipliers.soulShardsSiringThresholdMultiplier,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"vampire_awakening_buy_upgrade",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { vampireAwakeningRouter };
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* @file Vampire boss challenge route handling blood combat mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Boss handler requires many steps */
|
||||
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
||||
import { createHmac } from "node:crypto";
|
||||
import {
|
||||
computeVampireSetBonuses,
|
||||
type GameState,
|
||||
type VampireBossChallengeResponse,
|
||||
} from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { defaultVampireBosses } from "../data/vampireBosses.js";
|
||||
import { defaultVampireEquipmentSets } from "../data/vampireEquipmentSets.js";
|
||||
import { defaultVampireExplorationAreas } from "../data/vampireExplorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
|
||||
/**
|
||||
* Computes the HMAC-SHA256 of data using the given secret.
|
||||
* @param data - The data string to sign.
|
||||
* @param secret - The HMAC secret key.
|
||||
* @returns The hex-encoded HMAC digest.
|
||||
*/
|
||||
const computeHmac = (data: string, secret: string): string => {
|
||||
return createHmac("sha256", secret).update(data).
|
||||
digest("hex");
|
||||
};
|
||||
|
||||
const vampireBossRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
vampireBossRouter.use("*", authMiddleware);
|
||||
|
||||
const calculateThrallStats = (
|
||||
vampire: NonNullable<GameState["vampire"]>,
|
||||
): { partyDPS: number; partyMaxHp: number } => {
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of vampire.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const ichorCombatMultiplier = vampire.siring.ichorCombatMultiplier ?? 1;
|
||||
const { soulShardsCombatMultiplier } = vampire.awakening;
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equipmentCombatMultiplier = vampire.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equippedItemIds = vampire.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const { combatMultiplier: setCombatMultiplier } = computeVampireSetBonuses(
|
||||
equippedItemIds,
|
||||
defaultVampireEquipmentSets,
|
||||
);
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyMaxHp = 0;
|
||||
|
||||
for (const thrall of vampire.thralls) {
|
||||
if (thrall.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let thrallMultiplier = 1;
|
||||
for (const upgrade of vampire.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "thrall"
|
||||
&& upgrade.thrallId === thrall.id
|
||||
) {
|
||||
thrallMultiplier = thrallMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const thrallContribution
|
||||
= thrall.combatPower
|
||||
* thrall.count
|
||||
* thrallMultiplier
|
||||
* globalMultiplier;
|
||||
partyDPS = partyDPS + thrallContribution;
|
||||
|
||||
const thrallHp = thrall.level * 50 * thrall.count;
|
||||
partyMaxHp = partyMaxHp + thrallHp;
|
||||
}
|
||||
|
||||
const { craftedCombatMultiplier } = vampire.exploration;
|
||||
|
||||
partyDPS = partyDPS
|
||||
* equipmentCombatMultiplier
|
||||
* setCombatMultiplier
|
||||
* ichorCombatMultiplier
|
||||
* soulShardsCombatMultiplier
|
||||
* craftedCombatMultiplier;
|
||||
|
||||
return { partyDPS, partyMaxHp };
|
||||
};
|
||||
|
||||
vampireBossRouter.post("/challenge", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<{ bossId: string }>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.bossId) {
|
||||
return context.json({ error: "Invalid request body" }, 400);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const { vampire } = state;
|
||||
|
||||
const boss = vampire.bosses.find((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
|
||||
if (!boss) {
|
||||
return context.json({ error: "Boss not found" }, 404);
|
||||
}
|
||||
|
||||
if (boss.status !== "available" && boss.status !== "in_progress") {
|
||||
return context.json({ error: "Boss is not currently available" }, 400);
|
||||
}
|
||||
|
||||
if (boss.siringRequirement > vampire.siring.count) {
|
||||
return context.json({ error: "Siring requirement not met" }, 403);
|
||||
}
|
||||
|
||||
const { partyDPS, partyMaxHp } = calculateThrallStats(vampire);
|
||||
|
||||
if (
|
||||
partyDPS === 0
|
||||
|| partyMaxHp === 0
|
||||
|| !Number.isFinite(partyDPS)
|
||||
|| !Number.isFinite(partyMaxHp)
|
||||
) {
|
||||
return context.json(
|
||||
{ error: "Your thralls have no combat power" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const bossHpBefore = boss.currentHp;
|
||||
const bossDPS = boss.damagePerSecond;
|
||||
|
||||
const timeToKillBoss = bossHpBefore / partyDPS;
|
||||
const timeToKillParty = partyMaxHp / bossDPS;
|
||||
|
||||
const won = timeToKillBoss <= timeToKillParty;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let partyHpRemaining: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let bossHpAtBattleEnd: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let bossUpdatedHp: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||
let rewards: VampireBossChallengeResponse["rewards"];
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||
let casualties: VampireBossChallengeResponse["casualties"];
|
||||
|
||||
if (won) {
|
||||
bossHpAtBattleEnd = 0;
|
||||
bossUpdatedHp = 0;
|
||||
const bossDamageDealt = bossDPS * timeToKillBoss;
|
||||
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
|
||||
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
// eslint-disable-next-line unicorn/consistent-destructuring -- mutation requires direct property access on state.resources
|
||||
state.resources.blood = (state.resources.blood ?? 0) + boss.bloodReward;
|
||||
vampire.totalBloodEarned = vampire.totalBloodEarned + boss.bloodReward;
|
||||
vampire.lifetimeBloodEarned = vampire.lifetimeBloodEarned + boss.bloodReward;
|
||||
vampire.siring.ichor = vampire.siring.ichor + boss.ichorReward;
|
||||
vampire.awakening.soulShards = vampire.awakening.soulShards + boss.soulShardsReward;
|
||||
vampire.lifetimeBossesDefeated = vampire.lifetimeBossesDefeated + 1;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
const upgrade = vampire.upgrades.find((u) => {
|
||||
return u.id === upgradeId;
|
||||
});
|
||||
if (upgrade) {
|
||||
upgrade.unlocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Grant equipment rewards — auto-equip if the slot is currently empty
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 14 -- @preserve */
|
||||
for (const equipmentId of boss.equipmentRewards) {
|
||||
const equipment = vampire.equipment.find((item) => {
|
||||
return item.id === equipmentId;
|
||||
});
|
||||
if (equipment) {
|
||||
equipment.owned = true;
|
||||
const slotAlreadyEquipped = vampire.equipment.some((item) => {
|
||||
return item.type === equipment.type && item.equipped;
|
||||
});
|
||||
if (!slotAlreadyEquipped) {
|
||||
equipment.equipped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock next boss in the same zone
|
||||
const zoneBosses = vampire.bosses.filter((b) => {
|
||||
return b.zoneId === boss.zoneId;
|
||||
});
|
||||
const zoneIndex = zoneBosses.findIndex((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
|
||||
if (
|
||||
nextZoneBoss
|
||||
&& nextZoneBoss.siringRequirement <= vampire.siring.count
|
||||
) {
|
||||
const nextBossInState = vampire.bosses.find((b) => {
|
||||
return b.id === nextZoneBoss.id;
|
||||
});
|
||||
if (nextBossInState) {
|
||||
nextBossInState.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock zones whose conditions are now both satisfied
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
for (const zone of vampire.zones) {
|
||||
if (zone.status === "unlocked") {
|
||||
continue;
|
||||
}
|
||||
if (zone.unlockBossId !== body.bossId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const questSatisfied
|
||||
= zone.unlockQuestId === null
|
||||
|| vampire.quests.some((q) => {
|
||||
return q.id === zone.unlockQuestId && q.status === "completed";
|
||||
});
|
||||
if (!questSatisfied) {
|
||||
continue;
|
||||
}
|
||||
zone.status = "unlocked";
|
||||
|
||||
// Unlock exploration areas for the newly unlocked zone
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 9 -- @preserve */
|
||||
for (const area of vampire.exploration.areas) {
|
||||
const areaDefinition = defaultVampireExplorationAreas.find((explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
});
|
||||
if (areaDefinition?.zoneId === zone.id && area.status === "locked") {
|
||||
area.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
const updatedZoneBosses = vampire.bosses.filter((b) => {
|
||||
return b.zoneId === zone.id;
|
||||
});
|
||||
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
||||
if (
|
||||
firstUpdatedBoss
|
||||
&& firstUpdatedBoss.siringRequirement <= vampire.siring.count
|
||||
) {
|
||||
firstUpdatedBoss.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
// First-kill ichor bounty — only awarded once
|
||||
const staticBoss = defaultVampireBosses.find((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
const bountyIchor
|
||||
= boss.bountyIchorClaimed === true
|
||||
? 0
|
||||
: staticBoss?.bountyIchor ?? 0;
|
||||
if (bountyIchor > 0) {
|
||||
boss.bountyIchorClaimed = true;
|
||||
vampire.siring.ichor = vampire.siring.ichor + bountyIchor;
|
||||
}
|
||||
|
||||
rewards = {
|
||||
blood: boss.bloodReward,
|
||||
bountyIchor: bountyIchor,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
ichor: boss.ichorReward,
|
||||
soulShards: boss.soulShardsReward,
|
||||
upgradeIds: boss.upgradeRewards,
|
||||
};
|
||||
} else {
|
||||
const partyDamageDealt = partyDPS * timeToKillParty;
|
||||
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
|
||||
bossUpdatedHp = boss.maxHp;
|
||||
partyHpRemaining = 0;
|
||||
|
||||
boss.status = "available";
|
||||
boss.currentHp = boss.maxHp;
|
||||
|
||||
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
||||
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
||||
|
||||
casualties = [];
|
||||
for (const thrall of vampire.thralls) {
|
||||
if (thrall.count === 0) {
|
||||
continue;
|
||||
}
|
||||
const killed = Math.floor(thrall.count * casualtyFraction);
|
||||
if (killed > 0) {
|
||||
thrall.count = Math.max(1, thrall.count - killed);
|
||||
|
||||
casualties.push({ killed: killed, thrallId: thrall.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const updatedSignature = secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
|
||||
const { bossId } = body;
|
||||
void logger.metric("vampire_boss_challenge", 1, { bossId, discordId, won });
|
||||
|
||||
const bossMaxHp = boss.maxHp;
|
||||
const bossNewHp = bossUpdatedHp;
|
||||
const response: VampireBossChallengeResponse = {
|
||||
bossDPS,
|
||||
bossHpAtBattleEnd,
|
||||
bossHpBefore,
|
||||
bossMaxHp,
|
||||
bossNewHp,
|
||||
partyDPS,
|
||||
partyHpRemaining,
|
||||
partyMaxHp,
|
||||
won,
|
||||
};
|
||||
if (rewards !== undefined) {
|
||||
response.rewards = rewards;
|
||||
}
|
||||
if (casualties !== undefined) {
|
||||
response.casualties = casualties;
|
||||
}
|
||||
if (updatedSignature !== undefined) {
|
||||
response.signature = updatedSignature;
|
||||
}
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"vampire_boss_challenge",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { vampireBossRouter };
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file Vampire crafting route handling dark recipe crafting.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||
/* eslint-disable complexity -- Route handler has inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultVampireCraftingRecipes } from "../data/vampireCrafting.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
GameState,
|
||||
VampireCraftRequest,
|
||||
VampireCraftResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
const vampireCraftRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
vampireCraftRouter.use("*", authMiddleware);
|
||||
|
||||
const recomputeVampireCraftedMultipliers = (
|
||||
craftedRecipeIds: Array<string>,
|
||||
): {
|
||||
craftedBloodMultiplier: number;
|
||||
craftedCombatMultiplier: number;
|
||||
craftedIchorMultiplier: number;
|
||||
} => {
|
||||
return {
|
||||
craftedBloodMultiplier: defaultVampireCraftingRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
|
||||
}).reduce((mult, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return mult * recipe.bonus.value;
|
||||
}, 1),
|
||||
craftedCombatMultiplier: defaultVampireCraftingRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
|
||||
}).reduce((mult, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return mult * recipe.bonus.value;
|
||||
}, 1),
|
||||
craftedIchorMultiplier: defaultVampireCraftingRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
|
||||
}).reduce((mult, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return mult * recipe.bonus.value;
|
||||
}, 1),
|
||||
};
|
||||
};
|
||||
|
||||
vampireCraftRouter.post("/", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<VampireCraftRequest>();
|
||||
|
||||
const { recipeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!recipeId) {
|
||||
return context.json({ error: "recipeId is required" }, 400);
|
||||
}
|
||||
|
||||
const recipe = defaultVampireCraftingRecipes.find((r) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (state.vampire.exploration.craftedRecipeIds.includes(recipeId)) {
|
||||
return context.json({ error: "Recipe already crafted" }, 400);
|
||||
}
|
||||
|
||||
// Verify the player has all required dark materials
|
||||
for (const requirement of recipe.requiredMaterials) {
|
||||
const material = state.vampire.exploration.materials.find((m) => {
|
||||
return 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 dark materials
|
||||
for (const requirement of recipe.requiredMaterials) {
|
||||
const material = state.vampire.exploration.materials.find((m) => {
|
||||
return m.materialId === requirement.materialId;
|
||||
});
|
||||
if (material) {
|
||||
material.quantity = material.quantity - requirement.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// Add recipe and recompute all multipliers from scratch
|
||||
state.vampire.exploration.craftedRecipeIds.push(recipeId);
|
||||
|
||||
const updatedMultipliers = recomputeVampireCraftedMultipliers(
|
||||
state.vampire.exploration.craftedRecipeIds,
|
||||
);
|
||||
state.vampire.exploration.craftedBloodMultiplier = updatedMultipliers.craftedBloodMultiplier;
|
||||
state.vampire.exploration.craftedIchorMultiplier = updatedMultipliers.craftedIchorMultiplier;
|
||||
state.vampire.exploration.craftedCombatMultiplier = updatedMultipliers.craftedCombatMultiplier;
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("vampire_recipe_crafted", 1, { discordId, recipeId });
|
||||
|
||||
const bonusType = recipe.bonus.type;
|
||||
const bonusValue = recipe.bonus.value;
|
||||
|
||||
const { materials } = state.vampire.exploration;
|
||||
const {
|
||||
craftedBloodMultiplier,
|
||||
craftedIchorMultiplier,
|
||||
craftedCombatMultiplier,
|
||||
} = updatedMultipliers;
|
||||
|
||||
const response: VampireCraftResponse = {
|
||||
bonusType,
|
||||
bonusValue,
|
||||
craftedBloodMultiplier,
|
||||
craftedCombatMultiplier,
|
||||
craftedIchorMultiplier,
|
||||
materials,
|
||||
recipeId,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"vampire_craft",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { vampireCraftRouter };
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* @file Vampire exploration routes handling dark area exploration mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable max-lines -- Route file requires multiple handlers */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultVampireExplorationAreas } from "../data/vampireExplorations.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
GameState,
|
||||
VampireExploreClaimableResponse,
|
||||
VampireExploreCollectEventResult,
|
||||
VampireExploreCollectRequest,
|
||||
VampireExploreCollectResponse,
|
||||
VampireExploreStartRequest,
|
||||
VampireExploreStartResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
const vampireExploreRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
vampireExploreRouter.use("*", authMiddleware);
|
||||
|
||||
const nothingProbability = 0.2;
|
||||
|
||||
const nothingMessages = [
|
||||
"Your thralls searched the shadowy depths but found nothing of value.",
|
||||
"The cursed area yielded nothing remarkable this time.",
|
||||
"Your thralls returned empty-handed from the darkness.",
|
||||
"A wasted hunt — the darkened area proved barren.",
|
||||
"Nothing to show for the bloodshed. Perhaps next time.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random "nothing found" message.
|
||||
* @returns A random message string.
|
||||
*/
|
||||
const pickNothingMessage = (): string => {
|
||||
const index = Math.floor(Math.random() * nothingMessages.length);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||
};
|
||||
|
||||
vampireExploreRouter.get("/claimable", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const areaId = context.req.query("areaId");
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultVampireExplorationAreas.find((a) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
const response: VampireExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
const area = state.vampire.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
|
||||
if (!area || area.status !== "in_progress") {
|
||||
const response: VampireExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
const claimable = Date.now() >= expiresAt;
|
||||
const response: VampireExploreClaimableResponse = { claimable };
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"vampire_explore_claimable",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
vampireExploreRouter.post("/start", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<VampireExploreStartRequest>();
|
||||
|
||||
const { areaId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultVampireExplorationAreas.find((a) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const zone = state.vampire.zones.find((z) => {
|
||||
return z.id === explorationArea.zoneId;
|
||||
});
|
||||
if (!zone || zone.status !== "unlocked") {
|
||||
return context.json({ error: "Zone is not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const area = state.vampire.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!area) {
|
||||
return context.json(
|
||||
{ error: "Exploration area not found in state" },
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
const anyInProgress = state.vampire.exploration.areas.some((a) => {
|
||||
return a.status === "in_progress";
|
||||
});
|
||||
if (anyInProgress) {
|
||||
return context.json(
|
||||
{ error: "An exploration is already in progress" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (area.status === "locked") {
|
||||
return context.json({ error: "Exploration area is locked" }, 400);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||
area.status = "in_progress";
|
||||
area.startedAt = now;
|
||||
area.endsAt = endsAt;
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const response: VampireExploreStartResponse = {
|
||||
areaId,
|
||||
endsAt,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"vampire_explore_start",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
vampireExploreRouter.post("/collect", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<VampireExploreCollectRequest>();
|
||||
|
||||
const { areaId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultVampireExplorationAreas.find((a) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const area = state.vampire.exploration.areas.find((a) => {
|
||||
return 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();
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
|
||||
if (now < expiresAt) {
|
||||
return context.json({ error: "Exploration is not yet complete" }, 400);
|
||||
}
|
||||
|
||||
area.status = "available";
|
||||
area.completedOnce = true;
|
||||
|
||||
// 20% chance of finding nothing
|
||||
if (Math.random() < nothingProbability) {
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const response: VampireExploreCollectResponse = {
|
||||
event: null,
|
||||
foundNothing: true,
|
||||
materialsFound: [],
|
||||
nothingMessage: pickNothingMessage(),
|
||||
};
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// Pick a random event
|
||||
const eventIndex = Math.floor(
|
||||
Math.random() * explorationArea.events.length,
|
||||
);
|
||||
const event = explorationArea.events[eventIndex];
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!event) {
|
||||
return context.json({ error: "No events available" }, 500);
|
||||
}
|
||||
|
||||
// Apply event effects and build the result summary
|
||||
let bloodChange = 0;
|
||||
let ichorChange = 0;
|
||||
let materialGained: { materialId: string; quantity: number } | null = null;
|
||||
|
||||
if (event.effect.type === "blood_gain") {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.blood = (state.resources.blood ?? 0) + amount;
|
||||
state.vampire.totalBloodEarned = state.vampire.totalBloodEarned + amount;
|
||||
bloodChange = amount;
|
||||
} else if (event.effect.type === "blood_loss") {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
const amount = Math.min(state.resources.blood ?? 0, event.effect.amount ?? 0);
|
||||
state.resources.blood = (state.resources.blood ?? 0) - amount;
|
||||
bloodChange = -amount;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 4 -- @preserve */
|
||||
} else if (event.effect.type === "ichor_gain") {
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.vampire.siring.ichor = state.vampire.siring.ichor + amount;
|
||||
ichorChange = amount;
|
||||
} else if (event.effect.type === "dark_material_gain") {
|
||||
const { materialId } = event.effect;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const quantity = event.effect.quantity ?? 1;
|
||||
if (materialId !== undefined && materialId !== "") {
|
||||
const existing = state.vampire.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.vampire.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
materialGained = { materialId, quantity };
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 13 -- @preserve */
|
||||
}
|
||||
} else if (event.effect.type === "thrall_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const fraction = event.effect.fraction ?? 0.05;
|
||||
for (const thrall of state.vampire.thralls) {
|
||||
const lost = Math.floor(thrall.count * fraction);
|
||||
if (lost > 0) {
|
||||
thrall.count = Math.max(0, thrall.count - lost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let thrallLostCount = 0;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
if (event.effect.type === "thrall_loss") {
|
||||
const fraction = event.effect.fraction ?? 0.05;
|
||||
for (const thrall of state.vampire.thralls) {
|
||||
const lost = Math.floor(thrall.count * fraction);
|
||||
thrallLostCount = thrallLostCount + lost;
|
||||
}
|
||||
}
|
||||
|
||||
const eventResult: VampireExploreCollectEventResult = {
|
||||
bloodChange: bloodChange,
|
||||
ichorChange: ichorChange,
|
||||
materialGained: materialGained,
|
||||
text: event.text,
|
||||
thrallLostCount: thrallLostCount,
|
||||
};
|
||||
|
||||
// Roll for dark material drops from possibleMaterials (weighted random selection)
|
||||
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
||||
|
||||
if (explorationArea.possibleMaterials.length > 0) {
|
||||
let totalWeight = 0;
|
||||
for (const materialDrop of explorationArea.possibleMaterials) {
|
||||
totalWeight = totalWeight + materialDrop.weight;
|
||||
}
|
||||
let roll = Math.random() * totalWeight;
|
||||
|
||||
for (const possible of explorationArea.possibleMaterials) {
|
||||
roll = roll - possible.weight;
|
||||
if (roll <= 0) {
|
||||
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
||||
const range = maxMinDiff + 1;
|
||||
const randomOffset = Math.floor(Math.random() * range);
|
||||
const quantity = randomOffset + possible.minQuantity;
|
||||
const { materialId } = possible;
|
||||
|
||||
const existing = state.vampire.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.vampire.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
|
||||
materialsFound.push({ materialId, quantity });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const response: VampireExploreCollectResponse = {
|
||||
event: eventResult,
|
||||
foundNothing: false,
|
||||
materialsFound: materialsFound,
|
||||
};
|
||||
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"vampire_explore_collect",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { vampireExploreRouter };
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file Vampire upgrade purchase route.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||
/* eslint-disable max-statements -- Route handler requires many statements */
|
||||
/* eslint-disable complexity -- Route handler has inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Route logic requires long lines */
|
||||
import { Hono } from "hono";
|
||||
import { defaultVampireUpgrades } from "../data/vampireUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
BuyVampireUpgradeRequest,
|
||||
BuyVampireUpgradeResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const vampireUpgradeRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
vampireUpgradeRouter.use("*", authMiddleware);
|
||||
|
||||
vampireUpgradeRouter.post("/buy", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const body = await context.req.json<BuyVampireUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgradeTemplate = defaultVampireUpgrades.find((vampireUpgrade) => {
|
||||
return vampireUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgradeTemplate) {
|
||||
return context.json({ error: "Unknown vampire upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.vampire) {
|
||||
return context.json({ error: "Vampire realm not unlocked" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = state.vampire.upgrades.find((u) => {
|
||||
return u.id === upgradeId;
|
||||
});
|
||||
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Upgrade not found in vampire state" }, 404);
|
||||
}
|
||||
|
||||
if (!upgrade.unlocked) {
|
||||
return context.json({ error: "Upgrade is not yet unlocked" }, 400);
|
||||
}
|
||||
|
||||
if (upgrade.purchased) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
const currentBlood = state.resources.blood ?? 0;
|
||||
const currentIchor = state.vampire.siring.ichor;
|
||||
const currentSoulShards = state.vampire.awakening.soulShards;
|
||||
|
||||
if (currentBlood < upgradeTemplate.costBlood) {
|
||||
return context.json({ error: "Not enough blood" }, 400);
|
||||
}
|
||||
|
||||
if (currentIchor < upgradeTemplate.costIchor) {
|
||||
return context.json({ error: "Not enough ichor" }, 400);
|
||||
}
|
||||
|
||||
if (currentSoulShards < upgradeTemplate.costSoulShards) {
|
||||
return context.json({ error: "Not enough soul shards" }, 400);
|
||||
}
|
||||
|
||||
upgrade.purchased = true;
|
||||
|
||||
const updatedBlood = currentBlood - upgradeTemplate.costBlood;
|
||||
const updatedIchor = currentIchor - upgradeTemplate.costIchor;
|
||||
const updatedSoulShards = currentSoulShards - upgradeTemplate.costSoulShards;
|
||||
|
||||
state.resources.blood = updatedBlood;
|
||||
state.vampire.siring.ichor = updatedIchor;
|
||||
state.vampire.awakening.soulShards = updatedSoulShards;
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void logger.metric("vampire_upgrade_purchased", 1, { discordId, upgradeId });
|
||||
|
||||
const response: BuyVampireUpgradeResponse = {
|
||||
bloodRemaining: updatedBlood,
|
||||
ichorRemaining: updatedIchor,
|
||||
soulShardsRemaining: updatedSoulShards,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"vampire_upgrade_buy",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { vampireUpgradeRouter };
|
||||
@@ -4,7 +4,7 @@
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { initialGameState, initialGoddessState } from "../data/initialState.js";
|
||||
import {
|
||||
defaultTranscendenceUpgrades,
|
||||
} from "../data/transcendenceUpgrades.js";
|
||||
@@ -47,6 +47,15 @@ const buildPostApotheosisState = (
|
||||
const updatedApotheosisData: ApotheosisData = { count: apotheosisCount };
|
||||
|
||||
const freshState = initialGameState(currentState.player, characterName);
|
||||
|
||||
// Goddess state: initialised on first apotheosis, preserved on subsequent resets
|
||||
let goddessSpread: object = {};
|
||||
if (apotheosisCount === 1) {
|
||||
goddessSpread = { goddess: initialGoddessState() };
|
||||
} else if (currentState.goddess !== undefined) {
|
||||
goddessSpread = { goddess: currentState.goddess };
|
||||
}
|
||||
|
||||
const updatedState: GameState = {
|
||||
...freshState,
|
||||
lastTickAt: Date.now(),
|
||||
@@ -60,6 +69,7 @@ const buildPostApotheosisState = (
|
||||
...currentState.story
|
||||
? { story: currentState.story }
|
||||
: {},
|
||||
...goddessSpread,
|
||||
};
|
||||
|
||||
return { updatedApotheosisData, updatedState };
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file Awakening service handling eligibility checks and post-awakening state building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
|
||||
import { initialVampireState } from "../data/initialState.js";
|
||||
import { defaultVampireAwakeningUpgrades } from "../data/vampireAwakeningUpgrades.js";
|
||||
import type { AwakeningData, GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* The ID of the final vampire boss whose defeat triggers eligibility for awakening.
|
||||
*/
|
||||
const finalVampireBossId = "eternal_darkness";
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedIds: Array<string>,
|
||||
category: string,
|
||||
): number => {
|
||||
return defaultVampireAwakeningUpgrades.filter((upgrade) => {
|
||||
return upgrade.category === category && purchasedIds.includes(upgrade.id);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all five soul shard multipliers from the purchased awakening upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased awakening upgrade IDs.
|
||||
* @returns An object containing all five soul shard multiplier values.
|
||||
*/
|
||||
const computeAwakeningMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): Omit<AwakeningData, "count" | "soulShards" | "purchasedUpgradeIds"> => {
|
||||
return {
|
||||
soulShardsBloodMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "blood"),
|
||||
soulShardsCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
|
||||
soulShardsMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "soulshards_meta"),
|
||||
soulShardsSiringIchorMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "siring_ichor"),
|
||||
soulShardsSiringThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "siring_threshold"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the player is eligible to awaken:
|
||||
* the final vampire boss must have been defeated.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for awakening.
|
||||
*/
|
||||
const isEligibleForAwakening = (state: GameState): boolean => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.vampire === undefined) {
|
||||
return false;
|
||||
}
|
||||
return state.vampire.bosses.some((boss) => {
|
||||
return boss.id === finalVampireBossId && boss.status === "defeated";
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the soul shards yield from an awakening.
|
||||
* Formula: MAX(1, FLOOR(SQRT(siringCount) * metaMultiplier)).
|
||||
* @param siringCount - The number of sirings completed.
|
||||
* @param metaMultiplier - Multiplier from soul shard meta upgrades applied to yield.
|
||||
* @returns The soul shards earned.
|
||||
*/
|
||||
const calculateSoulShardsYield = (
|
||||
siringCount: number,
|
||||
metaMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(1, Math.floor(Math.sqrt(siringCount) * metaMultiplier));
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the updated vampire state after an awakening reset.
|
||||
* Resets the current run including siring data (bosses, quests, thralls, upgrades, zones, siring data).
|
||||
* Preserves: equipment, achievements, awakening data (updated), eternal sovereignty, lifetime stats.
|
||||
* @param state - The current game state before awakening.
|
||||
* @returns The soul shards earned and the updated vampire state.
|
||||
*/
|
||||
const buildPostAwakeningState = (
|
||||
state: GameState,
|
||||
): { soulShardsEarned: number; updatedVampire: NonNullable<GameState["vampire"]> } => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure vampire exists */
|
||||
const vampire = state.vampire as NonNullable<GameState["vampire"]>;
|
||||
|
||||
const metaMultiplier = vampire.awakening.soulShardsMetaMultiplier;
|
||||
const soulShardsEarned = calculateSoulShardsYield(vampire.siring.count, metaMultiplier);
|
||||
|
||||
const updatedCount = vampire.awakening.count + 1;
|
||||
const updatedSoulShards = vampire.awakening.soulShards + soulShardsEarned;
|
||||
const updatedPurchasedIds = vampire.awakening.purchasedUpgradeIds;
|
||||
const updatedMultipliers = computeAwakeningMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedAwakening: AwakeningData = {
|
||||
count: updatedCount,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
soulShards: updatedSoulShards,
|
||||
...updatedMultipliers,
|
||||
};
|
||||
|
||||
const freshVampire = initialVampireState();
|
||||
|
||||
const updatedVampire: NonNullable<GameState["vampire"]> = {
|
||||
...freshVampire,
|
||||
achievements: vampire.achievements,
|
||||
awakening: updatedAwakening,
|
||||
bosses: freshVampire.bosses.map((b) => {
|
||||
const existing = vampire.bosses.find((vb) => {
|
||||
return vb.id === b.id;
|
||||
});
|
||||
return {
|
||||
...b,
|
||||
bountyIchorClaimed: existing?.bountyIchorClaimed ?? false,
|
||||
};
|
||||
}),
|
||||
equipment: vampire.equipment,
|
||||
eternalSovereignty: vampire.eternalSovereignty,
|
||||
lastTickAt: Date.now(),
|
||||
lifetimeBloodEarned: vampire.lifetimeBloodEarned,
|
||||
lifetimeBossesDefeated: vampire.lifetimeBossesDefeated,
|
||||
lifetimeQuestsCompleted: vampire.lifetimeQuestsCompleted,
|
||||
totalBloodEarned: 0,
|
||||
};
|
||||
|
||||
return { soulShardsEarned, updatedVampire };
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostAwakeningState,
|
||||
calculateSoulShardsYield,
|
||||
computeAwakeningMultipliers,
|
||||
isEligibleForAwakening,
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @file Consecration service handling eligibility checks and post-consecration state building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Function requires many steps */
|
||||
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
|
||||
import { defaultConsecrationUpgrades } from "../data/goddessConsecrationUpgrades.js";
|
||||
import { initialGoddessState } from "../data/initialState.js";
|
||||
import type { ConsecrationData, GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Base prayers threshold for the first consecration.
|
||||
*/
|
||||
const baseConsecrationThreshold = 50_000;
|
||||
|
||||
/**
|
||||
* Divisor used in the divinity yield formula.
|
||||
*/
|
||||
const divinityYieldDivisor = 1000;
|
||||
|
||||
/**
|
||||
* Calculates the prayers threshold required for the next consecration.
|
||||
* Formula: BASE * (count + 1)^2 * thresholdMultiplier.
|
||||
* @param consecrationCount - The number of consecrations completed so far.
|
||||
* @param thresholdMultiplier - An optional stardust-upgrade multiplier applied to the threshold.
|
||||
* @returns The prayers amount required to consecrate.
|
||||
*/
|
||||
const calculateConsecrationThreshold = (
|
||||
consecrationCount: number,
|
||||
thresholdMultiplier = 1,
|
||||
): number => {
|
||||
return (
|
||||
baseConsecrationThreshold
|
||||
* Math.pow(consecrationCount + 1, 2)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the player is eligible to consecrate:
|
||||
* the total prayers earned in the current run must meet the threshold.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for consecration.
|
||||
*/
|
||||
const isEligibleForConsecration = (state: GameState): boolean => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.goddess === undefined) {
|
||||
return false;
|
||||
}
|
||||
const thresholdMultiplier
|
||||
= state.goddess.enlightenment.stardustConsecrationThresholdMultiplier;
|
||||
const threshold = calculateConsecrationThreshold(
|
||||
state.goddess.consecration.count,
|
||||
thresholdMultiplier,
|
||||
);
|
||||
return state.goddess.totalPrayersEarned >= threshold;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the divinity yield from a consecration.
|
||||
* Formula: MAX(1, FLOOR(SQRT(totalPrayersEarned / divisor) * divinityMultiplier)).
|
||||
* @param totalPrayersEarned - Total prayers earned in the current consecration run.
|
||||
* @param divinityMultiplier - Multiplier from stardust upgrades applied to divinity yield.
|
||||
* @returns The divinity earned.
|
||||
*/
|
||||
const calculateDivinityYield = (
|
||||
totalPrayersEarned: number,
|
||||
divinityMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
Math.sqrt(totalPrayersEarned / divinityYieldDivisor) * divinityMultiplier,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the consecration production multiplier from the count.
|
||||
* Each consecration adds 25% to the production multiplier.
|
||||
* @param count - The number of consecrations completed.
|
||||
* @returns The computed production multiplier as a number.
|
||||
*/
|
||||
const computeConsecrationProductionMultiplier = (count: number): number => {
|
||||
const bonus = count * 0.25;
|
||||
return 1 + bonus;
|
||||
};
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
category: string,
|
||||
): number => {
|
||||
return defaultConsecrationUpgrades.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === category
|
||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all three divinity-upgrade multipliers from the purchased upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased consecration upgrade IDs.
|
||||
* @returns An object containing the three divinity multiplier values.
|
||||
*/
|
||||
const computeConsecrationDivinityMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): Pick<
|
||||
ConsecrationData,
|
||||
| "divinityCombatMultiplier"
|
||||
| "divinityDisciplesMultiplier"
|
||||
| "divinityPrayersMultiplier"
|
||||
> => {
|
||||
return {
|
||||
divinityCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
|
||||
divinityDisciplesMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "disciples"),
|
||||
divinityPrayersMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prayers"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the updated goddess state after a consecration reset.
|
||||
* Resets the current run (bosses, quests, disciples, upgrades, zones, exploration crafting).
|
||||
* Preserves: equipment, achievements, consecration data (updated), enlightenment, lifetime stats, sacred materials.
|
||||
* @param state - The current game state before consecration.
|
||||
* @returns The divinity earned and the updated goddess state.
|
||||
*/
|
||||
const buildPostConsecrationState = (
|
||||
state: GameState,
|
||||
): { divinityEarned: number; updatedGoddess: NonNullable<GameState["goddess"]> } => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure goddess exists
|
||||
const goddess = state.goddess as NonNullable<GameState["goddess"]>;
|
||||
|
||||
const divinityMultiplier
|
||||
= goddess.enlightenment.stardustConsecrationDivinityMultiplier;
|
||||
const divinityEarned = calculateDivinityYield(
|
||||
goddess.totalPrayersEarned,
|
||||
divinityMultiplier,
|
||||
);
|
||||
|
||||
const updatedCount = goddess.consecration.count + 1;
|
||||
const updatedDivinity = goddess.consecration.divinity + divinityEarned;
|
||||
const productionMultiplier = computeConsecrationProductionMultiplier(updatedCount);
|
||||
|
||||
const updatedConsecration: ConsecrationData = {
|
||||
...goddess.consecration,
|
||||
count: updatedCount,
|
||||
divinity: updatedDivinity,
|
||||
lastConsecratedAt: Date.now(),
|
||||
productionMultiplier: productionMultiplier,
|
||||
...computeConsecrationDivinityMultipliers(
|
||||
goddess.consecration.purchasedUpgradeIds,
|
||||
),
|
||||
};
|
||||
|
||||
const freshGoddess = initialGoddessState();
|
||||
|
||||
const updatedGoddess: NonNullable<GameState["goddess"]> = {
|
||||
...freshGoddess,
|
||||
achievements: goddess.achievements,
|
||||
bosses: freshGoddess.bosses.map((b) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const existing = goddess.bosses.find((gb) => {
|
||||
return gb.id === b.id;
|
||||
});
|
||||
return {
|
||||
...b,
|
||||
bountyDivinityClaimed: existing?.bountyDivinityClaimed ?? false,
|
||||
};
|
||||
}),
|
||||
consecration: updatedConsecration,
|
||||
enlightenment: goddess.enlightenment,
|
||||
equipment: goddess.equipment,
|
||||
exploration: {
|
||||
...freshGoddess.exploration,
|
||||
materials: goddess.exploration.materials,
|
||||
},
|
||||
lastTickAt: Date.now(),
|
||||
lifetimeBossesDefeated: goddess.lifetimeBossesDefeated,
|
||||
lifetimePrayersEarned: goddess.lifetimePrayersEarned + goddess.totalPrayersEarned,
|
||||
lifetimeQuestsCompleted: goddess.lifetimeQuestsCompleted,
|
||||
totalPrayersEarned: 0,
|
||||
};
|
||||
|
||||
return { divinityEarned, updatedGoddess };
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostConsecrationState,
|
||||
calculateConsecrationThreshold,
|
||||
calculateDivinityYield,
|
||||
computeConsecrationDivinityMultipliers,
|
||||
computeConsecrationProductionMultiplier,
|
||||
isEligibleForConsecration,
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file Enlightenment service handling eligibility checks and post-enlightenment state building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
|
||||
import { defaultEnlightenmentUpgrades } from "../data/goddessEnlightenmentUpgrades.js";
|
||||
import { initialGoddessState } from "../data/initialState.js";
|
||||
import type { EnlightenmentData, GameState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* ID of the final goddess boss — must be defeated to unlock Enlightenment.
|
||||
*/
|
||||
const finalGoddessBossId = "divine_heart_sovereign";
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedIds: Array<string>,
|
||||
category: string,
|
||||
): number => {
|
||||
return defaultEnlightenmentUpgrades.filter((upgrade) => {
|
||||
return upgrade.category === category && purchasedIds.includes(upgrade.id);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all five stardust multipliers from the purchased enlightenment upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased enlightenment upgrade IDs.
|
||||
* @returns An object containing all five stardust multiplier values.
|
||||
*/
|
||||
const computeEnlightenmentMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): Omit<EnlightenmentData, "count" | "stardust" | "purchasedUpgradeIds"> => {
|
||||
return {
|
||||
stardustCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
|
||||
stardustConsecrationDivinityMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "consecration_divinity"),
|
||||
stardustConsecrationThresholdMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "consecration_threshold"),
|
||||
stardustMetaMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "stardust_meta"),
|
||||
stardustPrayersMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "prayers"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when the player is eligible for Enlightenment:
|
||||
* they must have defeated the final goddess boss at least once.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for Enlightenment.
|
||||
*/
|
||||
const isEligibleForEnlightenment = (state: GameState): boolean => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.goddess === undefined) {
|
||||
return false;
|
||||
}
|
||||
return state.goddess.bosses.some((boss) => {
|
||||
return boss.id === finalGoddessBossId && boss.status === "defeated";
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the stardust yield from an Enlightenment.
|
||||
* Formula: MAX(1, FLOOR(SQRT(consecrationCount) * metaMultiplier)).
|
||||
* @param consecrationCount - The number of consecrations completed before this Enlightenment.
|
||||
* @param metaMultiplier - Multiplier from prior enlightenment upgrades applied to stardust yield.
|
||||
* @returns The stardust earned.
|
||||
*/
|
||||
const calculateStardustYield = (
|
||||
consecrationCount: number,
|
||||
metaMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(1, Math.floor(Math.sqrt(consecrationCount) * metaMultiplier));
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the updated goddess state after an Enlightenment — a full goddess reset.
|
||||
* Wipes everything including consecration, preserving only equipment, achievements, and enlightenment data.
|
||||
* @param state - The current game state before enlightenment.
|
||||
* @returns The stardust earned and the updated goddess state.
|
||||
*/
|
||||
const buildPostEnlightenmentState = (
|
||||
state: GameState,
|
||||
): { stardustEarned: number; updatedGoddess: NonNullable<GameState["goddess"]> } => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure goddess exists
|
||||
const goddess = state.goddess as NonNullable<GameState["goddess"]>;
|
||||
|
||||
const metaMultiplier = goddess.enlightenment.stardustMetaMultiplier;
|
||||
const stardustEarned = calculateStardustYield(
|
||||
goddess.consecration.count,
|
||||
metaMultiplier,
|
||||
);
|
||||
|
||||
const updatedCount = goddess.enlightenment.count + 1;
|
||||
const updatedStardust = goddess.enlightenment.stardust + stardustEarned;
|
||||
const updatedPurchasedIds = goddess.enlightenment.purchasedUpgradeIds;
|
||||
const updatedMultipliers = computeEnlightenmentMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedEnlightenment: EnlightenmentData = {
|
||||
count: updatedCount,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
stardust: updatedStardust,
|
||||
...updatedMultipliers,
|
||||
};
|
||||
|
||||
const freshGoddess = initialGoddessState();
|
||||
|
||||
const updatedGoddess: NonNullable<GameState["goddess"]> = {
|
||||
...freshGoddess,
|
||||
achievements: goddess.achievements,
|
||||
bosses: freshGoddess.bosses.map((b) => {
|
||||
const existing = goddess.bosses.find((gb) => {
|
||||
return gb.id === b.id;
|
||||
});
|
||||
return {
|
||||
...b,
|
||||
bountyDivinityClaimed: existing?.bountyDivinityClaimed ?? false,
|
||||
};
|
||||
}),
|
||||
enlightenment: updatedEnlightenment,
|
||||
equipment: goddess.equipment,
|
||||
lastTickAt: Date.now(),
|
||||
lifetimeBossesDefeated: goddess.lifetimeBossesDefeated,
|
||||
lifetimePrayersEarned: goddess.lifetimePrayersEarned,
|
||||
lifetimeQuestsCompleted: goddess.lifetimeQuestsCompleted,
|
||||
totalPrayersEarned: 0,
|
||||
};
|
||||
|
||||
return { stardustEarned, updatedGoddess };
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostEnlightenmentState,
|
||||
calculateStardustYield,
|
||||
computeEnlightenmentMultipliers,
|
||||
isEligibleForEnlightenment,
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @file Siring service handling eligibility checks and post-siring state building.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Function requires many steps */
|
||||
/* eslint-disable stylistic/max-len -- Service logic requires long lines */
|
||||
import { initialVampireState } from "../data/initialState.js";
|
||||
import { defaultVampireSiringUpgrades } from "../data/vampireSiringUpgrades.js";
|
||||
import type { GameState, SiringData } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Base blood threshold for the first siring.
|
||||
*/
|
||||
const baseSiringThreshold = 1_000_000;
|
||||
|
||||
/**
|
||||
* Divisor used in the ichor yield formula.
|
||||
*/
|
||||
const ichorYieldDivisor = 50_000;
|
||||
|
||||
/**
|
||||
* Calculates the blood threshold required for the next siring.
|
||||
* Formula: BASE * (count + 1)^2 * thresholdMultiplier.
|
||||
* @param siringCount - The number of sirings completed so far.
|
||||
* @param thresholdMultiplier - An optional multiplier applied to the threshold.
|
||||
* @returns The blood amount required to sire.
|
||||
*/
|
||||
const calculateSiringThreshold = (
|
||||
siringCount: number,
|
||||
thresholdMultiplier = 1,
|
||||
): number => {
|
||||
return (
|
||||
baseSiringThreshold
|
||||
* Math.pow(siringCount + 1, 2)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the combined threshold multiplier from purchased utility siring upgrades.
|
||||
* @param purchasedUpgradeIds - The array of purchased siring upgrade IDs.
|
||||
* @returns The combined threshold multiplier.
|
||||
*/
|
||||
const computeSiringThresholdMultiplier = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): number => {
|
||||
return defaultVampireSiringUpgrades.filter((upgrade) => {
|
||||
return upgrade.id.startsWith("siring_threshold_") && purchasedUpgradeIds.includes(upgrade.id);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the player is eligible to sire:
|
||||
* the total blood earned in the current run must meet the threshold.
|
||||
* @param state - The current game state.
|
||||
* @returns Whether the player is eligible for siring.
|
||||
*/
|
||||
const isEligibleForSiring = (state: GameState): boolean => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.vampire === undefined) {
|
||||
return false;
|
||||
}
|
||||
const { siring, awakening, totalBloodEarned } = state.vampire;
|
||||
const siringThresholdMultiplier = computeSiringThresholdMultiplier(siring.purchasedUpgradeIds);
|
||||
const combinedMultiplier = siringThresholdMultiplier * awakening.soulShardsSiringThresholdMultiplier;
|
||||
const threshold = calculateSiringThreshold(siring.count, combinedMultiplier);
|
||||
return totalBloodEarned >= threshold;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the ichor yield from a siring.
|
||||
* Formula: MAX(1, FLOOR(SQRT(totalBloodEarned / divisor) * ichorMultiplier)).
|
||||
* @param totalBloodEarned - Total blood earned in the current siring run.
|
||||
* @param ichorMultiplier - Multiplier applied to the ichor yield.
|
||||
* @returns The ichor earned.
|
||||
*/
|
||||
const calculateIchorYield = (
|
||||
totalBloodEarned: number,
|
||||
ichorMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(1, Math.floor(Math.sqrt(totalBloodEarned / ichorYieldDivisor) * ichorMultiplier));
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the siring production multiplier from the count.
|
||||
* Each siring adds 25% to the production multiplier.
|
||||
* @param count - The number of sirings completed.
|
||||
* @returns The computed production multiplier as a number.
|
||||
*/
|
||||
const computeSiringProductionMultiplier = (count: number): number => {
|
||||
const bonus = count * 0.25;
|
||||
return 1 + bonus;
|
||||
};
|
||||
|
||||
const getCategoryMultiplier = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
category: string,
|
||||
): number => {
|
||||
return defaultVampireSiringUpgrades.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === category
|
||||
&& purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes all three ichor-upgrade multipliers from the purchased siring upgrade IDs.
|
||||
* @param purchasedUpgradeIds - The array of purchased siring upgrade IDs.
|
||||
* @returns An object containing the three ichor multiplier values.
|
||||
*/
|
||||
const computeSiringIchorMultipliers = (
|
||||
purchasedUpgradeIds: Array<string>,
|
||||
): Pick<SiringData, "ichorBloodMultiplier" | "ichorCombatMultiplier" | "ichorThrallsMultiplier"> => {
|
||||
return {
|
||||
ichorBloodMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "blood"),
|
||||
ichorCombatMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "combat"),
|
||||
ichorThrallsMultiplier: getCategoryMultiplier(purchasedUpgradeIds, "thralls"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the updated vampire state after a siring reset.
|
||||
* Resets the current run (bosses, quests, thralls, upgrades, zones, exploration crafting).
|
||||
* Preserves: equipment, achievements, siring data (updated), awakening, lifetime stats, dark materials.
|
||||
* @param state - The current game state before siring.
|
||||
* @returns The ichor earned and the updated vampire state.
|
||||
*/
|
||||
const buildPostSiringState = (
|
||||
state: GameState,
|
||||
): { ichorEarned: number; updatedVampire: NonNullable<GameState["vampire"]> } => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Caller must ensure vampire exists */
|
||||
const vampire = state.vampire as NonNullable<GameState["vampire"]>;
|
||||
|
||||
const siringIchorYieldMultiplier = getCategoryMultiplier(vampire.siring.purchasedUpgradeIds, "ichor");
|
||||
const awakeningIchorMultiplier = vampire.awakening.soulShardsSiringIchorMultiplier;
|
||||
const combinedIchorMultiplier = siringIchorYieldMultiplier * awakeningIchorMultiplier;
|
||||
|
||||
const ichorEarned = calculateIchorYield(vampire.totalBloodEarned, combinedIchorMultiplier);
|
||||
const updatedCount = vampire.siring.count + 1;
|
||||
const updatedIchor = vampire.siring.ichor + ichorEarned;
|
||||
const productionMultiplier = computeSiringProductionMultiplier(updatedCount);
|
||||
|
||||
const updatedSiring: SiringData = {
|
||||
...vampire.siring,
|
||||
count: updatedCount,
|
||||
ichor: updatedIchor,
|
||||
lastSiredAt: Date.now(),
|
||||
productionMultiplier: productionMultiplier,
|
||||
...computeSiringIchorMultipliers(vampire.siring.purchasedUpgradeIds),
|
||||
};
|
||||
|
||||
const freshVampire = initialVampireState();
|
||||
|
||||
const updatedVampire: NonNullable<GameState["vampire"]> = {
|
||||
...freshVampire,
|
||||
achievements: vampire.achievements,
|
||||
awakening: vampire.awakening,
|
||||
bosses: freshVampire.bosses.map((b) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const existing = vampire.bosses.find((vb) => {
|
||||
return vb.id === b.id;
|
||||
});
|
||||
return {
|
||||
...b,
|
||||
bountyIchorClaimed: existing?.bountyIchorClaimed ?? false,
|
||||
};
|
||||
}),
|
||||
equipment: vampire.equipment,
|
||||
eternalSovereignty: vampire.eternalSovereignty,
|
||||
exploration: {
|
||||
...freshVampire.exploration,
|
||||
materials: vampire.exploration.materials,
|
||||
},
|
||||
lastTickAt: Date.now(),
|
||||
lifetimeBloodEarned: vampire.lifetimeBloodEarned + vampire.totalBloodEarned,
|
||||
lifetimeBossesDefeated: vampire.lifetimeBossesDefeated,
|
||||
lifetimeQuestsCompleted: vampire.lifetimeQuestsCompleted,
|
||||
siring: updatedSiring,
|
||||
totalBloodEarned: 0,
|
||||
};
|
||||
|
||||
return { ichorEarned, updatedVampire };
|
||||
};
|
||||
|
||||
export {
|
||||
buildPostSiringState,
|
||||
calculateIchorYield,
|
||||
calculateSiringThreshold,
|
||||
computeSiringIchorMultipliers,
|
||||
computeSiringProductionMultiplier,
|
||||
computeSiringThresholdMultiplier,
|
||||
isEligibleForSiring,
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeConsecration = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
divinity: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as string[],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeEnlightenment = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
stardust: 0,
|
||||
purchasedUpgradeIds: [] as string[],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGoddessExploration = (overrides: Record<string, unknown> = {}) => ({
|
||||
areas: [] as Array<{ id: string; status: string }>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
craftedRecipeIds: [] as string[],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a minimal GoddessState that has met the first consecration threshold (50 000 prayers).
|
||||
*/
|
||||
const makeGoddessStateEligible = (overrides: Record<string, unknown> = {}) => ({
|
||||
zones: [] as Array<{ id: string; status: string }>,
|
||||
bosses: [] as Array<{ id: string; status: string }>,
|
||||
quests: [] as Array<{ id: string; status: string }>,
|
||||
disciples: [] as Array<{ id: string; count: number }>,
|
||||
equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record<string, unknown> }>,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number }>,
|
||||
achievements: [] as Array<{ id: string; completed: boolean }>,
|
||||
consecration: makeConsecration(),
|
||||
enlightenment: makeEnlightenment(),
|
||||
exploration: makeGoddessExploration(),
|
||||
totalPrayersEarned: 50_000, // Meets base threshold for count=0
|
||||
lifetimePrayersEarned: 50_000,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a minimal GoddessState that has NOT met the consecration threshold.
|
||||
*/
|
||||
const makeGoddessStateIneligible = (overrides: Record<string, unknown> = {}) => ({
|
||||
...makeGoddessStateEligible(),
|
||||
totalPrayersEarned: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
goddess: makeGoddessStateEligible() as GameState["goddess"],
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("consecration route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { consecrationRouter } = await import("../../src/routes/consecration.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/consecration", consecrationRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/consecration${path}`, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /", () => {
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess realm is not unlocked", async () => {
|
||||
const state = makeState({ goddess: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Goddess realm not unlocked");
|
||||
});
|
||||
|
||||
it("returns 400 with threshold message when not eligible", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateIneligible() as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toMatch(/Not eligible for consecration/u);
|
||||
expect(body.error).toMatch(/50,000/u);
|
||||
});
|
||||
|
||||
it("returns 200 with divinityEarned and newConsecrationCount on success", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { divinityEarned: number; newConsecrationCount: number };
|
||||
expect(body.newConsecrationCount).toBe(1);
|
||||
expect(body.divinityEarned).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("applies the threshold multiplier when checking eligibility", async () => {
|
||||
// threshold multiplier of 2 means we need 100 000 prayers for count=0 but only have 50 000
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateIneligible({
|
||||
totalPrayersEarned: 50_000,
|
||||
enlightenment: makeEnlightenment({ stardustConsecrationThresholdMultiplier: 2 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
// threshold should be 100 000 with multiplier 2
|
||||
expect(body.error).toMatch(/100,000/u);
|
||||
});
|
||||
|
||||
it("returns 500 when database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /buy-upgrade", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy-upgrade", {});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("upgradeId is required");
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown upgrade", async () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade_id" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Unknown consecration upgrade");
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess realm is not unlocked", async () => {
|
||||
const state = makeState({ goddess: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Goddess realm not unlocked");
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is already purchased", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateEligible({
|
||||
consecration: makeConsecration({
|
||||
divinity: 100,
|
||||
purchasedUpgradeIds: [ "divine_prayers_1" ],
|
||||
}),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Upgrade already purchased");
|
||||
});
|
||||
|
||||
it("returns 400 when not enough divinity", async () => {
|
||||
// divine_prayers_1 costs 5 divinity — give the player 0
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateEligible({
|
||||
consecration: makeConsecration({ divinity: 0 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Not enough divinity");
|
||||
});
|
||||
|
||||
it("returns 200 with updated multipliers on successful purchase", async () => {
|
||||
// divine_prayers_1 costs 5 divinity and is in the "prayers" category
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateEligible({
|
||||
consecration: makeConsecration({ divinity: 100 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
divinityRemaining: number;
|
||||
purchasedUpgradeIds: string[];
|
||||
divinityPrayersMultiplier: number;
|
||||
divinityDisciplesMultiplier: number;
|
||||
divinityCombatMultiplier: number;
|
||||
};
|
||||
expect(body.divinityRemaining).toBe(95); // 100 - 5
|
||||
expect(body.purchasedUpgradeIds).toContain("divine_prayers_1");
|
||||
expect(body.divinityPrayersMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns 500 when database throws an Error during buy-upgrade", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when database throws a non-Error value during buy-upgrade", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1128,6 +1128,335 @@ describe("debug route", () => {
|
||||
expect(body.state.exploration?.craftedClickMultiplier).toBe(1);
|
||||
expect(body.state.exploration?.craftedCombatMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("injects goddess content arrays when state.goddess exists with empty arrays", async () => {
|
||||
const goddess: GameState["goddess"] = {
|
||||
achievements: [],
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
disciples: [],
|
||||
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
|
||||
equipment: [],
|
||||
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
totalPrayersEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { goddessAchievementsAdded: number; goddessBossesAdded: number; goddessQuestsAdded: number; goddessZonesAdded: number };
|
||||
expect(body.goddessAchievementsAdded).toBeGreaterThan(0);
|
||||
expect(body.goddessBossesAdded).toBeGreaterThan(0);
|
||||
expect(body.goddessQuestsAdded).toBeGreaterThan(0);
|
||||
expect(body.goddessZonesAdded).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns zero goddess counts when state.goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { goddessAchievementsAdded: number; goddessBossesAdded: number; goddessDiscipesAdded: number; goddessEquipmentAdded: number; goddessExplorationAreasAdded: number; goddessQuestsAdded: number; goddessUpgradesAdded: number; goddessZonesAdded: number };
|
||||
expect(body.goddessAchievementsAdded).toBe(0);
|
||||
expect(body.goddessBossesAdded).toBe(0);
|
||||
expect(body.goddessDiscipesAdded).toBe(0);
|
||||
expect(body.goddessEquipmentAdded).toBe(0);
|
||||
expect(body.goddessExplorationAreasAdded).toBe(0);
|
||||
expect(body.goddessQuestsAdded).toBe(0);
|
||||
expect(body.goddessUpgradesAdded).toBe(0);
|
||||
expect(body.goddessZonesAdded).toBe(0);
|
||||
});
|
||||
|
||||
it("injects goddess exploration areas when goddess has no areas", async () => {
|
||||
const goddess: GameState["goddess"] = {
|
||||
achievements: [],
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
disciples: [],
|
||||
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
|
||||
equipment: [],
|
||||
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
totalPrayersEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { goddessExplorationAreasAdded: number };
|
||||
expect(body.goddessExplorationAreasAdded).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /sync-new-content — vampire injection", () => {
|
||||
const syncNewContent = () =>
|
||||
app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" }));
|
||||
|
||||
const makeVampireState = (): NonNullable<GameState["vampire"]> => ({
|
||||
achievements: [],
|
||||
awakening: { count: 0, purchasedUpgradeIds: [], soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1 },
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: { count: 0, ichor: 0, ichorBloodMultiplier: 1, ichorCombatMultiplier: 1, ichorThrallsMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
});
|
||||
|
||||
it("injects vampire content arrays when state.vampire exists with empty arrays", async () => {
|
||||
const state = makeState({ vampire: makeVampireState() });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { vampireAchievementsAdded: number; vampireBossesAdded: number; vampireQuestsAdded: number; vampireZonesAdded: number };
|
||||
expect(body.vampireAchievementsAdded).toBeGreaterThan(0);
|
||||
expect(body.vampireBossesAdded).toBeGreaterThan(0);
|
||||
expect(body.vampireQuestsAdded).toBeGreaterThan(0);
|
||||
expect(body.vampireZonesAdded).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns zero vampire counts when state.vampire is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { vampireAchievementsAdded: number; vampireBossesAdded: number; vampireEquipmentAdded: number; vampireExplorationAreasAdded: number; vampireQuestsAdded: number; vampireThrallsAdded: number; vampireUpgradesAdded: number; vampireZonesAdded: number };
|
||||
expect(body.vampireAchievementsAdded).toBe(0);
|
||||
expect(body.vampireBossesAdded).toBe(0);
|
||||
expect(body.vampireEquipmentAdded).toBe(0);
|
||||
expect(body.vampireExplorationAreasAdded).toBe(0);
|
||||
expect(body.vampireQuestsAdded).toBe(0);
|
||||
expect(body.vampireThrallsAdded).toBe(0);
|
||||
expect(body.vampireUpgradesAdded).toBe(0);
|
||||
expect(body.vampireZonesAdded).toBe(0);
|
||||
});
|
||||
|
||||
it("injects vampire exploration areas when vampire has no areas", async () => {
|
||||
const state = makeState({ vampire: makeVampireState() });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { vampireExplorationAreasAdded: number };
|
||||
expect(body.vampireExplorationAreasAdded).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /grant-eternal-sovereignty", () => {
|
||||
const grantEternalSovereignty = () =>
|
||||
app.fetch(new Request("http://localhost/debug/grant-eternal-sovereignty", { method: "POST" }));
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("No save found");
|
||||
});
|
||||
|
||||
it("returns 200 with unchanged state when eternalSovereignty count is already >= 1", async () => {
|
||||
const vampire: NonNullable<GameState["vampire"]> = {
|
||||
achievements: [],
|
||||
awakening: { count: 0, purchasedUpgradeIds: [], soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1 },
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 1 },
|
||||
exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: { count: 0, ichor: 0, ichorBloodMultiplier: 1, ichorCombatMultiplier: 1, ichorThrallsMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
expect(body.state.vampire?.eternalSovereignty.count).toBe(1);
|
||||
expect(vi.mocked(prisma.gameState.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 and grants eternal sovereignty when not yet granted", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
expect(body.state.vampire?.eternalSovereignty.count).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 200 and sets eternalSovereignty when vampire exists with count 0", async () => {
|
||||
const vampire: NonNullable<GameState["vampire"]> = {
|
||||
achievements: [],
|
||||
awakening: { count: 0, purchasedUpgradeIds: [], soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1 },
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: { count: 0, ichor: 0, ichorBloodMultiplier: 1, ichorCombatMultiplier: 1, ichorThrallsMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
expect(body.state.vampire?.eternalSovereignty.count).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 200 with HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
const vampire: NonNullable<GameState["vampire"]> = {
|
||||
achievements: [],
|
||||
awakening: { count: 0, purchasedUpgradeIds: [], soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1 },
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 1 },
|
||||
exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: { count: 0, ichor: 0, ichorBloodMultiplier: 1, ichorCombatMultiplier: 1, ichorThrallsMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { signature: string | undefined };
|
||||
expect(body.signature).toBeDefined();
|
||||
delete process.env.ANTI_CHEAT_SECRET;
|
||||
});
|
||||
|
||||
it("returns 500 when DB throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 when DB throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /grant-apotheosis", () => {
|
||||
const grantApotheosis = () =>
|
||||
app.fetch(new Request("http://localhost/debug/grant-apotheosis", { method: "POST" }));
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await grantApotheosis();
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("No save found");
|
||||
});
|
||||
|
||||
it("returns 200 with unchanged state when apotheosis count is already >= 1", async () => {
|
||||
const state = makeState({ apotheosis: { count: 1 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await grantApotheosis();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
expect(body.state.apotheosis?.count).toBe(1);
|
||||
expect(vi.mocked(prisma.gameState.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 and grants apotheosis with goddess state when not yet granted", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await grantApotheosis();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
expect(body.state.apotheosis?.count).toBe(1);
|
||||
expect(body.state.goddess).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 200 with HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
const state = makeState({ apotheosis: { count: 1 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await grantApotheosis();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { signature: string | undefined };
|
||||
expect(body.signature).toBeDefined();
|
||||
delete process.env.ANTI_CHEAT_SECRET;
|
||||
});
|
||||
|
||||
it("returns 500 when DB throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await grantApotheosis();
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 when DB throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
|
||||
const res = await grantApotheosis();
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /hard-reset", () => {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// stardust_prayers_1 costs 2 stardust
|
||||
const TEST_UPGRADE_ID = "stardust_prayers_1";
|
||||
|
||||
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
|
||||
zones: [{ id: "goddess_celestial_garden", name: "Celestial Garden", description: "", emoji: "🌸", status: "unlocked", unlockBossId: null, unlockQuestId: null }],
|
||||
bosses: [
|
||||
{
|
||||
id: "divine_heart_sovereign",
|
||||
name: "Divine Heart Sovereign",
|
||||
description: "",
|
||||
status: "defeated",
|
||||
maxHp: 1000,
|
||||
currentHp: 0,
|
||||
damagePerSecond: 10,
|
||||
prayersReward: 100,
|
||||
divinityReward: 1,
|
||||
stardustReward: 1,
|
||||
upgradeRewards: [],
|
||||
equipmentRewards: [],
|
||||
consecrationRequirement: 0,
|
||||
zoneId: "goddess_celestial_garden",
|
||||
bountyDivinity: 5,
|
||||
},
|
||||
],
|
||||
quests: [],
|
||||
disciples: [],
|
||||
equipment: [],
|
||||
upgrades: [],
|
||||
achievements: [],
|
||||
consecration: {
|
||||
count: 0,
|
||||
divinity: 10,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
enlightenment: {
|
||||
count: 0,
|
||||
stardust: 10,
|
||||
purchasedUpgradeIds: [],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
},
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("enlightenment route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { enlightenmentRouter } = await import("../../src/routes/enlightenment.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/enlightenment", enlightenmentRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/enlightenment${path}`, {
|
||||
method: "POST",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /", () => {
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not eligible (final boss not defeated)", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
// Override final boss to available (not defeated)
|
||||
goddess.bosses[0]!.status = "available";
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with stardustEarned and newEnlightenmentCount on success", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.consecration.count = 4; // sqrt(4)*1 = 2 stardust
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { stardustEarned: number; newEnlightenmentCount: number };
|
||||
expect(body.newEnlightenmentCount).toBe(1);
|
||||
expect(body.stardustEarned).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /buy-upgrade", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy-upgrade", {});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown upgrade", async () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is already purchased", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.enlightenment.purchasedUpgradeIds = [TEST_UPGRADE_ID];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough stardust", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.enlightenment.stardust = 0; // stardust_prayers_1 costs 2
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with updated multipliers on success", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.enlightenment.stardust = 10;
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { stardustRemaining: number; purchasedUpgradeIds: string[] };
|
||||
expect(body.stardustRemaining).toBe(8); // 10 - 2
|
||||
expect(body.purchasedUpgradeIds).toContain(TEST_UPGRADE_ID);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -513,6 +513,188 @@ describe("game route", () => {
|
||||
const res = await save({ state: stateWithCompanion });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("passes through goddess when incoming has goddess and previous does not", async () => {
|
||||
const goddess: GameState["goddess"] = {
|
||||
achievements: [],
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
disciples: [],
|
||||
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
|
||||
equipment: [],
|
||||
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
totalPrayersEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const prevState = makeState();
|
||||
const incomingState = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
|
||||
expect(savedState.goddess).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not add goddess to result when neither previous nor incoming has goddess", async () => {
|
||||
const prevState = makeState();
|
||||
const incomingState = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
|
||||
expect(savedState.goddess).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves previous goddess when incoming save lacks goddess", async () => {
|
||||
const goddess: GameState["goddess"] = {
|
||||
achievements: [],
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
disciples: [],
|
||||
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
|
||||
equipment: [],
|
||||
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
totalPrayersEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const prevState = makeState({ goddess });
|
||||
const incomingState = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
|
||||
expect(savedState.goddess).toEqual(goddess);
|
||||
});
|
||||
|
||||
it("caps goddess totalPrayersEarned at previous value (server-only field)", async () => {
|
||||
const prevGoddess: GameState["goddess"] = {
|
||||
achievements: [],
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
disciples: [],
|
||||
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
|
||||
equipment: [],
|
||||
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
totalPrayersEarned: 100,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const incomingGoddess: GameState["goddess"] = {
|
||||
...prevGoddess,
|
||||
totalPrayersEarned: 9999,
|
||||
};
|
||||
const prevState = makeState({ goddess: prevGoddess });
|
||||
const incomingState = makeState({ goddess: incomingGoddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
|
||||
expect(savedState.goddess?.totalPrayersEarned).toBe(100);
|
||||
});
|
||||
|
||||
it("restores goddess boss defeated status when incoming tries to un-defeat it", async () => {
|
||||
const prevGoddess: GameState["goddess"] = {
|
||||
achievements: [],
|
||||
baseClickPower: 1,
|
||||
bosses: [{ id: "divine_sentinel", status: "defeated", currentHp: 0, maxHp: 5000 }] as GameState["goddess"]["bosses"],
|
||||
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
disciples: [],
|
||||
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
|
||||
equipment: [],
|
||||
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
totalPrayersEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const incomingGoddess: GameState["goddess"] = {
|
||||
...prevGoddess,
|
||||
bosses: [{ id: "divine_sentinel", status: "available", currentHp: 5000, maxHp: 5000 }] as GameState["goddess"]["bosses"],
|
||||
};
|
||||
const prevState = makeState({ goddess: prevGoddess });
|
||||
const incomingState = makeState({ goddess: incomingGoddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
|
||||
const boss = savedState.goddess?.bosses.find((b) => b.id === "divine_sentinel");
|
||||
expect(boss?.status).toBe("defeated");
|
||||
});
|
||||
|
||||
it("restores goddess quest completed status when incoming tries to un-complete it", async () => {
|
||||
const prevGoddess: GameState["goddess"] = {
|
||||
achievements: [],
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
disciples: [],
|
||||
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
|
||||
equipment: [],
|
||||
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [{ id: "offering_ritual", status: "completed" }] as GameState["goddess"]["quests"],
|
||||
totalPrayersEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const incomingGoddess: GameState["goddess"] = {
|
||||
...prevGoddess,
|
||||
quests: [{ id: "offering_ritual", status: "available" }] as GameState["goddess"]["quests"],
|
||||
};
|
||||
const prevState = makeState({ goddess: prevGoddess });
|
||||
const incomingState = makeState({ goddess: incomingGoddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
||||
const res = await save({ state: incomingState });
|
||||
expect(res.status).toBe(200);
|
||||
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
|
||||
const quest = savedState.goddess?.quests.find((q) => q.id === "offering_ritual");
|
||||
expect(quest?.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /load error path", () => {
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeConsecration = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
divinity: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as string[],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeEnlightenment = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
stardust: 0,
|
||||
purchasedUpgradeIds: [] as string[],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGoddessExploration = (overrides: Record<string, unknown> = {}) => ({
|
||||
areas: [] as Array<{ id: string; status: string }>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
craftedRecipeIds: [] as string[],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGoddessBoss = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "test_goddess_boss",
|
||||
name: "Test Goddess Boss",
|
||||
description: "A test boss",
|
||||
status: "available",
|
||||
maxHp: 100,
|
||||
currentHp: 100,
|
||||
damagePerSecond: 1,
|
||||
prayersReward: 50,
|
||||
divinityReward: 2,
|
||||
stardustReward: 0,
|
||||
upgradeRewards: [] as string[],
|
||||
equipmentRewards: [] as string[],
|
||||
consecrationRequirement: 0,
|
||||
zoneId: "test_goddess_zone",
|
||||
bountyDivinity: 5,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDisciple = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "test_disciple",
|
||||
name: "Test Disciple",
|
||||
class: "oracle" as const,
|
||||
level: 10,
|
||||
baseCost: 100,
|
||||
prayersPerSecond: 1,
|
||||
divinityPerSecond: 0,
|
||||
combatPower: 10_000,
|
||||
count: 1,
|
||||
unlocked: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGoddessState = (overrides: Record<string, unknown> = {}) => ({
|
||||
zones: [] as Array<{ id: string; status: string; unlockBossId: string | null; unlockQuestId: string | null }>,
|
||||
bosses: [ makeGoddessBoss() ],
|
||||
quests: [] as Array<{ id: string; status: string }>,
|
||||
disciples: [ makeDisciple() ],
|
||||
equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record<string, unknown> }>,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number; discipleId?: string }>,
|
||||
achievements: [] as Array<{ id: string; completed: boolean }>,
|
||||
consecration: makeConsecration(),
|
||||
enlightenment: makeEnlightenment(),
|
||||
exploration: makeGoddessExploration(),
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
goddess: makeGoddessState() as GameState["goddess"],
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("goddessBoss route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { goddessBossRouter } = await import("../../src/routes/goddessBoss.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/goddess-boss", goddessBossRouter);
|
||||
});
|
||||
|
||||
const challenge = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-boss/challenge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when bossId is missing", async () => {
|
||||
const res = await challenge({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess realm is not unlocked", async () => {
|
||||
const state = makeState({ goddess: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Goddess realm not unlocked");
|
||||
});
|
||||
|
||||
it("returns 404 when boss is not found in goddess state", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({ bosses: [] }) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Boss not found");
|
||||
});
|
||||
|
||||
it("returns 400 when boss status is defeated", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ status: "defeated" }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Boss is not currently available");
|
||||
});
|
||||
|
||||
it("returns 400 when boss status is locked", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ status: "locked" }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("accepts in_progress boss status", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ status: "in_progress" }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 when consecration requirement is not met", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ consecrationRequirement: 3 }) ],
|
||||
consecration: makeConsecration({ count: 0 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Consecration requirement not met");
|
||||
});
|
||||
|
||||
it("returns 400 when party has no combat power (all disciples have count 0)", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
disciples: [ makeDisciple({ count: 0 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Your disciples have no combat power");
|
||||
});
|
||||
|
||||
it("returns 400 when party has no combat power (disciples array is empty)", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
disciples: [],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with rewards when party wins", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1, level: 10 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { prayers: number; divinity: number } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.prayers).toBe(50);
|
||||
expect(body.rewards.divinity).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 200 with bountyDivinity when first kill", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 50, maxHp: 50, damagePerSecond: 1, id: "celestial_sprite" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "celestial_sprite" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.bountyDivinity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns 0 bountyDivinity when already claimed", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "celestial_sprite", bountyDivinityClaimed: true }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "celestial_sprite" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.bountyDivinity).toBe(0);
|
||||
});
|
||||
|
||||
it("unlocks upgrade rewards on win", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ upgradeRewards: [ "test_upgrade" ] }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
upgrades: [ { id: "test_upgrade", purchased: false, unlocked: false, target: "global", multiplier: 1 } ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { upgradeIds: string[] } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.upgradeIds).toContain("test_upgrade");
|
||||
});
|
||||
|
||||
it("unlocks next zone boss when boss is defeated", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [
|
||||
makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }),
|
||||
makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 0 }),
|
||||
],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("does not unlock next boss if consecration requirement not met", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [
|
||||
makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }),
|
||||
makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 5 }),
|
||||
],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
consecration: makeConsecration({ count: 0 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks goddess zone when boss and quest conditions are both met", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" },
|
||||
],
|
||||
quests: [ { id: "test_quest", status: "completed" } ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("does not unlock zone when quest condition is not satisfied", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" },
|
||||
],
|
||||
quests: [ { id: "test_quest", status: "active" } ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks zone when unlockQuestId is null", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: null },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("skips zone that is already unlocked", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "test_goddess_zone", status: "unlocked", unlockBossId: "test_goddess_boss", unlockQuestId: null },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("skips zone whose unlockBossId does not match the defeated boss", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "other_zone", status: "locked", unlockBossId: "different_boss", unlockQuestId: null },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("applies global upgrade multiplier to party DPS", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ],
|
||||
upgrades: [ { id: "global_u", purchased: true, target: "global", multiplier: 100_000 } ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("applies disciple-specific upgrade multiplier", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ],
|
||||
disciples: [ makeDisciple({ id: "test_disciple", combatPower: 1, count: 1, level: 10 }) ],
|
||||
upgrades: [
|
||||
{ id: "disciple_u", purchased: true, target: "disciple", multiplier: 100_000, discipleId: "test_disciple" },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("skips unpurchased upgrades", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 100_000, damagePerSecond: 1 }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ],
|
||||
upgrades: [
|
||||
{ id: "not_bought", purchased: false, target: "global", multiplier: 100_000 },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 with casualties when party loses", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [
|
||||
makeGoddessBoss({ currentHp: 1_000_000, maxHp: 1_000_000, damagePerSecond: 1_000_000 }),
|
||||
],
|
||||
disciples: [
|
||||
makeDisciple({ combatPower: 1, count: 10, level: 1 }),
|
||||
makeDisciple({ id: "zero_disciple", combatPower: 0, count: 0, level: 1 }),
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; casualties: Array<{ discipleId: string }> };
|
||||
expect(body.won).toBe(false);
|
||||
expect(Array.isArray(body.casualties)).toBe(true);
|
||||
});
|
||||
|
||||
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { signature: string | undefined };
|
||||
expect(body.signature).toBeDefined();
|
||||
delete process.env.ANTI_CHEAT_SECRET;
|
||||
});
|
||||
|
||||
it("omits signature when ANTI_CHEAT_SECRET is not set", async () => {
|
||||
delete process.env.ANTI_CHEAT_SECRET;
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { signature: string | undefined };
|
||||
expect(body.signature).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// prayer_amplifier requires: divine_petal×3, prayer_crystal×2; bonus: gold_income 1.1
|
||||
const TEST_RECIPE_ID = "prayer_amplifier";
|
||||
|
||||
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
|
||||
zones: [],
|
||||
bosses: [],
|
||||
quests: [],
|
||||
disciples: [],
|
||||
equipment: [],
|
||||
upgrades: [],
|
||||
achievements: [],
|
||||
consecration: {
|
||||
count: 0,
|
||||
divinity: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
enlightenment: {
|
||||
count: 0,
|
||||
stardust: 0,
|
||||
purchasedUpgradeIds: [],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
},
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [
|
||||
{ materialId: "divine_petal", quantity: 5 },
|
||||
{ materialId: "prayer_crystal", quantity: 5 },
|
||||
],
|
||||
craftedRecipeIds: [],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("goddessCraft route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { goddessCraftRouter } = await import("../../src/routes/goddessCraft.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/goddess-craft", goddessCraftRouter);
|
||||
});
|
||||
|
||||
const post = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-craft", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when recipeId is missing", async () => {
|
||||
const res = await post({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown recipe", async () => {
|
||||
const res = await post({ recipeId: "nonexistent_recipe" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when recipe is already crafted", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.exploration.craftedRecipeIds = [TEST_RECIPE_ID];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough material (first requirement)", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.exploration.materials = [
|
||||
{ materialId: "divine_petal", quantity: 1 }, // needs 3
|
||||
{ materialId: "prayer_crystal", quantity: 5 },
|
||||
];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when material is completely absent", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.exploration.materials = []; // neither material present
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with updated multipliers and materials on success", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
recipeId: string;
|
||||
bonusType: string;
|
||||
bonusValue: number;
|
||||
craftedPrayersMultiplier: number;
|
||||
craftedDivinityMultiplier: number;
|
||||
craftedCombatMultiplier: number;
|
||||
materials: Array<{ materialId: string; quantity: number }>;
|
||||
};
|
||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||
expect(body.bonusType).toBe("gold_income");
|
||||
expect(body.bonusValue).toBe(1.1);
|
||||
expect(body.craftedPrayersMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,619 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState, GoddessExplorationArea } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
// Custom test areas exercising event types not present in the real data
|
||||
const PRAYERS_LOSS_AREA: GoddessExplorationArea = {
|
||||
description: "Test area for prayers_loss events",
|
||||
durationSeconds: 1,
|
||||
events: [
|
||||
{ effect: { amount: 100, type: "prayers_loss" }, id: "test_prayers_loss", text: "You lost some prayers." },
|
||||
],
|
||||
id: "test_prayers_loss_area",
|
||||
name: "Test Prayers Loss Area",
|
||||
possibleMaterials: [],
|
||||
zoneId: "goddess_celestial_garden",
|
||||
};
|
||||
|
||||
const DIVINITY_GAIN_AREA: GoddessExplorationArea = {
|
||||
description: "Test area for divinity_gain events",
|
||||
durationSeconds: 1,
|
||||
events: [
|
||||
{ effect: { amount: 10, type: "divinity_gain" }, id: "test_divinity_gain", text: "You gained divinity." },
|
||||
],
|
||||
id: "test_divinity_gain_area",
|
||||
name: "Test Divinity Gain Area",
|
||||
possibleMaterials: [],
|
||||
zoneId: "goddess_celestial_garden",
|
||||
};
|
||||
|
||||
vi.mock("../../src/data/goddessExplorations.js", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../../src/data/goddessExplorations.js")>();
|
||||
return {
|
||||
defaultGoddessExplorationAreas: [
|
||||
...original.defaultGoddessExplorationAreas,
|
||||
PRAYERS_LOSS_AREA,
|
||||
DIVINITY_GAIN_AREA,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// garden_glade: zoneId=goddess_celestial_garden, durationSeconds=30
|
||||
// events[0]: prayers_gain 50; events[1]: disciple_loss 0.05
|
||||
// possibleMaterials: divine_petal(weight 5), prayer_crystal(weight 3) — total 8
|
||||
const TEST_AREA_ID = "garden_glade";
|
||||
const TEST_ZONE_ID = "goddess_celestial_garden";
|
||||
|
||||
// celestial_meadow: durationSeconds=60
|
||||
// events[0]: prayers_gain 200; events[1]: sacred_material_gain celestial_dust qty 2
|
||||
const MATERIAL_AREA_ID = "celestial_meadow";
|
||||
|
||||
const makeGoddessState = (areaId: string, zoneStatus: "unlocked" | "locked" = "unlocked"): NonNullable<GameState["goddess"]> => ({
|
||||
zones: [
|
||||
{
|
||||
id: TEST_ZONE_ID,
|
||||
name: "Celestial Garden",
|
||||
description: "",
|
||||
emoji: "🌸",
|
||||
status: zoneStatus,
|
||||
unlockBossId: null,
|
||||
unlockQuestId: null,
|
||||
},
|
||||
],
|
||||
bosses: [],
|
||||
quests: [],
|
||||
disciples: [
|
||||
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
|
||||
],
|
||||
equipment: [],
|
||||
upgrades: [],
|
||||
achievements: [],
|
||||
consecration: {
|
||||
count: 0,
|
||||
divinity: 50,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
enlightenment: {
|
||||
count: 0,
|
||||
stardust: 0,
|
||||
purchasedUpgradeIds: [],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
},
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: areaId, status: "available" },
|
||||
],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
/** Builds a state with the area in_progress and startedAt in the past so it's complete. */
|
||||
const makeCompletedAreaState = (
|
||||
areaId: string,
|
||||
extraMaterials: Array<{ materialId: string; quantity: number }> = [],
|
||||
extraPrayers = 0,
|
||||
): GameState => {
|
||||
const goddess = makeGoddessState(areaId);
|
||||
goddess.exploration.areas = [{ id: areaId, status: "in_progress", startedAt: 0 }];
|
||||
goddess.exploration.materials = extraMaterials;
|
||||
return makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: extraPrayers } });
|
||||
};
|
||||
|
||||
describe("goddessExplore route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { goddessExploreRouter } = await import("../../src/routes/goddessExplore.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/goddess-explore", goddessExploreRouter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const getClaimable = (areaId?: string) => {
|
||||
const url = areaId === undefined
|
||||
? "http://localhost/goddess-explore/claimable"
|
||||
: `http://localhost/goddess-explore/claimable?areaId=${areaId}`;
|
||||
return app.fetch(new Request(url));
|
||||
};
|
||||
|
||||
const postStart = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-explore/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
const postCollect = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-explore/collect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
describe("GET /claimable", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await getClaimable();
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await getClaimable("nonexistent_area");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns claimable=false when goddess is undefined", async () => {
|
||||
const state = makeState(); // no goddess
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=false when area is not in_progress", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// area status is "available" (not in_progress)
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=false when area not found in state", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = []; // area missing entirely
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=false when exploration is not yet complete", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// startedAt = now → not complete yet (duration is 30s)
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=true when exploration is complete", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// startedAt = 0 → expired long ago
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /start", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await postStart({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await postStart({ areaId: "nonexistent_area" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when zone is not unlocked", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID, "locked");
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when zone is not found in goddess state", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.zones = []; // zone missing
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when area is not found in state", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = []; // area missing
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when another exploration is already in progress", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = [
|
||||
{ id: TEST_AREA_ID, status: "available" },
|
||||
{ id: "other_area", status: "in_progress" },
|
||||
];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when area is locked", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "locked" }];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with areaId and endsAt on success", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { areaId: string; endsAt: number };
|
||||
expect(body.areaId).toBe(TEST_AREA_ID);
|
||||
expect(body.endsAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /collect", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await postCollect({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await postCollect({ areaId: "nonexistent_area" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when area is not found in state", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = [];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when area is not in_progress", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// area is "available", not "in_progress"
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when exploration is not yet complete", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// startedAt = now → still in progress for 30s
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns foundNothing=true when Math.random is below 0.2 (nothing path)", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.1); // < 0.2 → nothing
|
||||
const state = makeCompletedAreaState(TEST_AREA_ID);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; nothingMessage: string; materialsFound: unknown[] };
|
||||
expect(body.foundNothing).toBe(true);
|
||||
expect(typeof body.nothingMessage).toBe("string");
|
||||
});
|
||||
|
||||
it("applies prayers_gain event and returns prayersChange > 0", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// garden_glade has 2 events: [prayers_gain(0), disciple_loss(1)]
|
||||
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
|
||||
// Call 2: event index Math.floor(0.1 * 2) = 0 → prayers_gain
|
||||
// Call 3: possibleMaterials roll (total weight 8): 0 * 8 = 0, 0 - 5 = -5 ≤ 0 → divine_petal
|
||||
// Call 4: quantity Math.floor(0 * (3-1+1)) + 1 = 0 + 1 = 1
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5)
|
||||
.mockReturnValueOnce(0.1)
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(0);
|
||||
const state = makeCompletedAreaState(TEST_AREA_ID);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; event: { prayersChange: number }; materialsFound: Array<{ materialId: string }> };
|
||||
expect(body.foundNothing).toBe(false);
|
||||
expect(body.event.prayersChange).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("applies sacred_material_gain event and pushes new material", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// celestial_meadow: events[1] = sacred_material_gain celestial_dust qty 2
|
||||
// index 1: Math.floor(0.6 * 2) = 1
|
||||
// possibleMaterials: [divine_petal(4), celestial_dust(3)] total 7
|
||||
// Call 3: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal (new material)
|
||||
// Call 4: Math.floor(0 * (4-2+1)) + 2 = 0 + 2 = 2
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.6) // event index: Math.floor(0.6 * 2) = 1 → sacred_material_gain
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal
|
||||
.mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 2 = 2
|
||||
const state = makeCompletedAreaState(MATERIAL_AREA_ID); // no materials in state
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } }; materialsFound: Array<{ materialId: string }> };
|
||||
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
|
||||
});
|
||||
|
||||
it("increments existing material quantity on sacred_material_gain event", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// Same as above — celestial_meadow events[1] = sacred_material_gain celestial_dust
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.6) // event: sacred_material_gain
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
|
||||
.mockReturnValueOnce(0); // quantity → 2
|
||||
const state = makeCompletedAreaState(MATERIAL_AREA_ID, [
|
||||
{ materialId: "celestial_dust", quantity: 5 }, // pre-existing
|
||||
]);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
|
||||
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
|
||||
expect(body.event.materialGained?.quantity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns materialsFound with new material from possibleMaterials when none pre-existing", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// prayers_gain event, then roll for divine_petal (new)
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal (first, weight 5)
|
||||
.mockReturnValueOnce(0); // quantity → min (1)
|
||||
const state = makeCompletedAreaState(TEST_AREA_ID); // no materials
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { materialsFound: Array<{ materialId: string; quantity: number }> };
|
||||
expect(body.materialsFound.length).toBeGreaterThan(0);
|
||||
expect(body.materialsFound[0]?.materialId).toBe("divine_petal");
|
||||
});
|
||||
|
||||
it("increments existing possibleMaterial quantity when material is already in state", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
|
||||
.mockReturnValueOnce(0); // quantity → 1
|
||||
const state = makeCompletedAreaState(TEST_AREA_ID, [
|
||||
{ materialId: "divine_petal", quantity: 10 }, // pre-existing
|
||||
]);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { materialsFound: Array<{ materialId: string }> };
|
||||
expect(body.materialsFound.some((m) => {
|
||||
return m.materialId === "divine_petal";
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("applies disciple_loss event and reduces disciple counts", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// garden_glade events[1] = disciple_loss fraction 0.05
|
||||
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
|
||||
// Call 2: event index Math.floor(0.6 * 2) = 1 → disciple_loss
|
||||
// possibleMaterials: total weight 8; call 3: 0.9 * 8 = 7.2; 7.2 - 5 = 2.2 > 0; 2.2 - 3 = -0.8 ≤ 0 → prayer_crystal
|
||||
// Call 4: quantity for prayer_crystal: Math.floor(0 * (2-1+1)) + 1 = 1
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.6) // event: Math.floor(0.6 * 2) = 1 → disciple_loss
|
||||
.mockReturnValueOnce(0.9) // possibleMaterials roll → prayer_crystal
|
||||
.mockReturnValueOnce(0); // quantity → 1
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
|
||||
// Need disciples with non-zero count so lost > 0 triggers
|
||||
goddess.disciples = [
|
||||
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
|
||||
];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("applies prayers_loss event and returns negative prayersChange", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// test_prayers_loss_area has 1 event: prayers_loss 100
|
||||
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
|
||||
// Call 2: event index Math.floor(0.1 * 1) = 0 → prayers_loss
|
||||
// No possibleMaterials, so no further calls needed
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5)
|
||||
.mockReturnValueOnce(0.1);
|
||||
const goddess = makeGoddessState(PRAYERS_LOSS_AREA.id);
|
||||
goddess.exploration.areas = [{ id: PRAYERS_LOSS_AREA.id, status: "in_progress", startedAt: 0 }];
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: PRAYERS_LOSS_AREA.id });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { prayersChange: number }; foundNothing: boolean };
|
||||
expect(body.foundNothing).toBe(false);
|
||||
expect(body.event.prayersChange).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("applies divinity_gain event and increases divinity", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// test_divinity_gain_area has 1 event: divinity_gain 10
|
||||
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
|
||||
// Call 2: event index Math.floor(0.1 * 1) = 0 → divinity_gain
|
||||
// No possibleMaterials
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5)
|
||||
.mockReturnValueOnce(0.1);
|
||||
const goddess = makeGoddessState(DIVINITY_GAIN_AREA.id);
|
||||
goddess.exploration.areas = [{ id: DIVINITY_GAIN_AREA.id, status: "in_progress", startedAt: 0 }];
|
||||
const initialDivinity = goddess.consecration.divinity;
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: DIVINITY_GAIN_AREA.id });
|
||||
expect(res.status).toBe(200);
|
||||
// Verify state was saved with updated divinity
|
||||
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
|
||||
data: { state: { goddess: { consecration: { divinity: number } } } };
|
||||
};
|
||||
expect(updateArg.data.state.goddess.consecration.divinity).toBe(initialDivinity + 10);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// prayer_offering_1 costs 50 prayers, 0 divinity, 0 stardust; unlocked: true
|
||||
const TEST_UPGRADE_ID = "prayer_offering_1";
|
||||
|
||||
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
|
||||
zones: [],
|
||||
bosses: [],
|
||||
quests: [],
|
||||
disciples: [],
|
||||
equipment: [],
|
||||
upgrades: [
|
||||
{
|
||||
id: TEST_UPGRADE_ID,
|
||||
name: "Morning Offering I",
|
||||
description: "",
|
||||
target: "prayers",
|
||||
multiplier: 1.25,
|
||||
costPrayers: 50,
|
||||
costDivinity: 0,
|
||||
costStardust: 0,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
],
|
||||
achievements: [],
|
||||
consecration: {
|
||||
count: 0,
|
||||
divinity: 100,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
enlightenment: {
|
||||
count: 0,
|
||||
stardust: 100,
|
||||
purchasedUpgradeIds: [],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
},
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("goddessUpgrade route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { goddessUpgradeRouter } = await import("../../src/routes/goddessUpgrade.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/goddess-upgrade", goddessUpgradeRouter);
|
||||
});
|
||||
|
||||
const post = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-upgrade/buy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown upgrade", async () => {
|
||||
const res = await post({ upgradeId: "nonexistent_upgrade" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when upgrade is not found in goddess state", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades = []; // no upgrades in state
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is not yet unlocked", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades[0]!.unlocked = false;
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is already purchased", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades[0]!.purchased = true;
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when prayers is undefined (treats as 0)", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
// Omitting prayers entirely exercises the `?? 0` fallback on line 75
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough prayers", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 10 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough divinity", async () => {
|
||||
// prayer_offering_3 costs 1 divinity
|
||||
const upgradeId = "prayer_offering_3";
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades.push({
|
||||
id: upgradeId,
|
||||
name: "Morning Offering III",
|
||||
description: "",
|
||||
target: "prayers",
|
||||
multiplier: 2,
|
||||
costPrayers: 1000,
|
||||
costDivinity: 1,
|
||||
costStardust: 0,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
});
|
||||
goddess.consecration.divinity = 0; // need 1 but have 0
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 5000 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough stardust", async () => {
|
||||
// divine_spark_2 is in defaultGoddessUpgrades with costStardust: 1, costDivinity: 100, costPrayers: 500_000
|
||||
const upgradeId = "divine_spark_2";
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades.push({
|
||||
id: upgradeId,
|
||||
name: "Divine Spark II",
|
||||
description: "",
|
||||
target: "prayers",
|
||||
multiplier: 25,
|
||||
costPrayers: 500_000,
|
||||
costDivinity: 100,
|
||||
costStardust: 1,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
});
|
||||
goddess.consecration.divinity = 100; // enough divinity
|
||||
goddess.enlightenment.stardust = 0; // NOT enough stardust (need 1)
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 500_000 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with remaining resources on success", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { prayersRemaining: number; divinityRemaining: number; stardustRemaining: number };
|
||||
expect(body.prayersRemaining).toBe(150); // 200 - 50
|
||||
expect(body.divinityRemaining).toBe(100); // no divinity cost
|
||||
expect(body.stardustRemaining).toBe(100); // no stardust cost
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeAwakening = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
|
||||
achievements: [] as Array<{ id: string; unlockedAt: number | null; reward: unknown }>,
|
||||
awakening: makeAwakening(),
|
||||
baseClickPower: 1,
|
||||
bosses: [] as Array<{ id: string; status: string }>,
|
||||
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean }>,
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: {
|
||||
areas: [] as Array<{ id: string; status: string }>,
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [] as Array<string>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
},
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [] as Array<{ id: string; status: string }>,
|
||||
siring: makeSiring(),
|
||||
thralls: [] as Array<{ id: string; count: number }>,
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean }>,
|
||||
zones: [] as Array<{ id: string; status: string }>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("siring route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { siringRouter } = await import("../../src/routes/siring.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/siring", siringRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/siring${path}`, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /", () => {
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when vampire realm is not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not eligible (totalBloodEarned below threshold)", async () => {
|
||||
const state = makeState({ vampire: makeVampireState() as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Not eligible");
|
||||
});
|
||||
|
||||
it("returns ichorEarned on successful siring", async () => {
|
||||
const vampire = makeVampireState({ totalBloodEarned: 1_000_000 });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { ichorEarned: number; newSiringCount: number };
|
||||
expect(body.newSiringCount).toBe(1);
|
||||
expect(body.ichorEarned).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("applies threshold multiplier when siring_threshold upgrade is purchased", async () => {
|
||||
// siring_threshold_1 reduces threshold by 10% → 900_000 required instead of 1_000_000
|
||||
const vampire = makeVampireState({
|
||||
siring: makeSiring({ purchasedUpgradeIds: [ "siring_threshold_1" ] }),
|
||||
totalBloodEarned: 900_000,
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { newSiringCount: number };
|
||||
expect(body.newSiringCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /buy-upgrade", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy-upgrade", {});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown upgrade id", async () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_siring_upgrade" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when vampire realm is not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when the upgrade is already purchased", async () => {
|
||||
const vampire = makeVampireState({
|
||||
siring: makeSiring({ ichor: 10, purchasedUpgradeIds: [ "siring_blood_1" ] }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough ichor", async () => {
|
||||
const vampire = makeVampireState({ siring: makeSiring({ ichor: 0 }) });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
// siring_blood_1 costs 5 ichor, state has 0
|
||||
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns updated multipliers on a successful upgrade purchase", async () => {
|
||||
const vampire = makeVampireState({ siring: makeSiring({ ichor: 10 }) });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { ichorRemaining: number; purchasedUpgradeIds: Array<string> };
|
||||
expect(body.ichorRemaining).toBe(5); // 10 - 5 (siring_blood_1 costs 5)
|
||||
expect(body.purchasedUpgradeIds).toContain("siring_blood_1");
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error during buy-upgrade", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("/buy-upgrade", { upgradeId: "siring_blood_1" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeAwakening = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
|
||||
achievements: [] as Array<{ id: string; unlockedAt: number | null; reward: unknown }>,
|
||||
awakening: makeAwakening(),
|
||||
baseClickPower: 1,
|
||||
bosses: [] as Array<{ id: string; status: string; bountyIchorClaimed?: boolean }>,
|
||||
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean }>,
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: {
|
||||
areas: [] as Array<{ id: string; status: string }>,
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [] as Array<string>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
},
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [] as Array<{ id: string; status: string }>,
|
||||
siring: makeSiring(),
|
||||
thralls: [] as Array<{ id: string; count: number }>,
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean }>,
|
||||
zones: [] as Array<{ id: string; status: string }>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeEligibleVampireState = (overrides: Record<string, unknown> = {}) =>
|
||||
makeVampireState({
|
||||
bosses: [ { id: "eternal_darkness", status: "defeated" } ],
|
||||
siring: makeSiring({ count: 4 }),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("vampireAwakening route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { vampireAwakeningRouter } = await import("../../src/routes/vampireAwakening.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/vampire-awakening", vampireAwakeningRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/vampire-awakening${path}`, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /", () => {
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when vampire realm is not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Vampire realm");
|
||||
});
|
||||
|
||||
it("returns 400 when not eligible for awakening", async () => {
|
||||
const vampire = makeVampireState({ bosses: [] });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Not eligible");
|
||||
});
|
||||
|
||||
it("returns 400 when eternal_darkness boss is present but not defeated", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ { id: "eternal_darkness", status: "available" } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns newAwakeningCount and soulShardsEarned on success", async () => {
|
||||
const vampire = makeEligibleVampireState();
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { newAwakeningCount: number; soulShardsEarned: number };
|
||||
expect(body.newAwakeningCount).toBe(1);
|
||||
expect(body.soulShardsEarned).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("increments awakening count from existing count", async () => {
|
||||
const vampire = makeEligibleVampireState({
|
||||
awakening: makeAwakening({ count: 3, soulShards: 10 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { newAwakeningCount: number };
|
||||
expect(body.newAwakeningCount).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /buy-upgrade", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy-upgrade", {});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("upgradeId");
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown upgrade id", async () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_awakening_upgrade" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Unknown awakening upgrade");
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when vampire realm is not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Vampire realm");
|
||||
});
|
||||
|
||||
it("returns 400 when the upgrade is already purchased", async () => {
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: 100, purchasedUpgradeIds: [ "awakening_blood_1" ] }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("already purchased");
|
||||
});
|
||||
|
||||
it("returns 400 when not enough soul shards", async () => {
|
||||
// awakening_blood_1 costs 10 soul shards; state has 5
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: 5 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("soul shards");
|
||||
});
|
||||
|
||||
it("returns updated multipliers and remaining soul shards on success", async () => {
|
||||
// awakening_blood_1 costs 10 soul shards; state has 20
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: 20 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
purchasedUpgradeIds: Array<string>;
|
||||
soulShardsBloodMultiplier: number;
|
||||
soulShardsCombatMultiplier: number;
|
||||
soulShardsMetaMultiplier: number;
|
||||
soulShardsRemaining: number;
|
||||
soulShardsSiringIchorMultiplier: number;
|
||||
soulShardsSiringThresholdMultiplier: number;
|
||||
};
|
||||
expect(body.soulShardsRemaining).toBe(10); // 20 - 10 (awakening_blood_1 costs 10)
|
||||
expect(body.purchasedUpgradeIds).toContain("awakening_blood_1");
|
||||
expect(body.soulShardsBloodMultiplier).toBe(1.5); // awakening_blood_1 multiplier
|
||||
expect(body.soulShardsCombatMultiplier).toBe(1);
|
||||
expect(body.soulShardsMetaMultiplier).toBe(1);
|
||||
expect(body.soulShardsSiringIchorMultiplier).toBe(1);
|
||||
expect(body.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("deducts the exact upgrade cost from soul shards", async () => {
|
||||
// awakening_combat_1 costs 15 soul shards; state has 15
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: 15 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "awakening_combat_1" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { soulShardsRemaining: number };
|
||||
expect(body.soulShardsRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error during buy-upgrade", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value during buy-upgrade", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("/buy-upgrade", { upgradeId: "awakening_blood_1" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,581 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
ichorCombatMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeAwakening = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
|
||||
achievements: [] as Array<{ id: string; unlockedAt: number | null; reward: unknown }>,
|
||||
awakening: makeAwakening(),
|
||||
baseClickPower: 1,
|
||||
bosses: [] as Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
zoneId: string;
|
||||
maxHp: number;
|
||||
currentHp: number;
|
||||
damagePerSecond: number;
|
||||
siringRequirement: number;
|
||||
bloodReward: number;
|
||||
ichorReward: number;
|
||||
soulShardsReward: number;
|
||||
upgradeRewards: Array<string>;
|
||||
equipmentRewards: Array<string>;
|
||||
bountyIchorClaimed: boolean;
|
||||
}>,
|
||||
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean; type: string; bonus: Record<string, unknown> }>,
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: {
|
||||
areas: [] as Array<{ id: string; status: string; startedAt?: number; endsAt?: number; completedOnce?: boolean }>,
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [] as Array<string>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
},
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [] as Array<{ id: string; status: string; zoneId?: string; unlockQuestId?: string | null }>,
|
||||
siring: makeSiring(),
|
||||
thralls: [] as Array<{
|
||||
id: string;
|
||||
count: number;
|
||||
combatPower: number;
|
||||
level: number;
|
||||
unlocked: boolean;
|
||||
bloodPerSecond: number;
|
||||
ichorPerSecond: number;
|
||||
baseCost: number;
|
||||
class: string;
|
||||
name: string;
|
||||
}>,
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number; thrallId?: string; unlocked?: boolean }>,
|
||||
zones: [] as Array<{ id: string; status: string; unlockBossId?: string; unlockQuestId?: string | null }>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
const makeBoss = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "test_boss",
|
||||
status: "available",
|
||||
zoneId: "test_zone",
|
||||
maxHp: 100,
|
||||
currentHp: 100,
|
||||
damagePerSecond: 1,
|
||||
siringRequirement: 0,
|
||||
bloodReward: 100,
|
||||
ichorReward: 0,
|
||||
soulShardsReward: 0,
|
||||
upgradeRewards: [] as Array<string>,
|
||||
equipmentRewards: [] as Array<string>,
|
||||
bountyIchorClaimed: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeStrongThrall = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "test_thrall",
|
||||
count: 10000,
|
||||
combatPower: 1000,
|
||||
level: 1,
|
||||
unlocked: true,
|
||||
bloodPerSecond: 0,
|
||||
ichorPerSecond: 0,
|
||||
baseCost: 0,
|
||||
class: "fighter",
|
||||
name: "Fighter",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("vampireBoss route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { vampireBossRouter } = await import("../../src/routes/vampireBoss.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/vampire-boss", vampireBossRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/vampire-boss${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}));
|
||||
|
||||
describe("POST /challenge", () => {
|
||||
it("returns 400 when bossId is missing from body", async () => {
|
||||
const res = await post("/challenge", {});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Invalid request body");
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("No save found");
|
||||
});
|
||||
|
||||
it("returns 400 when vampire realm is not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Vampire realm");
|
||||
});
|
||||
|
||||
it("returns 404 when boss is not found in state", async () => {
|
||||
const vampire = makeVampireState({ bosses: [] });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/challenge", { bossId: "missing_boss" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Boss not found");
|
||||
});
|
||||
|
||||
it("returns 400 when boss status is defeated", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ status: "defeated" }) ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("not currently available");
|
||||
});
|
||||
|
||||
it("returns 400 when boss status is locked", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ status: "locked" }) ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("not currently available");
|
||||
});
|
||||
|
||||
it("allows challenge when boss status is in_progress", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1, status: "in_progress" }) ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 when siring requirement is not met", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ siringRequirement: 10 }) ],
|
||||
siring: makeSiring({ count: 0 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Siring requirement");
|
||||
});
|
||||
|
||||
it("returns 400 when thralls have no combat power (empty thralls array)", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss() ],
|
||||
thralls: [],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("no combat power");
|
||||
});
|
||||
|
||||
it("returns 400 when thrall count is zero", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss() ],
|
||||
thralls: [ makeStrongThrall({ count: 0 }) ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("no combat power");
|
||||
});
|
||||
|
||||
it("returns won: true with rewards on a successful boss kill", async () => {
|
||||
// Boss with 1 HP, party kills it before it kills party
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ bloodReward: 100, currentHp: 1, damagePerSecond: 1, maxHp: 1 }) ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { blood: number } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards).toBeDefined();
|
||||
expect(body.rewards.blood).toBe(100);
|
||||
});
|
||||
|
||||
it("sets boss status to defeated on win", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/challenge", { bossId: "test_boss" });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const savedBoss = savedState.vampire?.bosses.find((b) => b.id === "test_boss");
|
||||
expect(savedBoss?.status).toBe("defeated");
|
||||
});
|
||||
|
||||
it("unlocks next boss in same zone after win when siring requirement met", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [
|
||||
makeBoss({ currentHp: 1, id: "boss_1", maxHp: 1, zoneId: "zone_a" }),
|
||||
makeBoss({ id: "boss_2", siringRequirement: 0, status: "locked", zoneId: "zone_a" }),
|
||||
],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/challenge", { bossId: "boss_1" });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const nextBoss = savedState.vampire?.bosses.find((b) => b.id === "boss_2");
|
||||
expect(nextBoss?.status).toBe("available");
|
||||
});
|
||||
|
||||
it("returns won: false with casualties on loss", async () => {
|
||||
// Boss with very high HP/DPS, weak thrall
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 999_999, damagePerSecond: 999_999, maxHp: 999_999 }) ],
|
||||
thralls: [ makeStrongThrall({ combatPower: 1, count: 100, level: 1 }) ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; casualties: Array<unknown> };
|
||||
expect(body.won).toBe(false);
|
||||
expect(body.casualties).toBeDefined();
|
||||
});
|
||||
|
||||
it("resets boss HP to maxHp on loss", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 999_999, damagePerSecond: 999_999, maxHp: 999_999 }) ],
|
||||
thralls: [ makeStrongThrall({ combatPower: 1, count: 100 }) ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/challenge", { bossId: "test_boss" });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const savedBoss = savedState.vampire?.bosses.find((b) => b.id === "test_boss");
|
||||
expect(savedBoss?.currentHp).toBe(999_999);
|
||||
expect(savedBoss?.status).toBe("available");
|
||||
});
|
||||
|
||||
it("returns 500 on DB Error throw", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB failure"));
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 on non-Error throw", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error");
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("grants ichor reward on win", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, ichorReward: 5, maxHp: 1 }) ],
|
||||
siring: makeSiring({ ichor: 0 }),
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { rewards: { ichor: number } };
|
||||
expect(body.rewards.ichor).toBe(5);
|
||||
});
|
||||
|
||||
it("grants soulShards reward on win", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1, soulShardsReward: 3 }) ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { rewards: { soulShards: number } };
|
||||
expect(body.rewards.soulShards).toBe(3);
|
||||
});
|
||||
|
||||
it("increments lifetimeBossesDefeated on win", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
|
||||
lifetimeBossesDefeated: 2,
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/challenge", { bossId: "test_boss" });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
expect(savedState.vampire?.lifetimeBossesDefeated).toBe(3);
|
||||
});
|
||||
|
||||
it("unlocks upgrade rewards on win", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1, upgradeRewards: [ "upgrade_1" ] }) ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
upgrades: [ { id: "upgrade_1", multiplier: 2, purchased: false, target: "global", unlocked: false } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/challenge", { bossId: "test_boss" });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const upgrade = savedState.vampire?.upgrades.find((u) => u.id === "upgrade_1");
|
||||
expect(upgrade?.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("response includes combat stats", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { partyDPS: number; partyMaxHp: number; bossDPS: number };
|
||||
expect(body.partyDPS).toBeGreaterThan(0);
|
||||
expect(body.partyMaxHp).toBeGreaterThan(0);
|
||||
expect(body.bossDPS).toBe(1);
|
||||
});
|
||||
|
||||
it("unlocks a zone when its unlock boss is defeated and quest condition is met", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [
|
||||
makeBoss({ currentHp: 1, id: "boss_for_zone", maxHp: 1, zoneId: "zone_a" }),
|
||||
makeBoss({ id: "new_zone_first_boss", siringRequirement: 0, status: "locked", zoneId: "new_zone" }),
|
||||
],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
zones: [
|
||||
{ id: "already_unlocked", status: "unlocked", unlockBossId: "boss_for_zone", unlockQuestId: null },
|
||||
{ id: "wrong_boss_zone", status: "locked", unlockBossId: "different_boss", unlockQuestId: null },
|
||||
{ id: "new_zone", status: "locked", unlockBossId: "boss_for_zone", unlockQuestId: null },
|
||||
],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/challenge", { bossId: "boss_for_zone" });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const zone = savedState.vampire?.zones.find((z) => z.id === "new_zone");
|
||||
expect(zone?.status).toBe("unlocked");
|
||||
});
|
||||
|
||||
it("skips zone unlock when quest condition is not satisfied (quest exists but not completed)", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, id: "boss_for_quest_zone", maxHp: 1, zoneId: "zone_a" }) ],
|
||||
quests: [ { id: "required_quest", status: "in_progress" } ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
zones: [ { id: "quest_locked_zone", status: "locked", unlockBossId: "boss_for_quest_zone", unlockQuestId: "required_quest" } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/challenge", { bossId: "boss_for_quest_zone" });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const zone = savedState.vampire?.zones.find((z) => z.id === "quest_locked_zone");
|
||||
expect(zone?.status).toBe("locked");
|
||||
});
|
||||
|
||||
it("unlocks a zone when both boss and required quest conditions are satisfied", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, id: "quest_boss", maxHp: 1, zoneId: "zone_a" }) ],
|
||||
quests: [ { id: "required_quest", status: "completed" } ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
zones: [ { id: "quest_zone", status: "locked", unlockBossId: "quest_boss", unlockQuestId: "required_quest" } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/challenge", { bossId: "quest_boss" });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const zone = savedState.vampire?.zones.find((z) => z.id === "quest_zone");
|
||||
expect(zone?.status).toBe("unlocked");
|
||||
});
|
||||
|
||||
it("skips thralls with count=0 when computing casualties on loss", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 999_999, damagePerSecond: 999_999, maxHp: 999_999 }) ],
|
||||
thralls: [
|
||||
makeStrongThrall({ combatPower: 1, count: 0, id: "dead_thrall" }),
|
||||
makeStrongThrall({ combatPower: 1, count: 100, id: "alive_thrall" }),
|
||||
],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; casualties: Array<unknown> };
|
||||
expect(body.won).toBe(false);
|
||||
expect(body.casualties).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
|
||||
thralls: [ makeStrongThrall() ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { signature: string };
|
||||
expect(body.signature).toBeDefined();
|
||||
delete process.env.ANTI_CHEAT_SECRET;
|
||||
});
|
||||
|
||||
it("applies purchased global upgrade multiplier to party DPS", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
|
||||
thralls: [ makeStrongThrall({ combatPower: 100 }) ],
|
||||
upgrades: [ { id: "global_upgrade_1", multiplier: 2, purchased: true, target: "global", unlocked: true } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("applies purchased thrall-specific upgrade multiplier to party DPS", async () => {
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ makeBoss({ currentHp: 1, maxHp: 1 }) ],
|
||||
thralls: [ makeStrongThrall({ combatPower: 100, id: "test_thrall" }) ],
|
||||
upgrades: [ { id: "thrall_upgrade_1", multiplier: 2, purchased: true, target: "thrall", thrallId: "test_thrall", unlocked: true } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/challenge", { bossId: "test_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// bone_dust_extract requires: bone_dust×3, grave_essence×2; bonus: gold_income 1.1
|
||||
const TEST_RECIPE_ID = "bone_dust_extract";
|
||||
|
||||
const makeVampireExploration = (overrides: Partial<NonNullable<GameState["vampire"]>["exploration"]> = {}): NonNullable<GameState["vampire"]>["exploration"] => ({
|
||||
areas: [],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [] as string[],
|
||||
materials: [
|
||||
{ materialId: "bone_dust", quantity: 5 },
|
||||
{ materialId: "grave_essence", quantity: 5 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeVampireState = (overrides: Partial<NonNullable<GameState["vampire"]>> = {}): NonNullable<GameState["vampire"]> => ({
|
||||
achievements: [],
|
||||
awakening: {
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [],
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
},
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: makeVampireExploration(),
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: {
|
||||
count: 1,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("vampireCraft route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { vampireCraftRouter } = await import("../../src/routes/vampireCraft.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/vampire-craft", vampireCraftRouter);
|
||||
});
|
||||
|
||||
const post = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/vampire-craft", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when recipeId is missing", async () => {
|
||||
const res = await post({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown recipe", async () => {
|
||||
const res = await post({ recipeId: "nonexistent_recipe" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when vampire state is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when recipe is already crafted", async () => {
|
||||
const vampire = makeVampireState({
|
||||
exploration: makeVampireExploration({ craftedRecipeIds: [TEST_RECIPE_ID] }),
|
||||
});
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when first required material is insufficient", async () => {
|
||||
const vampire = makeVampireState({
|
||||
exploration: makeVampireExploration({
|
||||
materials: [
|
||||
{ materialId: "bone_dust", quantity: 1 }, // needs 3
|
||||
{ materialId: "grave_essence", quantity: 5 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when second required material is completely absent", async () => {
|
||||
// bone_dust present with enough, but grave_essence entirely absent — quantity ?? 0 = 0
|
||||
const vampire = makeVampireState({
|
||||
exploration: makeVampireExploration({
|
||||
materials: [{ materialId: "bone_dust", quantity: 5 }],
|
||||
}),
|
||||
});
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with crafted result on success", async () => {
|
||||
const vampire = makeVampireState();
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
recipeId: string;
|
||||
bonusType: string;
|
||||
bonusValue: number;
|
||||
craftedBloodMultiplier: number;
|
||||
craftedCombatMultiplier: number;
|
||||
craftedIchorMultiplier: number;
|
||||
materials: Array<{ materialId: string; quantity: number }>;
|
||||
};
|
||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||
expect(body.bonusType).toBe("gold_income");
|
||||
expect(body.bonusValue).toBe(1.1);
|
||||
expect(body.craftedBloodMultiplier).toBeGreaterThan(1);
|
||||
expect(body.craftedCombatMultiplier).toBe(1);
|
||||
expect(body.craftedIchorMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("deducts required materials from the vampire exploration state on success", async () => {
|
||||
const vampire = makeVampireState();
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post({ recipeId: TEST_RECIPE_ID });
|
||||
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
|
||||
data: { state: GameState };
|
||||
};
|
||||
const updatedMaterials = updateArg.data.state.vampire?.exploration.materials ?? [];
|
||||
const boneDust = updatedMaterials.find((m) => m.materialId === "bone_dust");
|
||||
const graveEssence = updatedMaterials.find((m) => m.materialId === "grave_essence");
|
||||
// started with 5 each; bone_dust costs 3, grave_essence costs 2
|
||||
expect(boneDust?.quantity).toBe(2);
|
||||
expect(graveEssence?.quantity).toBe(3);
|
||||
});
|
||||
|
||||
it("adds the recipeId to craftedRecipeIds in the saved state", async () => {
|
||||
const vampire = makeVampireState();
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post({ recipeId: TEST_RECIPE_ID });
|
||||
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
|
||||
data: { state: GameState };
|
||||
};
|
||||
expect(updateArg.data.state.vampire?.exploration.craftedRecipeIds).toContain(TEST_RECIPE_ID);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,648 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
// First area from defaultVampireExplorationAreas
|
||||
const AREA_ID = "bone_chapel";
|
||||
const AREA_ZONE_ID = "vampire_haunted_catacombs";
|
||||
const AREA_DURATION_SECONDS = 30;
|
||||
|
||||
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
ichorCombatMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeAwakening = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
|
||||
achievements: [] as Array<{ id: string; unlockedAt: number | null; reward: unknown }>,
|
||||
awakening: makeAwakening(),
|
||||
baseClickPower: 1,
|
||||
bosses: [] as Array<{ id: string; status: string; zoneId: string; maxHp: number; currentHp: number; damagePerSecond: number; siringRequirement: number; bloodReward: number; ichorReward: number; soulShardsReward: number; upgradeRewards: Array<string>; equipmentRewards: Array<string>; bountyIchorClaimed: boolean }>,
|
||||
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean; type: string; bonus: Record<string, unknown> }>,
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: {
|
||||
areas: [] as Array<{ id: string; status: string; startedAt?: number; endsAt?: number; completedOnce?: boolean }>,
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [] as Array<string>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
},
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [] as Array<{ id: string; status: string; zoneId?: string; unlockQuestId?: string | null }>,
|
||||
siring: makeSiring(),
|
||||
thralls: [] as Array<{ id: string; count: number; combatPower: number; level: number; unlocked: boolean; bloodPerSecond: number; ichorPerSecond: number; baseCost: number; class: string; name: string }>,
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number; thrallId?: string; unlocked?: boolean }>,
|
||||
zones: [] as Array<{ id: string; status: string; unlockBossId?: string; unlockQuestId?: string | null }>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
// A vampire state with the zone unlocked and the area available
|
||||
const makeReadyVampireState = (areaOverrides: Record<string, unknown> = {}, vampireOverrides: Record<string, unknown> = {}) =>
|
||||
makeVampireState({
|
||||
exploration: {
|
||||
areas: [ { id: AREA_ID, status: "available", ...areaOverrides } ],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ],
|
||||
...vampireOverrides,
|
||||
});
|
||||
|
||||
describe("vampireExplore route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
const { vampireExploreRouter } = await import("../../src/routes/vampireExplore.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/vampire-explore", vampireExploreRouter);
|
||||
});
|
||||
|
||||
const get = (path: string) =>
|
||||
app.fetch(new Request(`http://localhost/vampire-explore${path}`, { method: "GET" }));
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/vampire-explore${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GET /claimable
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /claimable", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await get("/claimable");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("areaId is required");
|
||||
});
|
||||
|
||||
it("returns 404 when areaId is unknown", async () => {
|
||||
const res = await get("/claimable?areaId=not_a_real_area");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Unknown exploration area");
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await get(`/claimable?areaId=${AREA_ID}`);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("No save found");
|
||||
});
|
||||
|
||||
it("returns claimable: false when vampire realm not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await get(`/claimable?areaId=${AREA_ID}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable: false when area not found in state", async () => {
|
||||
const vampire = makeVampireState({ exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] } });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await get(`/claimable?areaId=${AREA_ID}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable: false when area is not in_progress", async () => {
|
||||
const vampire = makeReadyVampireState({ status: "available" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await get(`/claimable?areaId=${AREA_ID}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable: false when exploration is still in progress (not yet expired)", async () => {
|
||||
const futureStart = Date.now() + 999_999;
|
||||
const vampire = makeReadyVampireState({ startedAt: futureStart, status: "in_progress" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await get(`/claimable?areaId=${AREA_ID}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable: true when exploration duration has elapsed", async () => {
|
||||
const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000;
|
||||
const vampire = makeReadyVampireState({ startedAt: pastStart, status: "in_progress" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await get(`/claimable?areaId=${AREA_ID}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 500 when DB throws during claimable check", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB failure"));
|
||||
const res = await get(`/claimable?areaId=${AREA_ID}`);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 when claimable check throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error");
|
||||
const res = await get(`/claimable?areaId=${AREA_ID}`);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// POST /start
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /start", () => {
|
||||
it("returns 400 when areaId is missing from body", async () => {
|
||||
const res = await post("/start", {});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("areaId is required");
|
||||
});
|
||||
|
||||
it("returns 404 when areaId is unknown", async () => {
|
||||
const res = await post("/start", { areaId: "not_a_real_area" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Unknown exploration area");
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("No save found");
|
||||
});
|
||||
|
||||
it("returns 400 when vampire realm is not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Vampire realm");
|
||||
});
|
||||
|
||||
it("returns 400 when zone is not unlocked", async () => {
|
||||
const vampire = makeVampireState({
|
||||
exploration: {
|
||||
areas: [ { id: AREA_ID, status: "available" } ],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
zones: [ { id: AREA_ZONE_ID, status: "locked" } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Zone is not unlocked");
|
||||
});
|
||||
|
||||
it("returns 400 when zone is missing entirely", async () => {
|
||||
const vampire = makeVampireState({
|
||||
exploration: {
|
||||
areas: [ { id: AREA_ID, status: "available" } ],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
zones: [],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Zone is not unlocked");
|
||||
});
|
||||
|
||||
it("returns 404 when area not found in state", async () => {
|
||||
const vampire = makeVampireState({
|
||||
exploration: {
|
||||
areas: [],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Exploration area not found in state");
|
||||
});
|
||||
|
||||
it("returns 400 when an exploration is already in progress", async () => {
|
||||
const vampire = makeVampireState({
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: AREA_ID, startedAt: Date.now(), status: "in_progress" },
|
||||
{ id: "dusty_crypts", status: "available" },
|
||||
],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
// Try to start the second area while first is in_progress
|
||||
const res = await post("/start", { areaId: "dusty_crypts" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("already in progress");
|
||||
});
|
||||
|
||||
it("returns 400 when area is locked", async () => {
|
||||
const vampire = makeReadyVampireState({ status: "locked" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("locked");
|
||||
});
|
||||
|
||||
it("returns 200 with areaId and endsAt on success", async () => {
|
||||
const vampire = makeReadyVampireState();
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { areaId: string; endsAt: number };
|
||||
expect(body.areaId).toBe(AREA_ID);
|
||||
expect(body.endsAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("sets area status to in_progress in saved state", async () => {
|
||||
const vampire = makeReadyVampireState();
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/start", { areaId: AREA_ID });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const area = savedState.vampire?.exploration.areas.find((a) => a.id === AREA_ID);
|
||||
expect(area?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("returns 500 on DB error during start", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB failure"));
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 when start throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("string error");
|
||||
const res = await post("/start", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// POST /collect
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /collect", () => {
|
||||
it("returns 400 when areaId is missing from body", async () => {
|
||||
const res = await post("/collect", {});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("areaId is required");
|
||||
});
|
||||
|
||||
it("returns 404 when areaId is unknown", async () => {
|
||||
const res = await post("/collect", { areaId: "not_a_real_area" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Unknown exploration area");
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("No save found");
|
||||
});
|
||||
|
||||
it("returns 400 when vampire realm is not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Vampire realm");
|
||||
});
|
||||
|
||||
it("returns 404 when area not found in state", async () => {
|
||||
const vampire = makeVampireState({
|
||||
exploration: {
|
||||
areas: [],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Exploration area not found");
|
||||
});
|
||||
|
||||
it("returns 400 when area is not in_progress", async () => {
|
||||
const vampire = makeReadyVampireState({ status: "available" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("not in progress");
|
||||
});
|
||||
|
||||
it("returns 400 when exploration is not yet complete", async () => {
|
||||
const futureStart = Date.now() + 999_999;
|
||||
const vampire = makeReadyVampireState({ startedAt: futureStart, status: "in_progress" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("not yet complete");
|
||||
});
|
||||
|
||||
it("returns foundNothing: true when random roll is below nothing probability", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.15);
|
||||
const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000;
|
||||
const vampire = makeReadyVampireState({ startedAt: pastStart, status: "in_progress" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; nothingMessage: string; event: null };
|
||||
expect(body.foundNothing).toBe(true);
|
||||
expect(body.event).toBeNull();
|
||||
expect(body.nothingMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns foundNothing: false with event when random roll is above nothing probability", async () => {
|
||||
// 0.5 is above the 0.2 nothing threshold, so an event fires
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||||
const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000;
|
||||
const vampire = makeReadyVampireState({ startedAt: pastStart, status: "in_progress" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; event: unknown; materialsFound: Array<unknown> };
|
||||
expect(body.foundNothing).toBe(false);
|
||||
expect(body.event).not.toBeNull();
|
||||
expect(Array.isArray(body.materialsFound)).toBe(true);
|
||||
});
|
||||
|
||||
it("sets area status back to available after collecting", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||||
const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000;
|
||||
const vampire = makeReadyVampireState({ startedAt: pastStart, status: "in_progress" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
await post("/collect", { areaId: AREA_ID });
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const area = savedState.vampire?.exploration.areas.find((a) => a.id === AREA_ID);
|
||||
expect(area?.status).toBe("available");
|
||||
expect(area?.completedOnce).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 500 on DB error during collect", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB failure"));
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 on non-Error throw", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("unexpected string");
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("handles blood_gain event and updates totalBloodEarned", async () => {
|
||||
// bone_chapel event[0] is blood_gain — use mockReturnValueOnce to steer the random rolls
|
||||
// Call 1 (nothing check): 0.5 → not nothing; Call 2 (event index): 0.1 → index 0 (blood_gain)
|
||||
vi.spyOn(Math, "random").
|
||||
mockReturnValueOnce(0.5).
|
||||
mockReturnValueOnce(0.1).
|
||||
mockReturnValue(0);
|
||||
const pastStart = Date.now() - (AREA_DURATION_SECONDS * 1000) - 1000;
|
||||
const vampire = makeReadyVampireState({ endsAt: pastStart + (AREA_DURATION_SECONDS * 1000), startedAt: pastStart, status: "in_progress" });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/collect", { areaId: AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { bloodChange: number } };
|
||||
expect(body.event?.bloodChange).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles blood_loss event and reduces blood", async () => {
|
||||
// dusty_crypts event[1] is blood_loss — Math.random=0.7 → event index 1 (Math.floor(0.7*2)=1)
|
||||
const DUSTY_AREA_ID = "dusty_crypts";
|
||||
const DUSTY_DURATION = 60;
|
||||
const pastStart = Date.now() - (DUSTY_DURATION * 1000) - 1000;
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.7);
|
||||
const vampire = makeVampireState({
|
||||
exploration: {
|
||||
areas: [ { id: DUSTY_AREA_ID, endsAt: pastStart + (DUSTY_DURATION * 1000), startedAt: pastStart, status: "in_progress" } ],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ],
|
||||
});
|
||||
const state = makeState({ resources: { blood: 1000, crystals: 0, essence: 0, gold: 0, runestones: 0 }, vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/collect", { areaId: DUSTY_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { bloodChange: number } };
|
||||
expect(body.event?.bloodChange).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("handles dark_material_gain event and adds new material to state", async () => {
|
||||
// ossuary_hall has a dark_material_gain event as event[0] (grave_essence)
|
||||
const OSSUARY_AREA_ID = "ossuary_hall";
|
||||
const OSSUARY_DURATION = 90;
|
||||
const pastStart = Date.now() - (OSSUARY_DURATION * 1000) - 1000;
|
||||
// Math.random = 0.3: not nothing (0.3 > 0.2), eventIndex=0 (dark_material_gain), material roll picks grave_essence
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.3);
|
||||
const vampire = makeVampireState({
|
||||
exploration: {
|
||||
areas: [ { id: OSSUARY_AREA_ID, status: "in_progress", startedAt: pastStart, endsAt: pastStart + (OSSUARY_DURATION * 1000) } ],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [],
|
||||
},
|
||||
zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/collect", { areaId: OSSUARY_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; event: { text: string } };
|
||||
expect(body.foundNothing).toBe(false);
|
||||
expect(body.event).not.toBeNull();
|
||||
});
|
||||
|
||||
it("increments existing material quantity for dark_material_gain and possibleMaterials drop", async () => {
|
||||
// ossuary_hall area with grave_essence already in materials
|
||||
const OSSUARY_AREA_ID = "ossuary_hall";
|
||||
const OSSUARY_DURATION = 90;
|
||||
const pastStart = Date.now() - (OSSUARY_DURATION * 1000) - 1000;
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.3);
|
||||
const vampire = makeVampireState({
|
||||
exploration: {
|
||||
areas: [ { id: OSSUARY_AREA_ID, status: "in_progress", startedAt: pastStart, endsAt: pastStart + (OSSUARY_DURATION * 1000) } ],
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [],
|
||||
materials: [ { materialId: "grave_essence", quantity: 5 } ],
|
||||
},
|
||||
zones: [ { id: AREA_ZONE_ID, status: "unlocked" } ],
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/collect", { areaId: OSSUARY_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = vi.mocked(prisma.gameState.update).mock.calls[0];
|
||||
const savedState = (updateCall?.[0] as { data: { state: GameState } }).data.state;
|
||||
const graveEssence = savedState.vampire?.exploration.materials.find((m) => m.materialId === "grave_essence");
|
||||
expect(graveEssence?.quantity).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/middleware/auth.js", () => ({
|
||||
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
||||
c.set("discordId", "test_discord_id");
|
||||
await next();
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/services/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
// blood_hunt_1: costBlood=50, costIchor=0, costSoulShards=0, unlocked=true
|
||||
const UPGRADE_ID = "blood_hunt_1";
|
||||
const COST_BLOOD = 50;
|
||||
const COST_ICHOR = 0;
|
||||
const COST_SOUL_SHARDS = 0;
|
||||
|
||||
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeAwakening = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
|
||||
achievements: [] as Array<{ id: string; unlockedAt: number | null; reward: unknown }>,
|
||||
awakening: makeAwakening(),
|
||||
baseClickPower: 1,
|
||||
bosses: [] as Array<{ id: string; status: string }>,
|
||||
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean }>,
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: {
|
||||
areas: [] as Array<{ id: string; status: string }>,
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [] as Array<string>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
},
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [] as Array<{ id: string; status: string }>,
|
||||
siring: makeSiring(),
|
||||
thralls: [] as Array<{ id: string; count: number }>,
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [] as Array<{ id: string; unlocked: boolean; purchased: boolean; target: string; multiplier: number; thrallId?: string }>,
|
||||
zones: [] as Array<{ id: string; status: string }>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("vampireUpgrade route", () => {
|
||||
let app: Hono;
|
||||
let prisma: {
|
||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { vampireUpgradeRouter } = await import("../../src/routes/vampireUpgrade.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/vampire-upgrade", vampireUpgradeRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/vampire-upgrade${path}`, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /buy", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy", {});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("upgradeId is required");
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown upgradeId", async () => {
|
||||
const res = await post("/buy", { upgradeId: "nonexistent_vampire_upgrade" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Unknown vampire upgrade");
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("No save found");
|
||||
});
|
||||
|
||||
it("returns 400 when the vampire realm is not unlocked", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Vampire realm not unlocked");
|
||||
});
|
||||
|
||||
it("returns 404 when the upgrade is not in vampire state upgrades", async () => {
|
||||
const vampire = makeVampireState({ upgrades: [] });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Upgrade not found in vampire state");
|
||||
});
|
||||
|
||||
it("returns 400 when the upgrade is not yet unlocked", async () => {
|
||||
const upgrades = [ { id: UPGRADE_ID, unlocked: false, purchased: false, target: "blood", multiplier: 1.25 } ];
|
||||
const vampire = makeVampireState({ upgrades });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Upgrade is not yet unlocked");
|
||||
});
|
||||
|
||||
it("returns 400 when the upgrade is already purchased", async () => {
|
||||
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: true, target: "blood", multiplier: 1.25 } ];
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
|
||||
siring: makeSiring({ ichor: COST_ICHOR }),
|
||||
upgrades,
|
||||
});
|
||||
const state = makeState({
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: COST_BLOOD },
|
||||
vampire: vampire as GameState["vampire"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Upgrade already purchased");
|
||||
});
|
||||
|
||||
it("returns 400 when not enough blood", async () => {
|
||||
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: false, target: "blood", multiplier: 1.25 } ];
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
|
||||
siring: makeSiring({ ichor: COST_ICHOR }),
|
||||
upgrades,
|
||||
});
|
||||
const state = makeState({
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: COST_BLOOD - 1 },
|
||||
vampire: vampire as GameState["vampire"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Not enough blood");
|
||||
});
|
||||
|
||||
it("returns 400 when not enough ichor (upgrade with ichor cost)", async () => {
|
||||
// blood_hunt_3: costBlood=1000, costIchor=1, costSoulShards=0
|
||||
const upgrades = [ { id: "blood_hunt_3", unlocked: true, purchased: false, target: "blood", multiplier: 2 } ];
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: 0 }),
|
||||
siring: makeSiring({ ichor: 0 }),
|
||||
upgrades,
|
||||
});
|
||||
const state = makeState({
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: 1000 },
|
||||
vampire: vampire as GameState["vampire"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy", { upgradeId: "blood_hunt_3" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Not enough ichor");
|
||||
});
|
||||
|
||||
it("returns 400 when not enough soul shards (upgrade with soul shard cost)", async () => {
|
||||
// blood_mastery_3: costBlood=2_500_000, costIchor=50, costSoulShards=1
|
||||
const upgrades = [ { id: "blood_mastery_3", unlocked: true, purchased: false, target: "blood", multiplier: 5 } ];
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: 0 }),
|
||||
siring: makeSiring({ ichor: 50 }),
|
||||
upgrades,
|
||||
});
|
||||
const state = makeState({
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: 2_500_000 },
|
||||
vampire: vampire as GameState["vampire"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy", { upgradeId: "blood_mastery_3" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Not enough soul shards");
|
||||
});
|
||||
|
||||
it("returns 200 with deducted resources on successful purchase", async () => {
|
||||
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: false, target: "blood", multiplier: 1.25 } ];
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
|
||||
siring: makeSiring({ ichor: COST_ICHOR }),
|
||||
upgrades,
|
||||
});
|
||||
const state = makeState({
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: COST_BLOOD + 10 },
|
||||
vampire: vampire as GameState["vampire"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { bloodRemaining: number; ichorRemaining: number; soulShardsRemaining: number };
|
||||
expect(body.bloodRemaining).toBe(10);
|
||||
expect(body.ichorRemaining).toBe(COST_ICHOR);
|
||||
expect(body.soulShardsRemaining).toBe(COST_SOUL_SHARDS);
|
||||
});
|
||||
|
||||
it("returns 200 and marks the upgrade as purchased in the saved state", async () => {
|
||||
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: false, target: "blood", multiplier: 1.25 } ];
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
|
||||
siring: makeSiring({ ichor: COST_ICHOR }),
|
||||
upgrades,
|
||||
});
|
||||
const state = makeState({
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, blood: COST_BLOOD },
|
||||
vampire: vampire as GameState["vampire"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.gameState.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { discordId: DISCORD_ID },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Internal server error");
|
||||
});
|
||||
|
||||
it("treats missing blood as zero when resources.blood is undefined", async () => {
|
||||
const upgrades = [ { id: UPGRADE_ID, unlocked: true, purchased: false, target: "blood", multiplier: 1.25 } ];
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShards: COST_SOUL_SHARDS }),
|
||||
siring: makeSiring({ ichor: COST_ICHOR }),
|
||||
upgrades,
|
||||
});
|
||||
const state = makeState({
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
vampire: vampire as GameState["vampire"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy", { upgradeId: UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Not enough blood");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,4 +112,40 @@ describe("buildPostApotheosisState", () => {
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.apotheosis?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("initialises goddess state on first apotheosis (count goes to 1)", () => {
|
||||
const state = makeMinimalState();
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.goddess).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves existing goddess state on second apotheosis (count goes to 2)", () => {
|
||||
const goddessState: GameState["goddess"] = {
|
||||
achievements: [],
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
disciples: [],
|
||||
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
|
||||
equipment: [],
|
||||
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
totalPrayersEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeMinimalState({ apotheosis: { count: 1 }, goddess: goddessState });
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.goddess).toEqual(goddessState);
|
||||
});
|
||||
|
||||
it("does not add goddess when count goes to 2 but no goddess exists on current state", () => {
|
||||
const state = makeMinimalState({ apotheosis: { count: 1 } });
|
||||
const { updatedState } = buildPostApotheosisState(state, "T");
|
||||
expect(updatedState.goddess).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
||||
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPostAwakeningState,
|
||||
calculateSoulShardsYield,
|
||||
computeAwakeningMultipliers,
|
||||
isEligibleForAwakening,
|
||||
} from "../../src/services/awakening.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const makeAwakening = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
soulShards: 0,
|
||||
soulShardsBloodMultiplier: 1,
|
||||
soulShardsCombatMultiplier: 1,
|
||||
soulShardsMetaMultiplier: 1,
|
||||
soulShardsSiringIchorMultiplier: 1,
|
||||
soulShardsSiringThresholdMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSiring = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
ichor: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as Array<string>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeVampireState = (overrides: Record<string, unknown> = {}) => ({
|
||||
achievements: [] as Array<{ id: string; unlockedAt: number | null }>,
|
||||
awakening: makeAwakening(),
|
||||
baseClickPower: 1,
|
||||
bosses: [] as Array<{ id: string; status: string; bountyIchorClaimed?: boolean }>,
|
||||
equipment: [] as Array<{ id: string; owned: boolean; equipped: boolean }>,
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: {
|
||||
areas: [] as Array<{ id: string; status: string }>,
|
||||
craftedBloodMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
craftedIchorMultiplier: 1,
|
||||
craftedRecipeIds: [] as Array<string>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
},
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [] as Array<{ id: string; status: string }>,
|
||||
siring: makeSiring(),
|
||||
thralls: [] as Array<{ id: string; count: number }>,
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean }>,
|
||||
zones: [] as Array<{ id: string; status: string }>,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: "test_id", username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("isEligibleForAwakening", () => {
|
||||
it("returns false when vampire state is undefined", () => {
|
||||
const state = makeState();
|
||||
expect(isEligibleForAwakening(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when bosses array is empty", () => {
|
||||
const state = makeState({ vampire: makeVampireState() as GameState["vampire"] });
|
||||
expect(isEligibleForAwakening(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when eternal_darkness boss is present but not defeated", () => {
|
||||
const state = makeState({
|
||||
vampire: makeVampireState({
|
||||
bosses: [ { id: "eternal_darkness", status: "available" } ],
|
||||
}) as GameState["vampire"],
|
||||
});
|
||||
expect(isEligibleForAwakening(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when eternal_darkness boss is in_progress", () => {
|
||||
const state = makeState({
|
||||
vampire: makeVampireState({
|
||||
bosses: [ { id: "eternal_darkness", status: "in_progress" } ],
|
||||
}) as GameState["vampire"],
|
||||
});
|
||||
expect(isEligibleForAwakening(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when eternal_darkness boss is defeated", () => {
|
||||
const state = makeState({
|
||||
vampire: makeVampireState({
|
||||
bosses: [ { id: "eternal_darkness", status: "defeated" } ],
|
||||
}) as GameState["vampire"],
|
||||
});
|
||||
expect(isEligibleForAwakening(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true even with other bosses in the array", () => {
|
||||
const state = makeState({
|
||||
vampire: makeVampireState({
|
||||
bosses: [
|
||||
{ id: "some_other_boss", status: "defeated" },
|
||||
{ id: "eternal_darkness", status: "defeated" },
|
||||
],
|
||||
}) as GameState["vampire"],
|
||||
});
|
||||
expect(isEligibleForAwakening(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when only other bosses are defeated (not eternal_darkness)", () => {
|
||||
const state = makeState({
|
||||
vampire: makeVampireState({
|
||||
bosses: [ { id: "some_other_boss", status: "defeated" } ],
|
||||
}) as GameState["vampire"],
|
||||
});
|
||||
expect(isEligibleForAwakening(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateSoulShardsYield", () => {
|
||||
it("returns 1 as minimum yield when siring count is 0", () => {
|
||||
expect(calculateSoulShardsYield(0, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1 as minimum yield when result would be below 1", () => {
|
||||
// sqrt(0) * 100 = 0 → max(1, 0) = 1
|
||||
expect(calculateSoulShardsYield(0, 100)).toBe(1);
|
||||
});
|
||||
|
||||
it("computes floor(sqrt(4) * 1) = 2", () => {
|
||||
expect(calculateSoulShardsYield(4, 1)).toBe(2);
|
||||
});
|
||||
|
||||
it("computes floor(sqrt(9) * 1) = 3", () => {
|
||||
expect(calculateSoulShardsYield(9, 1)).toBe(3);
|
||||
});
|
||||
|
||||
it("applies meta multiplier correctly", () => {
|
||||
// floor(sqrt(4) * 2) = floor(2 * 2) = 4
|
||||
expect(calculateSoulShardsYield(4, 2)).toBe(4);
|
||||
});
|
||||
|
||||
it("floors fractional results", () => {
|
||||
// floor(sqrt(2) * 1) = floor(1.414...) = 1
|
||||
expect(calculateSoulShardsYield(2, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it("floors fractional results with multiplier", () => {
|
||||
// floor(sqrt(9) * 1.5) = floor(3 * 1.5) = floor(4.5) = 4
|
||||
expect(calculateSoulShardsYield(9, 1.5)).toBe(4);
|
||||
});
|
||||
|
||||
it("returns at least 1 even with very small siring count and no multiplier", () => {
|
||||
expect(calculateSoulShardsYield(1, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAwakeningMultipliers", () => {
|
||||
it("returns all 1s with empty purchasedUpgradeIds", () => {
|
||||
const result = computeAwakeningMultipliers([]);
|
||||
expect(result.soulShardsBloodMultiplier).toBe(1);
|
||||
expect(result.soulShardsCombatMultiplier).toBe(1);
|
||||
expect(result.soulShardsMetaMultiplier).toBe(1);
|
||||
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
|
||||
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("applies blood upgrade when purchased", () => {
|
||||
// awakening_blood_1 has multiplier 1.5 in blood category
|
||||
const result = computeAwakeningMultipliers([ "awakening_blood_1" ]);
|
||||
expect(result.soulShardsBloodMultiplier).toBe(1.5);
|
||||
expect(result.soulShardsCombatMultiplier).toBe(1);
|
||||
expect(result.soulShardsMetaMultiplier).toBe(1);
|
||||
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
|
||||
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("stacks multiple blood upgrades multiplicatively", () => {
|
||||
// awakening_blood_1 (×1.5) × awakening_blood_2 (×2) = 3.0
|
||||
const result = computeAwakeningMultipliers([ "awakening_blood_1", "awakening_blood_2" ]);
|
||||
expect(result.soulShardsBloodMultiplier).toBe(3);
|
||||
});
|
||||
|
||||
it("applies combat upgrade when purchased", () => {
|
||||
// awakening_combat_1 has multiplier 1.5 in combat category
|
||||
const result = computeAwakeningMultipliers([ "awakening_combat_1" ]);
|
||||
expect(result.soulShardsCombatMultiplier).toBe(1.5);
|
||||
expect(result.soulShardsBloodMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("stacks multiple combat upgrades multiplicatively", () => {
|
||||
// awakening_combat_1 (×1.5) × awakening_combat_2 (×2) = 3.0
|
||||
const result = computeAwakeningMultipliers([ "awakening_combat_1", "awakening_combat_2" ]);
|
||||
expect(result.soulShardsCombatMultiplier).toBe(3);
|
||||
});
|
||||
|
||||
it("applies siring threshold upgrade when purchased", () => {
|
||||
// awakening_threshold_1 has multiplier 0.85 in siring_threshold category
|
||||
const result = computeAwakeningMultipliers([ "awakening_threshold_1" ]);
|
||||
expect(result.soulShardsSiringThresholdMultiplier).toBe(0.85);
|
||||
});
|
||||
|
||||
it("stacks multiple threshold upgrades multiplicatively", () => {
|
||||
// awakening_threshold_1 (×0.85) × awakening_threshold_2 (×0.8) = 0.68
|
||||
const result = computeAwakeningMultipliers([ "awakening_threshold_1", "awakening_threshold_2" ]);
|
||||
expect(result.soulShardsSiringThresholdMultiplier).toBeCloseTo(0.68);
|
||||
});
|
||||
|
||||
it("applies siring ichor upgrade when purchased", () => {
|
||||
// awakening_siring_ichor_1 has multiplier 1.5 in siring_ichor category
|
||||
const result = computeAwakeningMultipliers([ "awakening_siring_ichor_1" ]);
|
||||
expect(result.soulShardsSiringIchorMultiplier).toBe(1.5);
|
||||
});
|
||||
|
||||
it("stacks multiple siring ichor upgrades multiplicatively", () => {
|
||||
// awakening_siring_ichor_1 (×1.5) × awakening_siring_ichor_2 (×2) = 3.0
|
||||
const result = computeAwakeningMultipliers([ "awakening_siring_ichor_1", "awakening_siring_ichor_2" ]);
|
||||
expect(result.soulShardsSiringIchorMultiplier).toBe(3);
|
||||
});
|
||||
|
||||
it("applies meta upgrade when purchased", () => {
|
||||
// awakening_meta_1 has multiplier 1.5 in soulshards_meta category
|
||||
const result = computeAwakeningMultipliers([ "awakening_meta_1" ]);
|
||||
expect(result.soulShardsMetaMultiplier).toBe(1.5);
|
||||
});
|
||||
|
||||
it("stacks multiple meta upgrades multiplicatively", () => {
|
||||
// awakening_meta_1 (×1.5) × awakening_meta_2 (×2) = 3.0
|
||||
const result = computeAwakeningMultipliers([ "awakening_meta_1", "awakening_meta_2" ]);
|
||||
expect(result.soulShardsMetaMultiplier).toBe(3);
|
||||
});
|
||||
|
||||
it("applies upgrades from multiple categories independently", () => {
|
||||
const result = computeAwakeningMultipliers([
|
||||
"awakening_blood_1",
|
||||
"awakening_combat_1",
|
||||
"awakening_meta_1",
|
||||
]);
|
||||
expect(result.soulShardsBloodMultiplier).toBe(1.5);
|
||||
expect(result.soulShardsCombatMultiplier).toBe(1.5);
|
||||
expect(result.soulShardsMetaMultiplier).toBe(1.5);
|
||||
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
|
||||
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores unknown upgrade ids gracefully", () => {
|
||||
const result = computeAwakeningMultipliers([ "totally_fake_upgrade_id" ]);
|
||||
expect(result.soulShardsBloodMultiplier).toBe(1);
|
||||
expect(result.soulShardsCombatMultiplier).toBe(1);
|
||||
expect(result.soulShardsMetaMultiplier).toBe(1);
|
||||
expect(result.soulShardsSiringIchorMultiplier).toBe(1);
|
||||
expect(result.soulShardsSiringThresholdMultiplier).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPostAwakeningState", () => {
|
||||
it("increments awakening count by 1", () => {
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ count: 2, soulShards: 5 }),
|
||||
siring: makeSiring({ count: 4 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.awakening.count).toBe(3);
|
||||
});
|
||||
|
||||
it("adds soulShardsEarned to existing soul shards", () => {
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ count: 0, soulShards: 10 }),
|
||||
siring: makeSiring({ count: 4 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { soulShardsEarned, updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(soulShardsEarned).toBeGreaterThanOrEqual(1);
|
||||
expect(updatedVampire.awakening.soulShards).toBe(10 + soulShardsEarned);
|
||||
});
|
||||
|
||||
it("uses metaMultiplier from awakening when computing soul shards yield", () => {
|
||||
// metaMultiplier = 1.5, siring count = 4 → floor(sqrt(4) * 1.5) = floor(3) = 3
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ soulShardsMetaMultiplier: 1.5 }),
|
||||
siring: makeSiring({ count: 4 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { soulShardsEarned } = buildPostAwakeningState(state);
|
||||
expect(soulShardsEarned).toBe(3);
|
||||
});
|
||||
|
||||
it("preserves purchased upgrade ids across awakening", () => {
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ purchasedUpgradeIds: [ "awakening_blood_1" ] }),
|
||||
siring: makeSiring({ count: 4 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.awakening.purchasedUpgradeIds).toContain("awakening_blood_1");
|
||||
});
|
||||
|
||||
it("recomputes multipliers based on existing purchased upgrade ids", () => {
|
||||
const vampire = makeVampireState({
|
||||
awakening: makeAwakening({ purchasedUpgradeIds: [ "awakening_blood_1" ] }),
|
||||
siring: makeSiring({ count: 4 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
// awakening_blood_1 has multiplier 1.5
|
||||
expect(updatedVampire.awakening.soulShardsBloodMultiplier).toBe(1.5);
|
||||
});
|
||||
|
||||
it("preserves achievements across awakening", () => {
|
||||
const achievements = [ { id: "ach_1", unlockedAt: 1000 } ];
|
||||
const vampire = makeVampireState({ achievements, siring: makeSiring({ count: 1 }) });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.achievements).toEqual(achievements);
|
||||
});
|
||||
|
||||
it("preserves equipment across awakening", () => {
|
||||
const equipment = [ { id: "eq_1", owned: true, equipped: true } ];
|
||||
const vampire = makeVampireState({ equipment, siring: makeSiring({ count: 1 }) });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.equipment).toEqual(equipment);
|
||||
});
|
||||
|
||||
it("preserves eternalSovereignty count across awakening", () => {
|
||||
const vampire = makeVampireState({
|
||||
eternalSovereignty: { count: 5 },
|
||||
siring: makeSiring({ count: 1 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.eternalSovereignty.count).toBe(5);
|
||||
});
|
||||
|
||||
it("preserves lifetime blood earned across awakening", () => {
|
||||
const vampire = makeVampireState({
|
||||
lifetimeBloodEarned: 9_999_999,
|
||||
siring: makeSiring({ count: 1 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.lifetimeBloodEarned).toBe(9_999_999);
|
||||
});
|
||||
|
||||
it("preserves lifetime bosses defeated across awakening", () => {
|
||||
const vampire = makeVampireState({
|
||||
lifetimeBossesDefeated: 42,
|
||||
siring: makeSiring({ count: 1 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.lifetimeBossesDefeated).toBe(42);
|
||||
});
|
||||
|
||||
it("preserves lifetime quests completed across awakening", () => {
|
||||
const vampire = makeVampireState({
|
||||
lifetimeQuestsCompleted: 17,
|
||||
siring: makeSiring({ count: 1 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.lifetimeQuestsCompleted).toBe(17);
|
||||
});
|
||||
|
||||
it("resets totalBloodEarned to 0", () => {
|
||||
const vampire = makeVampireState({
|
||||
siring: makeSiring({ count: 1 }),
|
||||
totalBloodEarned: 500_000,
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.totalBloodEarned).toBe(0);
|
||||
});
|
||||
|
||||
it("resets siring count to 0 on fresh vampire state", () => {
|
||||
const vampire = makeVampireState({ siring: makeSiring({ count: 25 }) });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
expect(updatedVampire.siring.count).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves bountyIchorClaimed flag on bosses that match fresh boss list", () => {
|
||||
// Provide an existing boss with bountyIchorClaimed = true for a boss that exists in defaultVampireBosses
|
||||
// We pass it through the bosses array and check that the flag survives the merge
|
||||
const vampire = makeVampireState({
|
||||
bosses: [ { id: "eternal_darkness", status: "defeated", bountyIchorClaimed: true } ],
|
||||
siring: makeSiring({ count: 4 }),
|
||||
});
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { updatedVampire } = buildPostAwakeningState(state);
|
||||
// eternal_darkness should exist in the fresh boss list; its bountyIchorClaimed should be true
|
||||
const eternDark = updatedVampire.bosses.find((b) => {
|
||||
return b.id === "eternal_darkness";
|
||||
});
|
||||
// The boss may or may not exist in default data; if it does, the flag is preserved
|
||||
if (eternDark !== undefined) {
|
||||
expect(eternDark.bountyIchorClaimed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns minimum 1 soul shard even when siring count is 0", () => {
|
||||
const vampire = makeVampireState({ siring: makeSiring({ count: 0 }) });
|
||||
const state = makeState({ vampire: vampire as GameState["vampire"] });
|
||||
const { soulShardsEarned } = buildPostAwakeningState(state);
|
||||
expect(soulShardsEarned).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,10 @@ export default defineConfig({
|
||||
"src/db/client.ts",
|
||||
"src/index.ts",
|
||||
"src/data/materials.ts",
|
||||
// Goddess materials data file — not directly imported by any route (referenced by ID strings only)
|
||||
"src/data/goddessMaterials.ts",
|
||||
// Vampire materials data file — not directly imported by any route (referenced by ID strings only)
|
||||
"src/data/vampireMaterials.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
|
||||
@@ -4,36 +4,75 @@
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- API client grows with each new endpoint group */
|
||||
import type {
|
||||
AboutResponse,
|
||||
ApotheosisRequest,
|
||||
ApotheosisResponse,
|
||||
AuthResponse,
|
||||
AwakeningRequest,
|
||||
AwakeningResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyAwakeningUpgradeRequest,
|
||||
BuyAwakeningUpgradeResponse,
|
||||
BuyConsecrationUpgradeRequest,
|
||||
BuyConsecrationUpgradeResponse,
|
||||
BuyEchoUpgradeRequest,
|
||||
BuyEchoUpgradeResponse,
|
||||
BuyEnlightenmentUpgradeRequest,
|
||||
BuyEnlightenmentUpgradeResponse,
|
||||
BuyGoddessUpgradeRequest,
|
||||
BuyGoddessUpgradeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
BuySiringUpgradeRequest,
|
||||
BuySiringUpgradeResponse,
|
||||
BuyVampireUpgradeRequest,
|
||||
BuyVampireUpgradeResponse,
|
||||
ConsecrationRequest,
|
||||
ConsecrationResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
EnlightenmentRequest,
|
||||
EnlightenmentResponse,
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
ExploreStartRequest,
|
||||
ExploreStartResponse,
|
||||
ForceUnlocksResponse,
|
||||
GoddessBossChallengeRequest,
|
||||
GoddessBossChallengeResponse,
|
||||
GoddessCraftRequest,
|
||||
GoddessCraftResponse,
|
||||
GoddessExploreClaimableResponse,
|
||||
GoddessExploreCollectRequest,
|
||||
GoddessExploreCollectResponse,
|
||||
GoddessExploreStartRequest,
|
||||
GoddessExploreStartResponse,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SiringRequest,
|
||||
SiringResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
VampireBossChallengeRequest,
|
||||
VampireBossChallengeResponse,
|
||||
VampireCraftRequest,
|
||||
VampireCraftResponse,
|
||||
VampireExploreClaimableResponse,
|
||||
VampireExploreCollectRequest,
|
||||
VampireExploreCollectResponse,
|
||||
VampireExploreStartRequest,
|
||||
VampireExploreStartResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
const baseUrl = "/api";
|
||||
@@ -328,6 +367,280 @@ const debugHardReset = async(): Promise<LoadResponse> => {
|
||||
return await fetchJson<LoadResponse>("/debug/hard-reset", { method: "POST" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Challenges a goddess boss.
|
||||
* @param body - The goddess boss challenge request payload.
|
||||
* @returns The goddess boss challenge response data.
|
||||
*/
|
||||
const challengeGoddessBoss = async(
|
||||
body: GoddessBossChallengeRequest,
|
||||
): Promise<GoddessBossChallengeResponse> => {
|
||||
return await fetchJson<GoddessBossChallengeResponse>(
|
||||
"/goddess-boss/challenge",
|
||||
{ body: JSON.stringify(body), method: "POST" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers a consecration reset on the server.
|
||||
* @param body - The consecration request payload.
|
||||
* @returns The consecration response data.
|
||||
*/
|
||||
const consecrate = async(
|
||||
body: ConsecrationRequest,
|
||||
): Promise<ConsecrationResponse> => {
|
||||
return await fetchJson<ConsecrationResponse>("/consecration", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases a consecration upgrade on the server.
|
||||
* @param body - The buy consecration upgrade request payload.
|
||||
* @returns The buy consecration upgrade response data.
|
||||
*/
|
||||
const buyConsecrationUpgrade = async(
|
||||
body: BuyConsecrationUpgradeRequest,
|
||||
): Promise<BuyConsecrationUpgradeResponse> => {
|
||||
return await fetchJson<BuyConsecrationUpgradeResponse>(
|
||||
"/consecration/buy-upgrade",
|
||||
{ body: JSON.stringify(body), method: "POST" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers an enlightenment reset on the server.
|
||||
* @param body - The enlightenment request payload.
|
||||
* @returns The enlightenment response data.
|
||||
*/
|
||||
const enlighten = async(
|
||||
body: EnlightenmentRequest,
|
||||
): Promise<EnlightenmentResponse> => {
|
||||
return await fetchJson<EnlightenmentResponse>("/enlightenment", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases an enlightenment upgrade on the server.
|
||||
* @param body - The buy enlightenment upgrade request payload.
|
||||
* @returns The buy enlightenment upgrade response data.
|
||||
*/
|
||||
const buyEnlightenmentUpgrade = async(
|
||||
body: BuyEnlightenmentUpgradeRequest,
|
||||
): Promise<BuyEnlightenmentUpgradeResponse> => {
|
||||
return await fetchJson<BuyEnlightenmentUpgradeResponse>(
|
||||
"/enlightenment/buy-upgrade",
|
||||
{ body: JSON.stringify(body), method: "POST" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases a goddess upgrade on the server.
|
||||
* @param body - The buy goddess upgrade request payload.
|
||||
* @returns The buy goddess upgrade response data.
|
||||
*/
|
||||
const buyGoddessUpgrade = async(
|
||||
body: BuyGoddessUpgradeRequest,
|
||||
): Promise<BuyGoddessUpgradeResponse> => {
|
||||
return await fetchJson<BuyGoddessUpgradeResponse>("/goddess-upgrade/buy", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Crafts a goddess recipe on the server.
|
||||
* @param body - The goddess craft request payload.
|
||||
* @returns The goddess craft response data.
|
||||
*/
|
||||
const craftGoddessRecipe = async(
|
||||
body: GoddessCraftRequest,
|
||||
): Promise<GoddessCraftResponse> => {
|
||||
return await fetchJson<GoddessCraftResponse>("/goddess-craft", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a goddess exploration in a given area.
|
||||
* @param body - The goddess exploration start request payload.
|
||||
* @returns The goddess exploration start response data.
|
||||
*/
|
||||
const startGoddessExploration = async(
|
||||
body: GoddessExploreStartRequest,
|
||||
): Promise<GoddessExploreStartResponse> => {
|
||||
return await fetchJson<GoddessExploreStartResponse>(
|
||||
"/goddess-explore/start",
|
||||
{ body: JSON.stringify(body), method: "POST" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects the rewards from a completed goddess exploration.
|
||||
* @param body - The goddess exploration collect request payload.
|
||||
* @returns The goddess exploration collect response data.
|
||||
*/
|
||||
const collectGoddessExploration = async(
|
||||
body: GoddessExploreCollectRequest,
|
||||
): Promise<GoddessExploreCollectResponse> => {
|
||||
return await fetchJson<GoddessExploreCollectResponse>(
|
||||
"/goddess-explore/collect",
|
||||
{ body: JSON.stringify(body), method: "PUT" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether a given goddess exploration area is ready to claim on the server.
|
||||
* @param areaId - The area ID to check.
|
||||
* @returns Whether the goddess exploration is claimable.
|
||||
*/
|
||||
const checkGoddessExplorationClaimable = async(
|
||||
areaId: string,
|
||||
): Promise<GoddessExploreClaimableResponse> => {
|
||||
return await fetchJson<GoddessExploreClaimableResponse>(
|
||||
`/goddess-explore/claimable?areaId=${encodeURIComponent(areaId)}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Challenges a vampire boss.
|
||||
* @param body - The vampire boss challenge request payload.
|
||||
* @returns The vampire boss challenge response data.
|
||||
*/
|
||||
const challengeVampireBoss = async(
|
||||
body: VampireBossChallengeRequest,
|
||||
): Promise<VampireBossChallengeResponse> => {
|
||||
return await fetchJson<VampireBossChallengeResponse>(
|
||||
"/vampire-boss/challenge",
|
||||
{ body: JSON.stringify(body), method: "POST" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers a siring reset on the server.
|
||||
* @param body - The siring request payload.
|
||||
* @returns The siring response data.
|
||||
*/
|
||||
const sire = async(body: SiringRequest): Promise<SiringResponse> => {
|
||||
return await fetchJson<SiringResponse>("/siring", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases a siring upgrade on the server.
|
||||
* @param body - The buy siring upgrade request payload.
|
||||
* @returns The buy siring upgrade response data.
|
||||
*/
|
||||
const buySiringUpgrade = async(
|
||||
body: BuySiringUpgradeRequest,
|
||||
): Promise<BuySiringUpgradeResponse> => {
|
||||
return await fetchJson<BuySiringUpgradeResponse>("/siring/buy-upgrade", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers a vampire awakening reset on the server.
|
||||
* @param body - The awakening request payload.
|
||||
* @returns The awakening response data.
|
||||
*/
|
||||
const awaken = async(body: AwakeningRequest): Promise<AwakeningResponse> => {
|
||||
return await fetchJson<AwakeningResponse>("/vampire-awakening", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases a vampire awakening upgrade on the server.
|
||||
* @param body - The buy awakening upgrade request payload.
|
||||
* @returns The buy awakening upgrade response data.
|
||||
*/
|
||||
const buyAwakeningUpgrade = async(
|
||||
body: BuyAwakeningUpgradeRequest,
|
||||
): Promise<BuyAwakeningUpgradeResponse> => {
|
||||
return await fetchJson<BuyAwakeningUpgradeResponse>(
|
||||
"/vampire-awakening/buy-upgrade",
|
||||
{ body: JSON.stringify(body), method: "POST" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases a vampire upgrade on the server.
|
||||
* @param body - The buy vampire upgrade request payload.
|
||||
* @returns The buy vampire upgrade response data.
|
||||
*/
|
||||
const buyVampireUpgrade = async(
|
||||
body: BuyVampireUpgradeRequest,
|
||||
): Promise<BuyVampireUpgradeResponse> => {
|
||||
return await fetchJson<BuyVampireUpgradeResponse>("/vampire-upgrade/buy", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Crafts a vampire recipe on the server.
|
||||
* @param body - The vampire craft request payload.
|
||||
* @returns The vampire craft response data.
|
||||
*/
|
||||
const craftVampireRecipe = async(
|
||||
body: VampireCraftRequest,
|
||||
): Promise<VampireCraftResponse> => {
|
||||
return await fetchJson<VampireCraftResponse>("/vampire-craft", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a vampire exploration in a given area.
|
||||
* @param body - The vampire exploration start request payload.
|
||||
* @returns The vampire exploration start response data.
|
||||
*/
|
||||
const startVampireExploration = async(
|
||||
body: VampireExploreStartRequest,
|
||||
): Promise<VampireExploreStartResponse> => {
|
||||
return await fetchJson<VampireExploreStartResponse>(
|
||||
"/vampire-explore/start",
|
||||
{ body: JSON.stringify(body), method: "POST" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects the rewards from a completed vampire exploration.
|
||||
* @param body - The vampire exploration collect request payload.
|
||||
* @returns The vampire exploration collect response data.
|
||||
*/
|
||||
const collectVampireExploration = async(
|
||||
body: VampireExploreCollectRequest,
|
||||
): Promise<VampireExploreCollectResponse> => {
|
||||
return await fetchJson<VampireExploreCollectResponse>(
|
||||
"/vampire-explore/collect",
|
||||
{ body: JSON.stringify(body), method: "PUT" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether a given vampire exploration area is ready to claim on the server.
|
||||
* @param areaId - The area ID to check.
|
||||
* @returns Whether the vampire exploration is claimable.
|
||||
*/
|
||||
const checkVampireExplorationClaimable = async(
|
||||
areaId: string,
|
||||
): Promise<VampireExploreClaimableResponse> => {
|
||||
return await fetchJson<VampireExploreClaimableResponse>(
|
||||
`/vampire-explore/claimable?areaId=${encodeURIComponent(areaId)}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a public player profile by Discord ID.
|
||||
* @param discordId - The Discord ID of the player to look up.
|
||||
@@ -356,13 +669,30 @@ const updateProfile = async(
|
||||
export {
|
||||
ValidationError,
|
||||
achieveApotheosis,
|
||||
awaken,
|
||||
buyAwakeningUpgrade,
|
||||
buyConsecrationUpgrade,
|
||||
buyEchoUpgrade,
|
||||
buyEnlightenmentUpgrade,
|
||||
buyGoddessUpgrade,
|
||||
buyPrestigeUpgrade,
|
||||
buySiringUpgrade,
|
||||
buyVampireUpgrade,
|
||||
challengeBoss,
|
||||
challengeGoddessBoss,
|
||||
challengeVampireBoss,
|
||||
checkExplorationClaimable,
|
||||
checkGoddessExplorationClaimable,
|
||||
checkVampireExplorationClaimable,
|
||||
collectExploration,
|
||||
collectGoddessExploration,
|
||||
collectVampireExploration,
|
||||
consecrate,
|
||||
craftGoddessRecipe,
|
||||
craftRecipe,
|
||||
craftVampireRecipe,
|
||||
debugHardReset,
|
||||
enlighten,
|
||||
forceUnlocks,
|
||||
syncNewContent,
|
||||
getAbout,
|
||||
@@ -373,7 +703,10 @@ export {
|
||||
prestige,
|
||||
resetProgress,
|
||||
saveGame,
|
||||
sire,
|
||||
startExploration,
|
||||
startGoddessExploration,
|
||||
startVampireExploration,
|
||||
transcend,
|
||||
updateProfile,
|
||||
};
|
||||
|
||||
@@ -254,6 +254,232 @@ const howToPlay = [
|
||||
+ " entries and lifetime profile statistics are always preserved.",
|
||||
title: "✨ Apotheosis",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Your first Apotheosis unlocks the Goddess Realm — an entirely new"
|
||||
+ " layer of the game that runs alongside your mortal progress. Switch"
|
||||
+ " between Mortal and Goddess modes using the mode bar at the top of"
|
||||
+ " the screen. All Goddess tabs are always visible, but their content"
|
||||
+ " is locked until Apotheosis. Your mortal progress is fully preserved"
|
||||
+ " when switching modes — they advance in parallel.",
|
||||
title: "✨ Goddess Mode",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"The Goddess Realm uses three currencies: Prayers (earned passively"
|
||||
+ " from Disciples each tick), Divinity (earned from Disciples and"
|
||||
+ " multiplied by Consecration bonuses), and Stardust (awarded by"
|
||||
+ " Goddess Quests, Enlightenment resets, and Achievement unlocks)."
|
||||
+ " All three are always visible in the resource bar — greyed out"
|
||||
+ " before Apotheosis, fully active after.",
|
||||
title: "🙏 Divine Currencies",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"The Goddess Realm has 18 zones, each containing 4 bosses and 5"
|
||||
+ " quests. The first zone is always available after Apotheosis."
|
||||
+ " Subsequent"
|
||||
+ " zones unlock when you defeat the required Goddess Boss AND complete"
|
||||
+ " the required Goddess Quest from the preceding zone — the same"
|
||||
+ " pattern as the mortal game, but with divine stakes.",
|
||||
title: "🌟 Goddess Zones",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Challenge Goddess Bosses to earn Prayers, sacred equipment drops, and"
|
||||
+ " unlock new Goddess Zones. Each boss has a Consecration requirement"
|
||||
+ " — you must have consecrated a minimum number of times before you"
|
||||
+ " can attempt it. Bosses that have been defeated stay defeated;"
|
||||
+ " the zone simply marks them as cleared.",
|
||||
title: "⚔️ Goddess Boss Fights",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Goddess Quests run on a timer just like mortal quests, but they always"
|
||||
+ " succeed — there is no failure chance in the divine realm. Rewards"
|
||||
+ " include Prayers, Divinity, Stardust, and unlocks for Goddess"
|
||||
+ " Upgrades, Disciples, and equipment. Quests within a zone are"
|
||||
+ " unlocked in order via prerequisites; a quest becomes available once"
|
||||
+ " all its required quests are completed.",
|
||||
title: "📜 Goddess Quests",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Disciples are the Goddess Realm's equivalent of adventurers. Hire"
|
||||
+ " them with Prayers and Divinity to generate passive Prayers and"
|
||||
+ " Divinity income every tick. Disciples come in six classes — Oracle,"
|
||||
+ " Seraph, Invoker, Templar, Herald, and Warden — each with unique"
|
||||
+ " income rates and combat power. Buy in batches of 1, 10, or Max."
|
||||
+ " Disciple-specific Upgrades multiply the income of individual"
|
||||
+ " classes, and Global Upgrades stack on top.",
|
||||
title: "🧎 Disciples",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"The Goddess Realm has three equipment types: Relics 📿, Vestments 👘,"
|
||||
+ " and Sigils 🔯. Each type occupies its own slot — only one of each"
|
||||
+ " can be equipped at a time. Equipment is purchased with Prayers,"
|
||||
+ " Divinity, and Stardust, or obtained exclusively as Boss Drop rewards"
|
||||
+ " (marked 🎲 Boss Drop Only). Pieces come in Common, Rare, Epic, and"
|
||||
+ " Legendary rarities and provide bonuses to Prayers/s, Disciple"
|
||||
+ " Combat, or Divinity from Consecration.",
|
||||
title: "🔯 Goddess Equipment & Sets",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Goddess Upgrades are purchased with Prayers, Divinity, and Stardust"
|
||||
+ " and fall into five categories: Prayers (boosts all Disciple prayer"
|
||||
+ " income globally), Disciple (boosts a specific Disciple class),"
|
||||
+ " Global (multiplies all Goddess income), Consecration (amplifies"
|
||||
+ " Consecration bonuses), and Boss (increases Goddess Boss damage)."
|
||||
+ " Upgrades stack multiplicatively and are permanent within a"
|
||||
+ " Consecration cycle.",
|
||||
title: "🔧 Goddess Upgrades",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Consecration is the Goddess Realm's prestige layer. When you"
|
||||
+ " Consecrate, your Prayers and Divinity are reset but you receive a"
|
||||
+ " permanent production multiplier that stacks with every Consecration."
|
||||
+ " Spend Divinity in the Consecration Shop on lasting upgrades that"
|
||||
+ " amplify Prayer income, Divinity income, and Disciple power. Each"
|
||||
+ " Consecration also raises your Consecration count, which is required"
|
||||
+ " to challenge higher-tier Goddess Bosses.",
|
||||
title: "🙏 Consecration",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Enlightenment is the Goddess Realm's transcendence layer, available"
|
||||
+ " after sufficient Consecrations. Enlightening performs a deeper"
|
||||
+ " reset — clearing Prayers, Divinity, and Consecration progress — in"
|
||||
+ " exchange for Stardust multipliers that persist forever. Spend"
|
||||
+ " Stardust in the Enlightenment Shop on meta-upgrades that amplify"
|
||||
+ " Prayer income, Disciple power, and future Stardust yields.",
|
||||
title: "🌌 Enlightenment",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Sacred Materials are gathered from Goddess Explorations (three unique"
|
||||
+ " materials per zone). Use them in the Goddess Crafting panel to"
|
||||
+ " craft recipes that grant permanent multipliers to Prayers/s, Divinity"
|
||||
+ " from Consecration, and Disciple Combat Power. Each recipe can only"
|
||||
+ " be crafted once; multipliers from all crafted recipes stack together"
|
||||
+ " and persist through Consecration and Enlightenment resets.",
|
||||
title: "⚗️ Goddess Crafting",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Send divine scouts to explore areas within each Goddess Zone. Each"
|
||||
+ " area runs on a timer and rewards Prayers, Divinity, Stardust, and"
|
||||
+ " Sacred Materials when collected. Goddess Explorations never fail."
|
||||
+ " Exploration zones unlock alongside their corresponding Goddess Zone"
|
||||
+ " — four areas are available per zone. Collecting from an area at"
|
||||
+ " least once marks it as discovered.",
|
||||
title: "🗺️ Goddess Exploration",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Goddess Achievements track milestones across the Goddess Realm:"
|
||||
+ " total Prayers earned, Goddess Bosses defeated, Goddess Quests"
|
||||
+ " completed, Disciples hired, Consecration count, and Goddess"
|
||||
+ " Equipment owned. Unlocking an achievement instantly awards bonus"
|
||||
+ " Divinity and Stardust. Achievements are checked automatically each"
|
||||
+ " tick and are permanent once unlocked.",
|
||||
title: "🏆 Goddess Achievements",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Your first Eternal Sovereignty unlocks the Vampire Realm — a third"
|
||||
+ " game layer that runs alongside your mortal and goddess progress."
|
||||
+ " Switch between modes using the mode bar at the top of the screen."
|
||||
+ " The Vampire Realm uses three currencies: Blood (earned passively"
|
||||
+ " from Thralls each tick), Ichor (earned from Thralls and quest"
|
||||
+ " rewards, carried through Sirings), and Soul Shards (awarded by"
|
||||
+ " Vampire Quests, Awakening resets, and Achievement unlocks).",
|
||||
title: "🧛 Vampire Realm",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Thralls are the Vampire Realm's equivalent of adventurers. Buy them"
|
||||
+ " with Blood to generate passive Blood and Ichor income every tick."
|
||||
+ " Thralls come in six classes — Fledgling, Revenant, Shade,"
|
||||
+ " Bloodbound, Wraith, and Ancient — each progressively more"
|
||||
+ " powerful. Buy in batches of 1, 10, or Max. Thrall-specific"
|
||||
+ " Upgrades multiply the income of individual classes; Blood and"
|
||||
+ " Global Upgrades apply on top. Toggle Auto-Thrall from the Thralls"
|
||||
+ " panel to automatically purchase the highest-tier affordable thrall"
|
||||
+ " each tick.",
|
||||
title: "🧟 Thralls",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"The Vampire Realm has 18 zones, each containing 4 bosses and 5"
|
||||
+ " quests. The starter zone is always available. Subsequent zones"
|
||||
+ " unlock when you defeat the required Vampire Boss AND complete the"
|
||||
+ " required Vampire Quest. Vampire Quests run on a timer and always"
|
||||
+ " succeed — there is no failure chance. Rewards include Blood, Ichor,"
|
||||
+ " Soul Shards, Upgrade unlocks, new Thrall tiers, and equipment."
|
||||
+ " Toggle Auto-Quest from the Quests panel to automatically send your"
|
||||
+ " thralls on the highest available quest.",
|
||||
title: "🗺️ Vampire Zones & Quests",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Challenge Vampire Bosses to earn Blood, equipment drops, and unlock"
|
||||
+ " new Vampire Zones. Your thralls' combined combat power determines"
|
||||
+ " the outcome. Defeated bosses stay defeated. Equipment comes in"
|
||||
+ " three types — Fangs, Shrouds, and Talismans — and provides"
|
||||
+ " bonuses to Blood income, Combat Power, or Ichor multipliers."
|
||||
+ " Equip matching set pieces to unlock escalating set bonuses.",
|
||||
title: "⚔️ Vampire Boss Fights & Equipment",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Siring is the Vampire Realm's prestige layer. When you Sire, your"
|
||||
+ " Blood resets but you receive Ichor and a permanent production"
|
||||
+ " multiplier that stacks with every Siring. Spend Ichor in the"
|
||||
+ " Siring Shop on upgrades that amplify Blood income, Combat Power,"
|
||||
+ " and Thrall effectiveness.",
|
||||
title: "🩸 Siring",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Awakening is the Vampire Realm's transcendence layer. When you"
|
||||
+ " Awaken, your Blood and Ichor reset in exchange for Soul Shards"
|
||||
+ " that persist forever. Spend Soul Shards on meta-upgrades that"
|
||||
+ " amplify Blood income, Combat Power, Siring thresholds, and future"
|
||||
+ " Soul Shard yields.",
|
||||
title: "💠 Awakening",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Dark Materials are gathered from Vampire Explorations (three unique"
|
||||
+ " materials per zone). Use them in the Dark Crafting panel to craft"
|
||||
+ " recipes that grant permanent multipliers to Blood income, Ichor"
|
||||
+ " income, and Thrall Combat Power. Each recipe can only be crafted"
|
||||
+ " once; multipliers from all crafted recipes stack and persist"
|
||||
+ " through Siring and Awakening resets.",
|
||||
title: "⚗️ Vampire Crafting",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Send your thralls to explore dark areas within each Vampire Zone."
|
||||
+ " Each area runs on a timer and rewards Blood, Ichor, and Dark"
|
||||
+ " Materials when collected. Collecting from an area at least once"
|
||||
+ " marks it as discovered. Vampire Explorations never fail. Only one"
|
||||
+ " area can be explored at a time — collect first before sending"
|
||||
+ " thralls out again.",
|
||||
title: "🗺️ Dark Exploration",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"Vampire Achievements track milestones in the Vampire Realm: total"
|
||||
+ " Blood earned, Vampire Bosses defeated, Vampire Quests completed,"
|
||||
+ " Thralls hired, Siring count, and Vampire Equipment owned."
|
||||
+ " Unlocking an achievement instantly awards bonus Ichor and Soul"
|
||||
+ " Shards. Achievements are permanent once unlocked.",
|
||||
title: "🏆 Vampire Achievements",
|
||||
},
|
||||
{
|
||||
body:
|
||||
"The Story tab contains 22 chapters that unlock as you progress. The"
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* @file Consecration panel component for goddess prestige and divinity upgrade shop.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-lines -- Large panel with consecration and shop tabs */
|
||||
/* eslint-disable max-statements -- Consecration panel manages many local state variables */
|
||||
/* eslint-disable stylistic/max-len -- Data content with long description strings */
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- SCREAMING_SNAKE_CASE is conventional for module-level data constants */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { ConsecrationUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const baseConsecrationThreshold = 50_000;
|
||||
|
||||
/**
|
||||
* Calculates the prayers threshold required for the next consecration.
|
||||
* Mirrors the server formula: BASE * (count + 1)^2 * thresholdMultiplier.
|
||||
* @param consecrationCount - The number of consecrations completed so far.
|
||||
* @param thresholdMultiplier - Optional stardust-upgrade multiplier applied to the threshold.
|
||||
* @returns The prayers amount required to consecrate.
|
||||
*/
|
||||
const calculateConsecrationThreshold = (
|
||||
consecrationCount: number,
|
||||
thresholdMultiplier = 1,
|
||||
): number => {
|
||||
return (
|
||||
baseConsecrationThreshold
|
||||
* Math.pow(consecrationCount + 1, 2)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
const divinityYieldDivisor = 1000;
|
||||
|
||||
/**
|
||||
* Calculates the projected divinity yield from a consecration.
|
||||
* Mirrors the server formula: MAX(1, FLOOR(SQRT(totalPrayersEarned / divisor) * divinityMultiplier)).
|
||||
* @param totalPrayersEarned - Total prayers earned in the current run.
|
||||
* @param divinityMultiplier - Multiplier from stardust upgrades applied to divinity yield.
|
||||
* @returns The projected divinity earned.
|
||||
*/
|
||||
const calculateDivinityYield = (
|
||||
totalPrayersEarned: number,
|
||||
divinityMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
Math.sqrt(totalPrayersEarned / divinityYieldDivisor) * divinityMultiplier,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the consecration production multiplier from the count.
|
||||
* Each consecration adds 25% to the production multiplier.
|
||||
* @param count - The number of consecrations completed.
|
||||
* @returns The computed production multiplier.
|
||||
*/
|
||||
const computeConsecrationProductionMultiplier = (count: number): number => {
|
||||
// eslint-disable-next-line stylistic/no-extra-parens -- Required by no-mixed-operators rule
|
||||
return 1 + (count * 0.25);
|
||||
};
|
||||
|
||||
const CONSECRATION_UPGRADES: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: ConsecrationUpgradeCategory;
|
||||
divinityCost: number;
|
||||
multiplier: number;
|
||||
}> = [
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The first drop of divinity awakens your disciples' devotion. All prayers/s ×1.25.",
|
||||
divinityCost: 5,
|
||||
id: "divine_prayers_1",
|
||||
multiplier: 1.25,
|
||||
name: "Divinity Blessing I",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "Deeper divine resonance amplifies every prayer across the order. All prayers/s ×1.5.",
|
||||
divinityCost: 15,
|
||||
id: "divine_prayers_2",
|
||||
multiplier: 1.5,
|
||||
name: "Divinity Blessing II",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The full weight of accumulated consecration doubles prayer output entirely. All prayers/s ×2.",
|
||||
divinityCost: 40,
|
||||
id: "divine_prayers_3",
|
||||
multiplier: 2,
|
||||
name: "Divinity Blessing III",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The goddess's own blessing multiplies prayers fivefold through all of creation. All prayers/s ×5.",
|
||||
divinityCost: 120,
|
||||
id: "divine_prayers_4",
|
||||
multiplier: 5,
|
||||
name: "Divinity Blessing IV",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "An unbroken chain of consecrations has tuned your disciples to a perfect divine frequency. All prayers/s ×10.",
|
||||
divinityCost: 350,
|
||||
id: "divine_prayers_5",
|
||||
multiplier: 10,
|
||||
name: "Divinity Blessing V",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "The consecration memory floods every prayer with exponential fervour. All prayers/s ×25.",
|
||||
divinityCost: 900,
|
||||
id: "divine_prayers_6",
|
||||
multiplier: 25,
|
||||
name: "Divinity Blessing VI",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
description: "Every act of consecration resonates through eternity, amplifying prayers a hundredfold. All prayers/s ×100.",
|
||||
divinityCost: 2500,
|
||||
id: "divine_prayers_7",
|
||||
multiplier: 100,
|
||||
name: "Divinity Blessing VII",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "Divinity breathes life into every disciple in your order. Disciple output ×1.25.",
|
||||
divinityCost: 8,
|
||||
id: "divine_disciples_1",
|
||||
multiplier: 1.25,
|
||||
name: "Sacred Ordination I",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "Deeper divine resonance heightens disciple fervour. Disciple output ×1.5.",
|
||||
divinityCost: 25,
|
||||
id: "divine_disciples_2",
|
||||
multiplier: 1.5,
|
||||
name: "Sacred Ordination II",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "Consecration anoints each disciple with divine purpose, doubling their output.",
|
||||
divinityCost: 70,
|
||||
id: "divine_disciples_3",
|
||||
multiplier: 2,
|
||||
name: "Sacred Ordination III",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "The goddess's light magnifies every disciple fivefold across the entire order.",
|
||||
divinityCost: 200,
|
||||
id: "divine_disciples_4",
|
||||
multiplier: 5,
|
||||
name: "Sacred Ordination IV",
|
||||
},
|
||||
{
|
||||
category: "disciples",
|
||||
description: "Generations of consecration have transcended the order itself. Disciple output ×10.",
|
||||
divinityCost: 600,
|
||||
id: "divine_disciples_5",
|
||||
multiplier: 10,
|
||||
name: "Sacred Ordination V",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "The goddess breathes divine fury into your disciples on the battlefield. Combat DPS ×1.25.",
|
||||
divinityCost: 10,
|
||||
id: "divine_combat_1",
|
||||
multiplier: 1.25,
|
||||
name: "Blessed Blade I",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Divine power surges through your warriors in battle. Combat DPS ×1.5.",
|
||||
divinityCost: 30,
|
||||
id: "divine_combat_2",
|
||||
multiplier: 1.5,
|
||||
name: "Blessed Blade II",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Consecration has forged your disciples into divine instruments of war. Combat DPS ×2.",
|
||||
divinityCost: 80,
|
||||
id: "divine_combat_3",
|
||||
multiplier: 2,
|
||||
name: "Blessed Blade III",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Your disciples strike with the force of accumulated divinity. Combat DPS ×5.",
|
||||
divinityCost: 250,
|
||||
id: "divine_combat_4",
|
||||
multiplier: 5,
|
||||
name: "Blessed Blade IV",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "The goddess herself fights through your disciples on the sacred field. Combat DPS ×10.",
|
||||
divinityCost: 750,
|
||||
id: "divine_combat_5",
|
||||
multiplier: 10,
|
||||
name: "Blessed Blade V",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "Divine attunement sharpens the flow of divinity from each consecration. Divinity yield ×1.25.",
|
||||
divinityCost: 20,
|
||||
id: "divine_divinity_1",
|
||||
multiplier: 1.25,
|
||||
name: "Divine Resonance I",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "Sacred wisdom accumulated through consecrations amplifies the divine yield. Divinity yield ×1.5.",
|
||||
divinityCost: 60,
|
||||
id: "divine_divinity_2",
|
||||
multiplier: 1.5,
|
||||
name: "Divine Resonance II",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "Each consecration leaves a deeper impression on the divine fabric. Divinity yield ×2.",
|
||||
divinityCost: 175,
|
||||
id: "divine_divinity_3",
|
||||
multiplier: 2,
|
||||
name: "Divine Resonance III",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "The goddess opens a direct channel of divinity to reward your devotion. Divinity yield ×3.",
|
||||
divinityCost: 500,
|
||||
id: "divine_divinity_4",
|
||||
multiplier: 3,
|
||||
name: "Divine Resonance IV",
|
||||
},
|
||||
{
|
||||
category: "divinity",
|
||||
description: "Perfect harmony between consecrator and goddess multiplies divinity fivefold. Divinity yield ×5.",
|
||||
divinityCost: 1500,
|
||||
id: "divine_divinity_5",
|
||||
multiplier: 5,
|
||||
name: "Divine Resonance V",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Consecration memory compresses the prayers required for the next divine reset by 10%.",
|
||||
divinityCost: 50,
|
||||
id: "divine_utility_1",
|
||||
multiplier: 0.9,
|
||||
name: "Sacred Shortcut I",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "The goddess guides your path, shortening the consecration threshold by 20%.",
|
||||
divinityCost: 150,
|
||||
id: "divine_utility_2",
|
||||
multiplier: 0.8,
|
||||
name: "Sacred Shortcut II",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Centuries of consecration have worn a path through the divine — threshold reduced by 30%.",
|
||||
divinityCost: 400,
|
||||
id: "divine_utility_3",
|
||||
multiplier: 0.7,
|
||||
name: "Sacred Shortcut III",
|
||||
},
|
||||
];
|
||||
|
||||
const categoryOrder: Array<ConsecrationUpgradeCategory> = [
|
||||
"prayers",
|
||||
"disciples",
|
||||
"combat",
|
||||
"divinity",
|
||||
"utility",
|
||||
];
|
||||
|
||||
const CONSECRATION_UPGRADE_CATEGORY_LABELS: Record<ConsecrationUpgradeCategory, string> = {
|
||||
combat: "⚔️ Combat Multipliers",
|
||||
disciples: "🙏 Disciple Multipliers",
|
||||
divinity: "✨ Divinity Yield",
|
||||
prayers: "📿 Prayer Multipliers",
|
||||
utility: "🎯 Quality of Life",
|
||||
};
|
||||
|
||||
type ConsecrationTab = "consecrate" | "shop";
|
||||
|
||||
/**
|
||||
* Renders the consecration panel with ascension and divinity shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ConsecrationPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reloadSilent,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
consecrate,
|
||||
buyConsecrationUpgrade,
|
||||
showConsecrationToast,
|
||||
dismissConsecrationToast,
|
||||
} = useGame();
|
||||
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
divinityEarned: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ consecrationError, setConsecrationError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<ConsecrationTab>("consecrate");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { consecration, enlightenment, totalPrayersEarned } = goddess;
|
||||
|
||||
const thresholdMultiplier = enlightenment.stardustConsecrationThresholdMultiplier;
|
||||
const threshold = calculateConsecrationThreshold(consecration.count, thresholdMultiplier);
|
||||
const isEligible = totalPrayersEarned >= threshold;
|
||||
|
||||
const divinityMultiplier = enlightenment.stardustConsecrationDivinityMultiplier;
|
||||
const divinityPreview = calculateDivinityYield(totalPrayersEarned, divinityMultiplier);
|
||||
|
||||
const nextMultiplier = computeConsecrationProductionMultiplier(consecration.count + 1);
|
||||
const progressRatio = Math.min(totalPrayersEarned / threshold, 1);
|
||||
const progressPct = (progressRatio * 100).toFixed(1);
|
||||
|
||||
const currentDivinity = consecration.divinity;
|
||||
|
||||
async function handleConsecrate(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setConsecrationError(null);
|
||||
try {
|
||||
const data = await consecrate();
|
||||
setResult({
|
||||
count: data.newConsecrationCount,
|
||||
divinityEarned: data.divinityEarned,
|
||||
});
|
||||
await reloadSilent();
|
||||
} catch (error_: unknown) {
|
||||
setConsecrationError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Consecration failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyConsecrationUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((categoryId) => {
|
||||
const label = CONSECRATION_UPGRADE_CATEGORY_LABELS[categoryId];
|
||||
const upgrades = CONSECRATION_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === categoryId;
|
||||
});
|
||||
return { categoryId, label, upgrades };
|
||||
});
|
||||
|
||||
function handleConsecrateClick(): void {
|
||||
void handleConsecrate();
|
||||
}
|
||||
|
||||
function handleConsecrateTabClick(): void {
|
||||
setActiveTab("consecrate");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel consecration-panel">
|
||||
<h2>{"🕯️ Consecration"}</h2>
|
||||
|
||||
{showConsecrationToast
|
||||
? <div className="prestige-toast consecration-toast">
|
||||
<p>{"✨ Consecration complete!"}</p>
|
||||
<button
|
||||
onClick={dismissConsecrationToast}
|
||||
type="button"
|
||||
>
|
||||
{"Dismiss"}
|
||||
</button>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "consecrate"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleConsecrateTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Consecrate"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"✨ Divinity Shop ("}
|
||||
{formatInteger(currentDivinity)}
|
||||
{" divinity)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "consecrate"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Consecration is the goddess prestige layer. It resets your prayers"
|
||||
+ " and goddess progress, but grants "}
|
||||
<strong>{"Divinity"}</strong>
|
||||
{" — a permanent goddess currency used to purchase powerful upgrades."
|
||||
+ " Each consecration also permanently increases your prayers/s multiplier."}
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{consecration.count > 0
|
||||
? <p>
|
||||
{"Consecration count: "}
|
||||
<strong>{consecration.count}</strong>
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
<p>
|
||||
{"Current Divinity: "}
|
||||
<strong>{formatInteger(currentDivinity)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Prayers this run: "}
|
||||
<strong>{formatNumber(totalPrayersEarned)}</strong>
|
||||
{" / "}
|
||||
<strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<div className="prestige-progress-bar">
|
||||
<div
|
||||
className="prestige-progress-fill"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="prestige-progress-label">
|
||||
{progressPct}
|
||||
{"% of threshold"}
|
||||
</p>
|
||||
{isEligible
|
||||
? <p className="echo-preview">
|
||||
{"Divinity on consecration: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatInteger(divinityPreview)}
|
||||
</strong>
|
||||
{divinityMultiplier > 1
|
||||
? <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{divinityMultiplier.toFixed(2)}
|
||||
{" yield bonus applied)"}
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
<p>
|
||||
{"Next production multiplier: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{nextMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Earn enough prayers"}</strong>
|
||||
{" to unlock consecration."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"You need "}
|
||||
{formatNumber(threshold)}
|
||||
{" total prayers in the current run. You have "}
|
||||
{formatNumber(totalPrayersEarned)}
|
||||
{"."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to consecrate. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{" within this goddess run."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleConsecrateClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Consecrating..."
|
||||
: `🕯️ Consecrate (+${formatInteger(divinityPreview)} Divinity)`}
|
||||
</button>
|
||||
{consecrationError === null
|
||||
? null
|
||||
: <p className="error">{consecrationError}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Consecrated! Earned "}
|
||||
<strong>
|
||||
{formatInteger(result.divinityEarned)}
|
||||
{" Divinity"}
|
||||
</strong>
|
||||
{". This is Consecration "}
|
||||
{result.count}
|
||||
{". A new divine cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatInteger(currentDivinity)}
|
||||
{" Divinity"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Divinity upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive future consecrations."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ categoryId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={categoryId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = consecration.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = currentDivinity >= upgrade.divinityCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `✨ ${formatInteger(upgrade.divinityCost)} Divinity`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="upgrade-buy-button"
|
||||
disabled={!canAfford || isLoading}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ConsecrationPanel };
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @file Disciples panel component for purchasing goddess disciples.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable react/no-multi-comp -- DiscipleCard sub-component is tightly coupled */
|
||||
/* eslint-disable complexity -- DiscipleCard has inherent branching for batch/afford logic */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { GoddessDisciple } from "@elysium/types";
|
||||
|
||||
type BatchSize = 1 | 10 | "max";
|
||||
const batchOptions: Array<BatchSize> = [ 1, 10, "max" ];
|
||||
|
||||
const growthRate = 1.15;
|
||||
|
||||
/**
|
||||
* Computes the total prayers cost to buy a batch of disciples.
|
||||
* @param disciple - The disciple tier to purchase.
|
||||
* @param quantity - The number to buy.
|
||||
* @returns The total prayers cost.
|
||||
*/
|
||||
const computeBatchCost = (
|
||||
disciple: GoddessDisciple,
|
||||
quantity: number,
|
||||
): number => {
|
||||
let total = 0;
|
||||
for (let index = 0; index < quantity; index = index + 1) {
|
||||
const exponent = disciple.count + index;
|
||||
const cost = disciple.baseCost * Math.pow(growthRate, exponent);
|
||||
total = total + cost;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the maximum number of disciples affordable with available prayers.
|
||||
* @param disciple - The disciple tier.
|
||||
* @param prayers - The available prayer balance.
|
||||
* @returns The maximum affordable quantity.
|
||||
*/
|
||||
const computeMaxAffordable = (
|
||||
disciple: GoddessDisciple,
|
||||
prayers: number,
|
||||
): number => {
|
||||
let total = 0;
|
||||
let quantity = 0;
|
||||
for (let index = 0; index < 100_000; index = index + 1) {
|
||||
const exponent = disciple.count + index;
|
||||
const cost = disciple.baseCost * Math.pow(growthRate, exponent);
|
||||
if (total + cost > prayers) {
|
||||
break;
|
||||
}
|
||||
total = total + cost;
|
||||
quantity = quantity + 1;
|
||||
}
|
||||
return quantity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
|
||||
* @param stored - The raw string from localStorage (or null if absent).
|
||||
* @returns A valid BatchSize value.
|
||||
*/
|
||||
const parseBatchSize = (stored: string | null): BatchSize => {
|
||||
if (stored === "max") {
|
||||
return "max";
|
||||
}
|
||||
if (stored === "10") {
|
||||
return 10;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
interface DiscipleCardProperties {
|
||||
readonly disciple: GoddessDisciple;
|
||||
readonly prayers: number;
|
||||
readonly selectedBatch: BatchSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single disciple purchase card.
|
||||
* @param props - The component properties.
|
||||
* @param props.disciple - The disciple tier to display.
|
||||
* @param props.prayers - The player's current prayer balance.
|
||||
* @param props.selectedBatch - The active batch size selection.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const DiscipleCard = ({
|
||||
disciple,
|
||||
prayers,
|
||||
selectedBatch,
|
||||
}: DiscipleCardProperties): JSX.Element => {
|
||||
const { buyGoddessDisciple, formatNumber } = useGame();
|
||||
|
||||
const maxAffordable = computeMaxAffordable(disciple, prayers);
|
||||
const effectiveBatch = selectedBatch === "max"
|
||||
? maxAffordable
|
||||
: selectedBatch;
|
||||
const batchCost = computeBatchCost(disciple, effectiveBatch);
|
||||
const canAffordBatch = prayers >= batchCost && effectiveBatch > 0;
|
||||
|
||||
const singleCost = computeBatchCost(disciple, 1);
|
||||
|
||||
function handleBuy(): void {
|
||||
if (effectiveBatch > 0) {
|
||||
buyGoddessDisciple(disciple.id, effectiveBatch);
|
||||
}
|
||||
}
|
||||
|
||||
function getBuyButtonLabel(): string {
|
||||
if (selectedBatch === "max") {
|
||||
if (maxAffordable === 0) {
|
||||
return "Can't Afford";
|
||||
}
|
||||
return `Buy Max (×${String(maxAffordable)})`;
|
||||
}
|
||||
return `Buy ×${String(effectiveBatch)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`disciple-card ${disciple.unlocked
|
||||
? ""
|
||||
: "disciple-locked"}`}>
|
||||
<div className="disciple-header">
|
||||
<div className="disciple-title">
|
||||
<h3>{disciple.name}</h3>
|
||||
<span className="disciple-class">{disciple.class}</span>
|
||||
</div>
|
||||
<span className="disciple-count">
|
||||
{"×"}
|
||||
{formatNumber(disciple.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="disciple-income">
|
||||
{disciple.prayersPerSecond > 0
|
||||
&& <span className="income-tag">
|
||||
{"🙏 "}
|
||||
{formatNumber(disciple.prayersPerSecond)}
|
||||
{"/s prayers"}
|
||||
</span>
|
||||
}
|
||||
{disciple.divinityPerSecond > 0
|
||||
&& <span className="income-tag">
|
||||
{"✨ "}
|
||||
{formatNumber(disciple.divinityPerSecond)}
|
||||
{"/s divinity"}
|
||||
</span>
|
||||
}
|
||||
<span className="combat-power-tag">
|
||||
{"⚔️ "}
|
||||
{formatNumber(disciple.combatPower)}
|
||||
{" combat power each"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="disciple-cost">
|
||||
<span className="cost-label">
|
||||
{"Next: 🙏 "}
|
||||
{formatNumber(singleCost)}
|
||||
</span>
|
||||
{selectedBatch !== 1
|
||||
&& effectiveBatch > 0
|
||||
&& <span className="cost-label">
|
||||
{selectedBatch === "max"
|
||||
? "Max"
|
||||
: String(selectedBatch)}
|
||||
{" (×"}
|
||||
{String(effectiveBatch)}
|
||||
{"): 🙏 "}
|
||||
{formatNumber(batchCost)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
{disciple.unlocked
|
||||
? <button
|
||||
className="buy-disciple-button"
|
||||
disabled={!canAffordBatch}
|
||||
onClick={handleBuy}
|
||||
title={
|
||||
canAffordBatch
|
||||
? undefined
|
||||
: `Need 🙏 ${formatNumber(batchCost)} prayers`
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{getBuyButtonLabel()}
|
||||
</button>
|
||||
: <span className="disciple-badge locked">{"🔒 Locked"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the disciples panel for purchasing goddess disciples.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const DisciplesPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
|
||||
return parseBatchSize(localStorage.getItem("elysium_disciple_batch"));
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const goddessState = state.goddess;
|
||||
if (goddessState === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Goddess expansion not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const prayers = state.resources.prayers ?? 0;
|
||||
const { disciples } = goddessState;
|
||||
|
||||
function handleBatchSelect(batch: BatchSize): void {
|
||||
setSelectedBatch(batch);
|
||||
localStorage.setItem("elysium_disciple_batch", String(batch));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel disciples-panel">
|
||||
<h2>{"Disciples"}</h2>
|
||||
<div className="disciples-balance">
|
||||
<span>
|
||||
{"🙏 Prayers: "}
|
||||
<strong>{formatNumber(prayers)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((batch) => {
|
||||
function handleClick(): void {
|
||||
handleBatchSelect(batch);
|
||||
}
|
||||
return <button
|
||||
className={`batch-button ${selectedBatch === batch
|
||||
? "active"
|
||||
: ""}`}
|
||||
key={String(batch)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{batch === "max"
|
||||
? "Max"
|
||||
: `×${String(batch)}`}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="disciples-list">
|
||||
{disciples.map((disciple: GoddessDisciple) => {
|
||||
return <DiscipleCard
|
||||
disciple={disciple}
|
||||
key={disciple.id}
|
||||
prayers={prayers}
|
||||
selectedBatch={selectedBatch}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { DisciplesPanel };
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* @file Enlightenment panel component for goddess transcendence and stardust upgrade shop.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-lines -- Large panel with enlightenment and shop tabs */
|
||||
/* eslint-disable max-statements -- Enlightenment panel manages many local state variables */
|
||||
/* eslint-disable stylistic/max-len -- Data content with long description strings */
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- SCREAMING_SNAKE_CASE is conventional for module-level data constants */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { EnlightenmentUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const finalGoddessBossId = "divine_heart_sovereign";
|
||||
|
||||
/**
|
||||
* Calculates the projected stardust yield from an Enlightenment.
|
||||
* Mirrors the server formula: MAX(1, FLOOR(SQRT(consecrationCount) * metaMultiplier)).
|
||||
* @param consecrationCount - The number of consecrations completed before this Enlightenment.
|
||||
* @param metaMultiplier - Multiplier from prior enlightenment upgrades applied to stardust yield.
|
||||
* @returns The projected stardust earned.
|
||||
*/
|
||||
const calculateStardustYield = (
|
||||
consecrationCount: number,
|
||||
metaMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(1, Math.floor(Math.sqrt(consecrationCount) * metaMultiplier));
|
||||
};
|
||||
|
||||
const ENLIGHTENMENT_UPGRADES: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: EnlightenmentUpgradeCategory;
|
||||
cost: number;
|
||||
multiplier: number;
|
||||
}> = [
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 2,
|
||||
description: "The memory of past consecrations echoes through your order, amplifying prayer income by 25%.",
|
||||
id: "stardust_prayers_1",
|
||||
multiplier: 1.25,
|
||||
name: "Celestial Echo I",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 4,
|
||||
description: "Transcendent experience resonates through every disciple in the order, boosting prayers by 50%.",
|
||||
id: "stardust_prayers_2",
|
||||
multiplier: 1.5,
|
||||
name: "Celestial Echo II",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 8,
|
||||
description: "The harmony of enlightened cycles surges through your order, doubling all prayer income.",
|
||||
id: "stardust_prayers_3",
|
||||
multiplier: 2,
|
||||
name: "Celestial Echo III",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 16,
|
||||
description: "Divine overflow from enlightenment floods the order, tripling all prayer income.",
|
||||
id: "stardust_prayers_4",
|
||||
multiplier: 3,
|
||||
name: "Celestial Echo IV",
|
||||
},
|
||||
{
|
||||
category: "prayers",
|
||||
cost: 32,
|
||||
description: "The infinite chorus of every consecration you have completed multiplies prayer income fivefold.",
|
||||
id: "stardust_prayers_5",
|
||||
multiplier: 5,
|
||||
name: "Celestial Echo V",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 2,
|
||||
description: "Memories of every divine battle harden your disciples, increasing combat power by 25%.",
|
||||
id: "stardust_combat_1",
|
||||
multiplier: 1.25,
|
||||
name: "Battle Memory I",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 5,
|
||||
description: "Veterans of enlightenment carry the strength of all past battles, boosting combat by 50%.",
|
||||
id: "stardust_combat_2",
|
||||
multiplier: 1.5,
|
||||
name: "Battle Memory II",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 12,
|
||||
description: "Your disciples fight with the accumulated fury of countless consecrated cycles. Combat ×2.",
|
||||
id: "stardust_combat_3",
|
||||
multiplier: 2,
|
||||
name: "Battle Memory III",
|
||||
},
|
||||
{
|
||||
category: "consecration_threshold",
|
||||
cost: 3,
|
||||
description: "Enlightened wisdom shortens the path to each consecration — threshold reduced by 10%.",
|
||||
id: "stardust_threshold_1",
|
||||
multiplier: 0.9,
|
||||
name: "Accelerated Devotion I",
|
||||
},
|
||||
{
|
||||
category: "consecration_threshold",
|
||||
cost: 7,
|
||||
description: "The goddess herself smooths your path — consecration threshold reduced by 20%.",
|
||||
id: "stardust_threshold_2",
|
||||
multiplier: 0.8,
|
||||
name: "Accelerated Devotion II",
|
||||
},
|
||||
{
|
||||
category: "consecration_threshold",
|
||||
cost: 15,
|
||||
description: "Generations of enlightenment compress the threshold by 30%.",
|
||||
id: "stardust_threshold_3",
|
||||
multiplier: 0.7,
|
||||
name: "Accelerated Devotion III",
|
||||
},
|
||||
{
|
||||
category: "consecration_divinity",
|
||||
cost: 3,
|
||||
description: "Enlightened insight amplifies the divinity granted by each consecration by 25%.",
|
||||
id: "stardust_divinity_1",
|
||||
multiplier: 1.25,
|
||||
name: "Divinity Amplifier I",
|
||||
},
|
||||
{
|
||||
category: "consecration_divinity",
|
||||
cost: 8,
|
||||
description: "The goddess pours greater divinity through each sacred reset — divinity yield ×1.5.",
|
||||
id: "stardust_divinity_2",
|
||||
multiplier: 1.5,
|
||||
name: "Divinity Amplifier II",
|
||||
},
|
||||
{
|
||||
category: "consecration_divinity",
|
||||
cost: 18,
|
||||
description: "Enlightenment has attuned you to the divine source itself — divinity yield ×2.",
|
||||
id: "stardust_divinity_3",
|
||||
multiplier: 2,
|
||||
name: "Divinity Amplifier III",
|
||||
},
|
||||
{
|
||||
category: "stardust_meta",
|
||||
cost: 5,
|
||||
description: "Each enlightenment resonates deeper, amplifying future stardust yields by 25%.",
|
||||
id: "stardust_meta_1",
|
||||
multiplier: 1.25,
|
||||
name: "Stellar Resonance I",
|
||||
},
|
||||
{
|
||||
category: "stardust_meta",
|
||||
cost: 12,
|
||||
description: "The spiral of enlightenment compounds — future stardust yields ×1.5.",
|
||||
id: "stardust_meta_2",
|
||||
multiplier: 1.5,
|
||||
name: "Stellar Resonance II",
|
||||
},
|
||||
{
|
||||
category: "stardust_meta",
|
||||
cost: 25,
|
||||
description: "You have mastered the infinite stellar cycle — future stardust yields ×2.",
|
||||
id: "stardust_meta_3",
|
||||
multiplier: 2,
|
||||
name: "Stellar Resonance III",
|
||||
},
|
||||
];
|
||||
|
||||
const categoryOrder: Array<EnlightenmentUpgradeCategory> = [
|
||||
"prayers",
|
||||
"combat",
|
||||
"consecration_threshold",
|
||||
"consecration_divinity",
|
||||
"stardust_meta",
|
||||
];
|
||||
|
||||
const ENLIGHTENMENT_UPGRADE_CATEGORY_LABELS: Record<EnlightenmentUpgradeCategory, string> = {
|
||||
combat: "⚔️ Combat Multipliers",
|
||||
consecration_divinity: "✨ Consecration Quality of Life — Divinity Yield",
|
||||
consecration_threshold: "🎯 Consecration Quality of Life — Threshold",
|
||||
prayers: "📿 Prayer Multipliers",
|
||||
stardust_meta: "🌟 Stardust Meta Upgrades",
|
||||
};
|
||||
|
||||
type EnlightenmentTab = "enlighten" | "shop";
|
||||
|
||||
/**
|
||||
* Renders the enlightenment panel with transcendence and stardust shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EnlightenmentPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reloadSilent,
|
||||
formatInteger,
|
||||
enlighten,
|
||||
buyEnlightenmentUpgrade,
|
||||
showEnlightenmentToast,
|
||||
dismissEnlightenmentToast,
|
||||
} = useGame();
|
||||
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
stardustEarned: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ enlightenmentError, setEnlightenmentError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<EnlightenmentTab>("enlighten");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { consecration, enlightenment, bosses } = goddess;
|
||||
|
||||
const hasDefeatedFinalBoss = bosses.some((boss) => {
|
||||
return boss.id === finalGoddessBossId && boss.status === "defeated";
|
||||
});
|
||||
|
||||
const metaMultiplier = enlightenment.stardustMetaMultiplier;
|
||||
const stardustPreview = calculateStardustYield(consecration.count, metaMultiplier);
|
||||
const currentStardust = enlightenment.stardust;
|
||||
const enlightenmentCount = enlightenment.count;
|
||||
|
||||
async function handleEnlighten(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setEnlightenmentError(null);
|
||||
try {
|
||||
const data = await enlighten();
|
||||
setResult({
|
||||
count: data.newEnlightenmentCount,
|
||||
stardustEarned: data.stardustEarned,
|
||||
});
|
||||
await reloadSilent();
|
||||
} catch (error_: unknown) {
|
||||
setEnlightenmentError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Enlightenment failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyEnlightenmentUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((catId) => {
|
||||
const label = ENLIGHTENMENT_UPGRADE_CATEGORY_LABELS[catId];
|
||||
const upgrades = ENLIGHTENMENT_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === catId;
|
||||
});
|
||||
return { catId, label, upgrades };
|
||||
});
|
||||
|
||||
function handleEnlightenClick(): void {
|
||||
void handleEnlighten();
|
||||
}
|
||||
|
||||
function handleEnlightenTabClick(): void {
|
||||
setActiveTab("enlighten");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel enlightenment-panel">
|
||||
<h2>{"🌟 Enlightenment"}</h2>
|
||||
|
||||
{showEnlightenmentToast
|
||||
? <div className="prestige-toast enlightenment-toast">
|
||||
<p>{"🌟 Enlightenment achieved!"}</p>
|
||||
<button
|
||||
onClick={dismissEnlightenmentToast}
|
||||
type="button"
|
||||
>
|
||||
{"Dismiss"}
|
||||
</button>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "enlighten"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleEnlightenTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Enlighten"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"🌟 Stardust Shop ("}
|
||||
{formatInteger(currentStardust)}
|
||||
{" stardust)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "enlighten"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Enlightenment is the ultimate goddess reset. It wipes "}
|
||||
<strong>{"everything"}</strong>
|
||||
{" in the goddess realm — prayers, consecrations, disciples, and upgrades"
|
||||
+ " — but grants "}
|
||||
<strong>{"Stardust"}</strong>
|
||||
{", a permanent goddess currency that survives all future resets."
|
||||
+ " Stardust powers upgrades that permanently amplify every goddess run."}
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
{"More consecrations = more Stardust."}
|
||||
{" Optimise your goddess run for maximum yield!"}
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{enlightenmentCount > 0
|
||||
? <p>
|
||||
{"Enlightenment count: "}
|
||||
<strong>{enlightenmentCount}</strong>
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
<p>
|
||||
{"Current Stardust: "}
|
||||
<strong>{formatInteger(currentStardust)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current consecration count: "}
|
||||
<strong>{consecration.count}</strong>
|
||||
</p>
|
||||
{hasDefeatedFinalBoss
|
||||
? <p className="echo-preview">
|
||||
{"Stardust on enlightenment: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatInteger(stardustPreview)}
|
||||
</strong>
|
||||
{metaMultiplier > 1
|
||||
? <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{metaMultiplier.toFixed(2)}
|
||||
{" meta bonus applied)"}
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Defeat the Divine Heart Sovereign"}</strong>
|
||||
{" to unlock Enlightenment."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"The Divine Heart Sovereign is the final boss of the Goddess realm."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to achieve Enlightenment. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleEnlightenClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Achieving Enlightenment..."
|
||||
: `🌟 Enlighten (+${formatInteger(stardustPreview)} Stardust)`}
|
||||
</button>
|
||||
{enlightenmentError === null
|
||||
? null
|
||||
: <p className="error">{enlightenmentError}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Enlightenment achieved! Earned "}
|
||||
<strong>
|
||||
{formatInteger(result.stardustEarned)}
|
||||
{" Stardust"}
|
||||
</strong>
|
||||
{". This is Enlightenment "}
|
||||
{result.count}
|
||||
{". A new stellar cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatInteger(currentStardust)}
|
||||
{" Stardust"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Stardust upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive all future consecrations and enlightenments."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ catId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={catId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = enlightenment.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = currentStardust >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `🌟 ${formatInteger(upgrade.cost)} Stardust`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="upgrade-buy-button"
|
||||
disabled={!canAfford || isLoading}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { EnlightenmentPanel };
|
||||
@@ -4,9 +4,11 @@
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Complex layout with many conditional renders */
|
||||
/* eslint-disable max-lines-per-function -- Complex layout with many conditional renders */
|
||||
/* eslint-disable complexity -- Many tab render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
/* eslint-disable max-statements -- Many state variables for multi-mode tab routing */
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { ResourceBar } from "../ui/resourceBar.js";
|
||||
import { AboutPanel } from "./aboutPanel.js";
|
||||
@@ -21,12 +23,23 @@ import { ClickArea } from "./clickArea.js";
|
||||
import { CodexPanel } from "./codexPanel.js";
|
||||
import { CodexToast } from "./codexToast.js";
|
||||
import { CompanionPanel } from "./companionPanel.js";
|
||||
import { ConsecrationPanel } from "./consecrationPanel.js";
|
||||
import { CraftingPanel } from "./craftingPanel.js";
|
||||
import { DailyChallengePanel } from "./dailyChallengePanel.js";
|
||||
import { DebugPanel } from "./debugPanel.js";
|
||||
import { DisciplesPanel } from "./disciplesPanel.js";
|
||||
import { EditProfileModal } from "./editProfileModal.js";
|
||||
import { EnlightenmentPanel } from "./enlightenmentPanel.js";
|
||||
import { EquipmentPanel } from "./equipmentPanel.js";
|
||||
import { ExplorationPanel } from "./explorationPanel.js";
|
||||
import { GoddessAchievementsPanel } from "./goddessAchievementsPanel.js";
|
||||
import { GoddessBossPanel } from "./goddessBossPanel.js";
|
||||
import { GoddessCraftingPanel } from "./goddessCraftingPanel.js";
|
||||
import { GoddessEquipmentPanel } from "./goddessEquipmentPanel.js";
|
||||
import { GoddessExplorationPanel } from "./goddessExplorationPanel.js";
|
||||
import { GoddessQuestsPanel } from "./goddessQuestsPanel.js";
|
||||
import { GoddessUpgradesPanel } from "./goddessUpgradesPanel.js";
|
||||
import { GoddessZonesPanel } from "./goddessZonesPanel.js";
|
||||
import { JoinCommunityModal } from "./joinCommunityModal.js";
|
||||
import { LoginBonusModal } from "./loginBonusModal.js";
|
||||
import { MilestoneToast } from "./milestoneToast.js";
|
||||
@@ -40,6 +53,19 @@ import { StoryPanel } from "./storyPanel.js";
|
||||
import { StoryToast } from "./storyToast.js";
|
||||
import { TranscendencePanel } from "./transcendencePanel.js";
|
||||
import { UpgradePanel } from "./upgradePanel.js";
|
||||
import { VampireAchievementsPanel } from "./vampireAchievementsPanel.js";
|
||||
import { VampireAwakeningPanel } from "./vampireAwakeningPanel.js";
|
||||
import { VampireBossPanel } from "./vampireBossPanel.js";
|
||||
import { VampireCraftingPanel } from "./vampireCraftingPanel.js";
|
||||
import { VampireEquipmentPanel } from "./vampireEquipmentPanel.js";
|
||||
import { VampireExplorationPanel } from "./vampireExplorationPanel.js";
|
||||
import { VampireQuestsPanel } from "./vampireQuestsPanel.js";
|
||||
import { VampireSiringPanel } from "./vampireSiringPanel.js";
|
||||
import { VampireThrallsPanel } from "./vampireThrallsPanel.js";
|
||||
import { VampireUpgradesPanel } from "./vampireUpgradesPanel.js";
|
||||
import { VampireZonesPanel } from "./vampireZonesPanel.js";
|
||||
|
||||
type Mode = "mortal" | "goddess" | "vampire";
|
||||
|
||||
type Tab =
|
||||
| "adventurers"
|
||||
@@ -62,6 +88,32 @@ type Tab =
|
||||
| "story"
|
||||
| "debug";
|
||||
|
||||
type GoddessTab =
|
||||
| "goddess-zones"
|
||||
| "goddess-bosses"
|
||||
| "goddess-quests"
|
||||
| "disciples"
|
||||
| "goddess-equipment"
|
||||
| "goddess-upgrades"
|
||||
| "consecration"
|
||||
| "enlightenment"
|
||||
| "goddess-crafting"
|
||||
| "goddess-exploration"
|
||||
| "goddess-achievements";
|
||||
|
||||
type VampireTab =
|
||||
| "vampire-zones"
|
||||
| "vampire-bosses"
|
||||
| "vampire-quests"
|
||||
| "thralls"
|
||||
| "vampire-equipment"
|
||||
| "vampire-upgrades"
|
||||
| "siring"
|
||||
| "vampire-awakening"
|
||||
| "vampire-crafting"
|
||||
| "vampire-exploration"
|
||||
| "vampire-achievements";
|
||||
|
||||
const baseTabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "adventurers", label: "⚔️ Adventurers" },
|
||||
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||
@@ -84,6 +136,54 @@ const baseTabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: "debug", label: "🔧 Debug" },
|
||||
];
|
||||
|
||||
const vampireTabs: Array<{ id: VampireTab; label: string }> = [
|
||||
{ id: "vampire-zones", label: "🗺️ Zones" },
|
||||
{ id: "vampire-bosses", label: "🩸 Bosses" },
|
||||
{ id: "vampire-quests", label: "📜 Quests" },
|
||||
{ id: "thralls", label: "🧟 Thralls" },
|
||||
{ id: "vampire-equipment", label: "🦇 Equipment" },
|
||||
{ id: "vampire-upgrades", label: "⚔️ Upgrades" },
|
||||
{ id: "siring", label: "🩸 Siring" },
|
||||
{ id: "vampire-awakening", label: "💀 Awakening" },
|
||||
{ id: "vampire-crafting", label: "⚗️ Crafting" },
|
||||
{ id: "vampire-exploration", label: "🌑 Exploration" },
|
||||
{ id: "vampire-achievements", label: "🏆 Achievements" },
|
||||
];
|
||||
|
||||
const goddessTabs: Array<{ id: GoddessTab; label: string }> = [
|
||||
{ id: "goddess-zones", label: "🌟 Zones" },
|
||||
{ id: "goddess-bosses", label: "👁️ Bosses" },
|
||||
{ id: "goddess-quests", label: "📿 Quests" },
|
||||
{ id: "disciples", label: "🙏 Disciples" },
|
||||
{ id: "goddess-equipment", label: "🔮 Equipment" },
|
||||
{ id: "goddess-upgrades", label: "✨ Upgrades" },
|
||||
{ id: "consecration", label: "🕯️ Consecration" },
|
||||
{ id: "enlightenment", label: "💫 Enlightenment" },
|
||||
{ id: "goddess-crafting", label: "⚗️ Crafting" },
|
||||
{ id: "goddess-exploration", label: "🌌 Exploration" },
|
||||
{ id: "goddess-achievements", label: "🏆 Achievements" },
|
||||
];
|
||||
|
||||
const modes: Array<Mode> = [ "mortal", "goddess", "vampire" ];
|
||||
|
||||
const modeLabels: Record<Mode, string> = {
|
||||
goddess: "✨ Goddess",
|
||||
mortal: "⚔️ Mortal",
|
||||
vampire: "🧛 Vampire",
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the saved active mode from localStorage, defaulting to "mortal".
|
||||
* @returns The saved mode or "mortal".
|
||||
*/
|
||||
const readSavedMode = (): Mode => {
|
||||
const saved = localStorage.getItem("elysium-active-mode");
|
||||
if (saved === "goddess" || saved === "vampire") {
|
||||
return saved;
|
||||
}
|
||||
return "mortal";
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the main game layout with tabs and panels.
|
||||
* @returns The JSX element.
|
||||
@@ -104,11 +204,21 @@ const GameLayout = (): JSX.Element => {
|
||||
dismissLoginBonus,
|
||||
schemaOutdated,
|
||||
} = useGame();
|
||||
const [ activeMode, setActiveMode ] = useState<Mode>(readSavedMode);
|
||||
const [ activeTab, setActiveTab ] = useState<Tab>("adventurers");
|
||||
const [ activeGoddessTab, setActiveGoddessTab ]
|
||||
= useState<GoddessTab>("goddess-zones");
|
||||
const [ activeVampireTab, setActiveVampireTab ]
|
||||
= useState<VampireTab>("vampire-zones");
|
||||
const [ editingProfile, setEditingProfile ] = useState(false);
|
||||
const [ dismissedOutdatedWarning, setDismissedOutdatedWarning ]
|
||||
= useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("goddess-mode", activeMode === "goddess");
|
||||
document.body.classList.toggle("vampire-mode", activeMode === "vampire");
|
||||
}, [ activeMode ]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
@@ -151,6 +261,11 @@ const GameLayout = (): JSX.Element => {
|
||||
setDismissedOutdatedWarning(true);
|
||||
}
|
||||
|
||||
function handleSetMode(mode: Mode): void {
|
||||
localStorage.setItem("elysium-active-mode", mode);
|
||||
setActiveMode(mode);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-layout">
|
||||
<ResourceBar
|
||||
@@ -197,55 +312,226 @@ const GameLayout = (): JSX.Element => {
|
||||
</aside>
|
||||
|
||||
<main className="game-content">
|
||||
<nav className="tab-bar">
|
||||
{baseTabs.map((tab) => {
|
||||
const { id: tabId, label } = tab;
|
||||
function handleTabClick(): void {
|
||||
setActiveTab(tabId);
|
||||
<nav className="mode-bar">
|
||||
{modes.map((mode) => {
|
||||
const apotheosisCount = state.apotheosis?.count ?? 0;
|
||||
const eternalSovereigntyCount
|
||||
= state.vampire?.eternalSovereignty.count ?? 0;
|
||||
const vampireLocked
|
||||
= mode === "vampire" && apotheosisCount === 0;
|
||||
const goddessLocked
|
||||
= mode === "goddess" && eternalSovereigntyCount === 0;
|
||||
const isLocked = vampireLocked || goddessLocked;
|
||||
function handleModeClick(): void {
|
||||
if (!isLocked) {
|
||||
handleSetMode(mode);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-button ${
|
||||
activeTab === tabId
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
key={tabId}
|
||||
onClick={handleTabClick}
|
||||
className={`mode-button${activeMode === mode
|
||||
? " active"
|
||||
: ""}${isLocked
|
||||
? " locked"
|
||||
: ""}`}
|
||||
disabled={isLocked}
|
||||
key={mode}
|
||||
onClick={handleModeClick}
|
||||
title={isLocked
|
||||
? "Not yet unlocked"
|
||||
: modeLabels[mode]}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
{tabId === "codex" && codexBadgeCount > 0
|
||||
&& <span className="tab-badge">{codexBadgeCount}</span>
|
||||
}
|
||||
{tabId === "story" && storyBadgeCount > 0
|
||||
&& <span className="tab-badge">{storyBadgeCount}</span>
|
||||
}
|
||||
{modeLabels[mode]}
|
||||
{isLocked
|
||||
? <span className="mode-lock">{"🔒"}</span>
|
||||
: null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* eslint-disable-next-line no-nested-ternary -- Three-way mode switch for tab bar */}
|
||||
{activeMode === "mortal"
|
||||
? <nav className="tab-bar">
|
||||
{baseTabs.map((tab) => {
|
||||
const { id: tabId, label } = tab;
|
||||
function handleTabClick(): void {
|
||||
setActiveTab(tabId);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-button${activeTab === tabId
|
||||
? " active"
|
||||
: ""}`}
|
||||
key={tabId}
|
||||
onClick={handleTabClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
{tabId === "codex" && codexBadgeCount > 0
|
||||
&& <span className="tab-badge">{codexBadgeCount}</span>
|
||||
}
|
||||
{tabId === "story" && storyBadgeCount > 0
|
||||
&& <span className="tab-badge">{storyBadgeCount}</span>
|
||||
}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
: activeMode === "goddess"
|
||||
? <nav className="tab-bar goddess-tab-bar">
|
||||
{goddessTabs.map((tab) => {
|
||||
const { id: tabId, label } = tab;
|
||||
function handleGoddessTabClick(): void {
|
||||
setActiveGoddessTab(tabId);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-button${activeGoddessTab === tabId
|
||||
? " active"
|
||||
: ""}`}
|
||||
key={tabId}
|
||||
onClick={handleGoddessTabClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
: <nav className="tab-bar vampire-tab-bar">
|
||||
{vampireTabs.map((tab) => {
|
||||
const { id: tabId, label } = tab;
|
||||
function handleVampireTabClick(): void {
|
||||
setActiveVampireTab(tabId);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-button${activeVampireTab === tabId
|
||||
? " active"
|
||||
: ""}`}
|
||||
key={tabId}
|
||||
onClick={handleVampireTabClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
}
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === "adventurers" && <AdventurerPanel />}
|
||||
{activeTab === "upgrades" && <UpgradePanel />}
|
||||
{activeTab === "quests" && <QuestPanel />}
|
||||
{activeTab === "bosses" && <BossPanel />}
|
||||
{activeTab === "equipment" && <EquipmentPanel />}
|
||||
{activeTab === "achievements" && <AchievementPanel />}
|
||||
{activeTab === "prestige" && <PrestigePanel />}
|
||||
{activeTab === "transcendence" && <TranscendencePanel />}
|
||||
{activeTab === "apotheosis" && <ApotheosisPanel />}
|
||||
{activeTab === "exploration" && <ExplorationPanel />}
|
||||
{activeTab === "crafting" && <CraftingPanel />}
|
||||
{activeTab === "statistics" && <StatisticsPanel />}
|
||||
{activeTab === "daily" && <DailyChallengePanel />}
|
||||
{activeTab === "companions" && <CompanionPanel />}
|
||||
{activeTab === "character" && <CharacterSheetPanel />}
|
||||
{activeTab === "story" && <StoryPanel />}
|
||||
{activeTab === "codex" && <CodexPanel />}
|
||||
{activeTab === "about" && <AboutPanel />}
|
||||
{activeTab === "debug" && <DebugPanel />}
|
||||
{activeMode === "mortal" && activeTab === "adventurers"
|
||||
&& <AdventurerPanel />}
|
||||
{activeMode === "mortal" && activeTab === "upgrades"
|
||||
&& <UpgradePanel />}
|
||||
{activeMode === "mortal" && activeTab === "quests"
|
||||
&& <QuestPanel />}
|
||||
{activeMode === "mortal" && activeTab === "bosses"
|
||||
&& <BossPanel />}
|
||||
{activeMode === "mortal" && activeTab === "equipment"
|
||||
&& <EquipmentPanel />}
|
||||
{activeMode === "mortal" && activeTab === "achievements"
|
||||
&& <AchievementPanel />}
|
||||
{activeMode === "mortal" && activeTab === "prestige"
|
||||
&& <PrestigePanel />}
|
||||
{activeMode === "mortal" && activeTab === "transcendence"
|
||||
&& <TranscendencePanel />}
|
||||
{activeMode === "mortal" && activeTab === "apotheosis"
|
||||
&& <ApotheosisPanel />}
|
||||
{activeMode === "mortal" && activeTab === "exploration"
|
||||
&& <ExplorationPanel />}
|
||||
{activeMode === "mortal" && activeTab === "crafting"
|
||||
&& <CraftingPanel />}
|
||||
{activeMode === "mortal" && activeTab === "statistics"
|
||||
&& <StatisticsPanel />}
|
||||
{activeMode === "mortal" && activeTab === "daily"
|
||||
&& <DailyChallengePanel />}
|
||||
{activeMode === "mortal" && activeTab === "companions"
|
||||
&& <CompanionPanel />}
|
||||
{activeMode === "mortal" && activeTab === "character"
|
||||
&& <CharacterSheetPanel />}
|
||||
{activeMode === "mortal" && activeTab === "story"
|
||||
&& <StoryPanel />}
|
||||
{activeMode === "mortal" && activeTab === "codex"
|
||||
&& <CodexPanel />}
|
||||
{activeMode === "mortal" && activeTab === "about"
|
||||
&& <AboutPanel />}
|
||||
{activeMode === "mortal" && activeTab === "debug"
|
||||
&& <DebugPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "goddess-zones"
|
||||
&& <GoddessZonesPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "goddess-bosses"
|
||||
&& <GoddessBossPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "goddess-quests"
|
||||
&& <GoddessQuestsPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "disciples"
|
||||
&& <DisciplesPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-equipment"
|
||||
&& <GoddessEquipmentPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-upgrades"
|
||||
&& <GoddessUpgradesPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "consecration"
|
||||
&& <ConsecrationPanel />}
|
||||
{activeMode === "goddess" && activeGoddessTab === "enlightenment"
|
||||
&& <EnlightenmentPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-crafting"
|
||||
&& <GoddessCraftingPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-exploration"
|
||||
&& <GoddessExplorationPanel />}
|
||||
{activeMode === "goddess"
|
||||
&& activeGoddessTab === "goddess-achievements"
|
||||
&& <GoddessAchievementsPanel />}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-zones"
|
||||
&& <VampireZonesPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-bosses"
|
||||
&& <VampireBossPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-quests"
|
||||
&& <VampireQuestsPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "thralls"
|
||||
&& <VampireThrallsPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-equipment"
|
||||
&& <VampireEquipmentPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-upgrades"
|
||||
&& <VampireUpgradesPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "siring"
|
||||
&& <VampireSiringPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-awakening"
|
||||
&& <VampireAwakeningPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-crafting"
|
||||
&& <VampireCraftingPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-exploration"
|
||||
&& <VampireExplorationPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-achievements"
|
||||
&& <VampireAchievementsPanel />
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @file Goddess achievements panel component displaying all goddess expansion achievements.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||
/* eslint-disable max-lines-per-function -- Achievement panel renders many achievement states */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { GoddessAchievement, GoddessState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Returns the plural form of a word based on a count.
|
||||
* @param count - The count to check.
|
||||
* @param word - The base word to pluralise.
|
||||
* @returns The pluralised word string.
|
||||
*/
|
||||
const pluralise = (count: number, word: string): string => {
|
||||
return count > 1
|
||||
? `${word}s`
|
||||
: word;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a human-readable condition description for a goddess achievement.
|
||||
* @param achievement - The goddess achievement to describe.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns A string describing the achievement condition.
|
||||
*/
|
||||
const conditionDescription = (
|
||||
achievement: GoddessAchievement,
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalPrayersEarned":
|
||||
return `Earn ${formatNumber(condition.amount)} total prayers`;
|
||||
case "goddessBossesDefeated":
|
||||
return `Defeat ${String(condition.amount)} goddess ${pluralise(condition.amount, "boss")}`;
|
||||
case "goddessQuestsCompleted":
|
||||
return `Complete ${String(condition.amount)} goddess ${pluralise(condition.amount, "quest")}`;
|
||||
case "discipleTotal":
|
||||
return `Recruit ${formatNumber(condition.amount)} total ${pluralise(condition.amount, "disciple")}`;
|
||||
case "consecrationCount":
|
||||
return `Consecrate ${String(condition.amount)} ${pluralise(condition.amount, "time")}`;
|
||||
case "goddessEquipmentOwned":
|
||||
return `Own ${String(condition.amount)} goddess equipment ${pluralise(condition.amount, "item")}`;
|
||||
default:
|
||||
return "Unknown condition";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the player's current progress value toward a goddess achievement's unlock condition.
|
||||
* @param achievement - The achievement to evaluate progress for.
|
||||
* @param goddess - The current goddess state.
|
||||
* @returns The current numeric progress toward the achievement condition.
|
||||
*/
|
||||
const getCurrentProgress = (
|
||||
achievement: GoddessAchievement,
|
||||
goddess: GoddessState,
|
||||
): number => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalPrayersEarned":
|
||||
return goddess.lifetimePrayersEarned;
|
||||
case "goddessBossesDefeated":
|
||||
return goddess.lifetimeBossesDefeated;
|
||||
case "goddessQuestsCompleted":
|
||||
return goddess.lifetimeQuestsCompleted;
|
||||
case "discipleTotal":
|
||||
return goddess.disciples.reduce((sum, disciple) => {
|
||||
return sum + disciple.count;
|
||||
}, 0);
|
||||
case "consecrationCount":
|
||||
return goddess.consecration.count;
|
||||
case "goddessEquipmentOwned":
|
||||
return goddess.equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
interface GoddessAchievementCardProperties {
|
||||
readonly achievement: GoddessAchievement;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly progressValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single goddess achievement card.
|
||||
* @param props - The achievement card properties.
|
||||
* @param props.achievement - The achievement to display.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.progressValue - The player's current progress toward the unlock condition.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessAchievementCard = ({
|
||||
achievement,
|
||||
formatNumber,
|
||||
progressValue,
|
||||
}: GoddessAchievementCardProperties): JSX.Element => {
|
||||
const isUnlocked = achievement.unlockedAt !== null;
|
||||
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
|
||||
|
||||
return (
|
||||
<div className={`achievement-card ${isUnlocked
|
||||
? "unlocked"
|
||||
: "locked"}`}>
|
||||
<div className="achievement-icon">
|
||||
<span className="achievement-emoji">{achievement.icon}</span>
|
||||
</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">
|
||||
{conditionDescription(achievement, formatNumber)}
|
||||
</p>
|
||||
{!isUnlocked
|
||||
&& <div className="achievement-progress">
|
||||
<progress
|
||||
max={achievement.condition.amount}
|
||||
value={cappedProgress}
|
||||
/>
|
||||
<span className="achievement-progress-label">
|
||||
{formatNumber(progressValue)}
|
||||
{" / "}
|
||||
{formatNumber(achievement.condition.amount)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{achievement.reward !== undefined
|
||||
&& <div className="achievement-reward">
|
||||
{achievement.reward.divinity !== undefined
|
||||
&& <p>
|
||||
{"✨ +"}
|
||||
{achievement.reward.divinity}
|
||||
{" Divinity"}
|
||||
</p>
|
||||
}
|
||||
{achievement.reward.stardust !== undefined
|
||||
&& <p>
|
||||
{"🌟 +"}
|
||||
{achievement.reward.stardust}
|
||||
{" Stardust"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked
|
||||
? <>
|
||||
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
{achievement.unlockedAt !== null
|
||||
&& <span className="achievement-unlocked-at">
|
||||
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
</>
|
||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the goddess achievements panel with all goddess expansion achievements.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessAchievementsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const achievementList = goddess.achievements;
|
||||
const unlocked = achievementList.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
});
|
||||
const locked = achievementList.filter((achievement) => {
|
||||
return achievement.unlockedAt === null;
|
||||
});
|
||||
const visible = showLocked
|
||||
? achievementList
|
||||
: unlocked;
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel achievement-panel goddess-achievements-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🌸 Goddess Achievements"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="achievement-progress">
|
||||
{unlocked.length}
|
||||
{" / "}
|
||||
{achievementList.length}
|
||||
{" unlocked"}
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{visible.map((achievement) => {
|
||||
return (
|
||||
<GoddessAchievementCard
|
||||
achievement={achievement}
|
||||
formatNumber={formatNumber}
|
||||
key={achievement.id}
|
||||
progressValue={getCurrentProgress(achievement, goddess)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessAchievementsPanel };
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* @file Goddess Boss panel — challenge divine realm bosses.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Panel with sub-component, modal, and zone filter */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Boss card requires many conditional render paths */
|
||||
/* eslint-disable max-statements -- Panel requires many variable declarations */
|
||||
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to this panel */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type {
|
||||
GoddessBoss,
|
||||
GoddessBossChallengeResponse,
|
||||
GoddessZone,
|
||||
} from "@elysium/types";
|
||||
|
||||
interface GoddessBossCardProperties {
|
||||
readonly boss: GoddessBoss;
|
||||
readonly onChallenge: (bossId: string)=> void;
|
||||
readonly isChallenging: boolean;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly formatInteger: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single goddess boss card.
|
||||
* @param props - The boss card properties.
|
||||
* @param props.boss - The boss data.
|
||||
* @param props.onChallenge - Callback to challenge this boss.
|
||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.formatInteger - The integer formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessBossCard = ({
|
||||
boss,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
}: GoddessBossCardProperties): JSX.Element => {
|
||||
const canChallenge
|
||||
= (boss.status === "available" || boss.status === "in_progress")
|
||||
&& !isChallenging;
|
||||
|
||||
const hpRatio = boss.currentHp / boss.maxHp;
|
||||
const hpPercent = hpRatio * 100;
|
||||
|
||||
function handleChallenge(): void {
|
||||
onChallenge(boss.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{boss.status === "locked" && unlockHint !== undefined
|
||||
? <p className="unlock-hint">{unlockHint}</p>
|
||||
: null}
|
||||
{boss.consecrationRequirement > 0
|
||||
? <p className="consecration-requirement">
|
||||
{"🕊️ Requires Consecration "}
|
||||
{boss.consecrationRequirement}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated"
|
||||
? <div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)}
|
||||
{" / "}
|
||||
{formatNumber(boss.maxHp)}
|
||||
{" HP"}
|
||||
</span>
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">
|
||||
{"💢 Boss DPS: "}
|
||||
{formatNumber(boss.damagePerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
{boss.prayersReward > 0
|
||||
&& <span>
|
||||
{"🙏 "}
|
||||
{formatNumber(boss.prayersReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.divinityReward > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatInteger(boss.divinityReward)}
|
||||
{" Divinity"}
|
||||
</span>
|
||||
}
|
||||
{boss.stardustReward > 0
|
||||
&& <span>
|
||||
{"⭐ "}
|
||||
{formatInteger(boss.stardustReward)}
|
||||
{" Stardust"}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
&& <span>
|
||||
{"🗡️ "}
|
||||
{boss.equipmentRewards.length}
|
||||
{" Equipment"}
|
||||
</span>
|
||||
}
|
||||
{boss.status !== "defeated"
|
||||
&& boss.bountyDivinity > 0
|
||||
&& boss.bountyDivinityClaimed !== true
|
||||
? <span className="boss-bounty">
|
||||
{"✨ "}
|
||||
{boss.bountyDivinity}
|
||||
{" Divinity (first kill)"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status === "available" || boss.status === "in_progress"
|
||||
? <button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={handleChallenge}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging
|
||||
? "⚔️ Battling…"
|
||||
: "⚔️ Challenge"}
|
||||
</button>
|
||||
: null}
|
||||
|
||||
{boss.status === "defeated"
|
||||
? <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GoddessBattleModalProperties {
|
||||
readonly result: GoddessBossChallengeResponse;
|
||||
readonly onDismiss: ()=> void;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly formatInteger: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the goddess battle result modal overlay.
|
||||
* @param props - The modal properties.
|
||||
* @param props.result - The battle result data.
|
||||
* @param props.onDismiss - Callback to dismiss the modal.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.formatInteger - The integer formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessBattleModal = ({
|
||||
result,
|
||||
onDismiss,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
}: GoddessBattleModalProperties): JSX.Element => {
|
||||
return (
|
||||
<div aria-modal="true" className="battle-modal-overlay" role="dialog">
|
||||
<div className="battle-modal">
|
||||
<h2 className="battle-modal-title">
|
||||
{result.won
|
||||
? "⚔️ Victory!"
|
||||
: "💀 Defeated!"}
|
||||
</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"⚔️ Your Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"💢 Boss DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"❤️ Boss HP Before"}</span>
|
||||
<span className="stat-value">
|
||||
{formatNumber(result.bossHpBefore)}
|
||||
{" / "}
|
||||
{formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"❤️ Boss HP After"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossNewHp)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"🛡️ Party HP Remaining"}</span>
|
||||
<span className="stat-value">
|
||||
{formatNumber(result.partyHpRemaining)}
|
||||
{" / "}
|
||||
{formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.won && result.rewards !== undefined
|
||||
? <div className="battle-rewards">
|
||||
<h3>{"Rewards"}</h3>
|
||||
{result.rewards.prayers > 0
|
||||
? <p>
|
||||
{"🙏 "}
|
||||
{formatNumber(result.rewards.prayers)}
|
||||
{" Prayers"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.divinity > 0
|
||||
? <p>
|
||||
{"✨ "}
|
||||
{formatInteger(result.rewards.divinity)}
|
||||
{" Divinity"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.stardust > 0
|
||||
? <p>
|
||||
{"⭐ "}
|
||||
{formatInteger(result.rewards.stardust)}
|
||||
{" Stardust"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.bountyDivinity > 0
|
||||
? <p className="bounty-reward">
|
||||
{"✨ "}
|
||||
{formatInteger(result.rewards.bountyDivinity)}
|
||||
{" Divinity (first kill bonus!)"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.upgradeIds.length > 0
|
||||
? <p>
|
||||
{"🔓 "}
|
||||
{result.rewards.upgradeIds.length}
|
||||
{" Upgrade(s) unlocked"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.equipmentIds.length > 0
|
||||
? <p>
|
||||
{"🗡️ "}
|
||||
{result.rewards.equipmentIds.length}
|
||||
{" Equipment item(s) gained"}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
{result.casualties !== undefined && result.casualties.length > 0
|
||||
? <div className="battle-casualties">
|
||||
<h3>{"Casualties"}</h3>
|
||||
{result.casualties.map((casualty) => {
|
||||
return (
|
||||
<p key={casualty.discipleId}>
|
||||
{casualty.killed}
|
||||
{" "}
|
||||
{casualty.discipleId}
|
||||
{" lost"}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Dismiss"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the Goddess Boss panel with zone filtering and battle result modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessBossPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
challengeGoddessBoss,
|
||||
goddessBattleResult,
|
||||
dismissGoddessBattle,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
} = useGame();
|
||||
|
||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ activeZoneId, setActiveZoneId ] = useState<string | null>(() => {
|
||||
return sessionStorage.getItem("elysium_goddess_boss_zone");
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, quests, zones } = goddess;
|
||||
|
||||
async function handleChallenge(bossId: string): Promise<void> {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeGoddessBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChallengeClick(bossId: string): void {
|
||||
void handleChallenge(bossId);
|
||||
}
|
||||
|
||||
function handleZoneSelect(zoneId: string): void {
|
||||
setActiveZoneId(zoneId);
|
||||
sessionStorage.setItem("elysium_goddess_boss_zone", zoneId);
|
||||
}
|
||||
|
||||
function handleShowAll(): void {
|
||||
setActiveZoneId(null);
|
||||
sessionStorage.removeItem("elysium_goddess_boss_zone");
|
||||
}
|
||||
|
||||
const filteredBosses = activeZoneId === null
|
||||
? bosses
|
||||
: bosses.filter((boss) => {
|
||||
return boss.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const bossUnlockHints = new Map<string, string>();
|
||||
for (const zone of zones) {
|
||||
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
|
||||
const zoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === zoneId;
|
||||
});
|
||||
for (let index = 0; index < zoneBosses.length; index = index + 1) {
|
||||
const boss = zoneBosses[index];
|
||||
if (boss === undefined || boss.status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
if (index === 0) {
|
||||
const parts: Array<string> = [];
|
||||
if (unlockBossId !== null) {
|
||||
const gateBoss = bosses.find((candidate) => {
|
||||
return candidate.id === unlockBossId;
|
||||
});
|
||||
if (gateBoss !== undefined) {
|
||||
parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
}
|
||||
if (unlockQuestId !== null) {
|
||||
const gateQuest = quests.find((candidate) => {
|
||||
return candidate.id === unlockQuestId;
|
||||
});
|
||||
if (gateQuest !== undefined) {
|
||||
parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const previousBoss = zoneBosses[index - 1];
|
||||
if (previousBoss !== undefined) {
|
||||
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeZoneData: GoddessZone | undefined = activeZoneId === null
|
||||
? undefined
|
||||
: zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel goddess-boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚔️ Goddess Bosses"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-selector">
|
||||
<button
|
||||
className={`zone-tab${activeZoneId === null
|
||||
? " zone-tab-active"
|
||||
: ""}`}
|
||||
onClick={handleShowAll}
|
||||
type="button"
|
||||
>
|
||||
{"All Zones"}
|
||||
</button>
|
||||
{zones.map((zone) => {
|
||||
function handleSelect(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab${zone.id === activeZoneId
|
||||
? " zone-tab-active"
|
||||
: ""}`}
|
||||
key={zone.id}
|
||||
onClick={handleSelect}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeZoneData?.status === "locked"
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This zone is locked."}</p>
|
||||
{activeZoneData.unlockBossId === null
|
||||
? null
|
||||
: <p>
|
||||
{"⚔️ Defeat: "}
|
||||
{bosses.find((boss) => {
|
||||
return boss.id === activeZoneData.unlockBossId;
|
||||
})?.name ?? activeZoneData.unlockBossId}
|
||||
</p>}
|
||||
{activeZoneData.unlockQuestId === null
|
||||
? null
|
||||
: <p>
|
||||
{"📜 Complete: "}
|
||||
{quests.find((quest) => {
|
||||
return quest.id === activeZoneData.unlockQuestId;
|
||||
})?.name ?? activeZoneData.unlockQuestId}
|
||||
</p>}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="boss-list">
|
||||
{filteredBosses.map((boss) => {
|
||||
return (
|
||||
<GoddessBossCard
|
||||
boss={boss}
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === boss.id}
|
||||
key={boss.id}
|
||||
onChallenge={handleChallengeClick}
|
||||
unlockHint={bossUnlockHints.get(boss.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredBosses.length === 0
|
||||
? <p className="empty-zone">{"No bosses to show in this zone."}</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{goddessBattleResult === null
|
||||
? null
|
||||
: <GoddessBattleModal
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
onDismiss={dismissGoddessBattle}
|
||||
result={goddessBattleResult}
|
||||
/>}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessBossPanel };
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @file Goddess crafting panel component for crafting recipes from sacred materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable max-nested-callbacks -- Nested recipe/material maps require nesting */
|
||||
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { GODDESS_RECIPES } from "../../data/goddessCraftingRecipes.js";
|
||||
import { GODDESS_MATERIALS } from "../../data/goddessMaterials.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
|
||||
const bonusLabel: Record<string, string> = {
|
||||
click_power: "👆 Click Power",
|
||||
combat_power: "⚔️ Combat Power",
|
||||
essence_income: "✨ Essence Income",
|
||||
gold_income: "🪙 Gold Income",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the goddess crafting panel for crafting recipes from sacred materials.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessCraftingPanel = (): JSX.Element => {
|
||||
const { state, craftGoddessRecipe, formatNumber } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||
return (
|
||||
sessionStorage.getItem("elysium_goddess_craft_zone")
|
||||
?? "goddess_celestial_garden"
|
||||
);
|
||||
});
|
||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
const playerMaterials = goddess?.exploration.materials ?? [];
|
||||
const craftedIds = goddess?.exploration.craftedRecipeIds ?? [];
|
||||
|
||||
const goddessZones = goddess?.zones ?? [];
|
||||
const zoneRecipes = GODDESS_RECIPES.filter((recipe) => {
|
||||
return recipe.zoneId === activeZoneId;
|
||||
});
|
||||
const zoneMaterials = GODDESS_MATERIALS.filter((material) => {
|
||||
return material.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
function getQuantity(materialId: string): number {
|
||||
return (
|
||||
playerMaterials.find((playerMaterial) => {
|
||||
return playerMaterial.materialId === materialId;
|
||||
})?.quantity ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function canAffordRecipe(recipeId: string): boolean {
|
||||
const recipe = GODDESS_RECIPES.find((candidateRecipe) => {
|
||||
return candidateRecipe.id === recipeId;
|
||||
});
|
||||
if (recipe === undefined) {
|
||||
return false;
|
||||
}
|
||||
return recipe.requiredMaterials.every((request) => {
|
||||
return getQuantity(request.materialId) >= request.quantity;
|
||||
});
|
||||
}
|
||||
|
||||
function handleZoneSelect(zoneId: string): void {
|
||||
setActiveZoneId(zoneId);
|
||||
sessionStorage.setItem("elysium_goddess_craft_zone", zoneId);
|
||||
}
|
||||
|
||||
async function handleCraft(recipeId: string): Promise<void> {
|
||||
setPendingRecipeId(recipeId);
|
||||
try {
|
||||
await craftGoddessRecipe(recipeId);
|
||||
} finally {
|
||||
setPendingRecipeId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel crafting-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚗️ Sacred Crafting"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-selector">
|
||||
{goddessZones.map((zone) => {
|
||||
const isLocked = zone.status === "locked";
|
||||
|
||||
function handleZoneClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
activeZoneId === zone.id
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
} ${isLocked
|
||||
? "zone-tab-locked"
|
||||
: ""}`}
|
||||
disabled={isLocked}
|
||||
key={zone.id}
|
||||
onClick={handleZoneClick}
|
||||
title={isLocked
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="crafting-content">
|
||||
<div className="materials-section">
|
||||
<h3>{"📦 Sacred 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
|
||||
className={`material-card rarity-${material.rarity} ${
|
||||
qty === 0
|
||||
? "material-empty"
|
||||
: ""
|
||||
}`}
|
||||
key={material.id}
|
||||
>
|
||||
<img
|
||||
alt={material.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("materials", material.id)}
|
||||
/>
|
||||
<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>{"📜 Sacred 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 = canAffordRecipe(recipe.id);
|
||||
const isPending = pendingRecipeId === recipe.id;
|
||||
|
||||
function handleCraftClick(): void {
|
||||
void handleCraft(recipe.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`recipe-card ${
|
||||
crafted
|
||||
? "recipe-crafted"
|
||||
: ""
|
||||
} ${!affordable && !crafted
|
||||
? "recipe-unaffordable"
|
||||
: ""}`}
|
||||
key={recipe.id}
|
||||
>
|
||||
<img
|
||||
alt={recipe.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("recipes", recipe.id)}
|
||||
/>
|
||||
<div className="recipe-info">
|
||||
<h4>{recipe.name}</h4>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
<div className="recipe-bonus">
|
||||
<span className="bonus-label">
|
||||
{bonusLabel[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((request) => {
|
||||
const have = getQuantity(request.materialId);
|
||||
const enough = have >= request.quantity;
|
||||
const matName
|
||||
= GODDESS_MATERIALS.find((mat) => {
|
||||
return mat.id === request.materialId;
|
||||
})?.name ?? request.materialId;
|
||||
return (
|
||||
<span
|
||||
className={`req-tag ${
|
||||
enough
|
||||
? "req-met"
|
||||
: "req-missing"
|
||||
}`}
|
||||
key={request.materialId}
|
||||
>
|
||||
{matName}
|
||||
{": "}
|
||||
{formatNumber(have)}
|
||||
{"/"}
|
||||
{formatNumber(request.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={handleCraftClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Crafting..."
|
||||
: "⚗️ Craft"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessCraftingPanel };
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file Goddess equipment panel for managing goddess relics, vestments, and sigils.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- GoddessEquipmentCard has many conditional render paths */
|
||||
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { GoddessEquipment, GoddessEquipmentType } from "@elysium/types";
|
||||
|
||||
const rarityColour: Record<string, string> = {
|
||||
common: "#9e9e9e",
|
||||
epic: "#9c27b0",
|
||||
legendary: "#ff9800",
|
||||
rare: "#2196f3",
|
||||
};
|
||||
|
||||
const rarityLabel: Record<string, string> = {
|
||||
common: "Common",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
rare: "Rare",
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a human-readable bonus description for a goddess equipment item.
|
||||
* @param item - The goddess equipment item.
|
||||
* @returns The formatted bonus description string.
|
||||
*/
|
||||
const bonusDescription = (item: GoddessEquipment): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (item.bonus.prayersMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.prayersMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Prayers/s`);
|
||||
}
|
||||
if (item.bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Disciple Combat`);
|
||||
}
|
||||
if (item.bonus.divinityMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.divinityMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Divinity/Consecration`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a goddess equipment cost as a readable string.
|
||||
* @param cost - The cost object with prayers, divinity, and stardust.
|
||||
* @param cost.prayers - The prayers component of the cost.
|
||||
* @param cost.divinity - The divinity component of the cost.
|
||||
* @param cost.stardust - The stardust component of the cost.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns The formatted cost string.
|
||||
*/
|
||||
const costLabel = (
|
||||
cost: { prayers: number; divinity: number; stardust: number },
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (cost.prayers > 0) {
|
||||
parts.push(`🙏 ${formatNumber(cost.prayers)}`);
|
||||
}
|
||||
if (cost.divinity > 0) {
|
||||
parts.push(`✨ ${formatNumber(cost.divinity)}`);
|
||||
}
|
||||
if (cost.stardust > 0) {
|
||||
parts.push(`⭐ ${formatNumber(cost.stardust)}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
interface GoddessEquipmentCardProperties {
|
||||
readonly item: GoddessEquipment;
|
||||
readonly prayers: number;
|
||||
readonly divinity: number;
|
||||
readonly stardust: number;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single goddess equipment card with buy/equip actions.
|
||||
* @param props - The card properties.
|
||||
* @param props.item - The goddess equipment data to display.
|
||||
* @param props.prayers - The player's current prayers balance.
|
||||
* @param props.divinity - The player's current divinity balance.
|
||||
* @param props.stardust - The player's current stardust balance.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessEquipmentCard = ({
|
||||
item,
|
||||
prayers,
|
||||
divinity,
|
||||
stardust,
|
||||
formatNumber,
|
||||
}: GoddessEquipmentCardProperties): JSX.Element => {
|
||||
const { buyGoddessEquipment, equipGoddessItem } = useGame();
|
||||
|
||||
const canAfford = item.cost !== undefined
|
||||
&& prayers >= item.cost.prayers
|
||||
&& divinity >= item.cost.divinity
|
||||
&& stardust >= item.cost.stardust;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyGoddessEquipment(item.id);
|
||||
}
|
||||
|
||||
function handleEquip(): void {
|
||||
equipGoddessItem(item.id);
|
||||
}
|
||||
|
||||
let typeEmoji = "🔯";
|
||||
if (item.type === "relic") {
|
||||
typeEmoji = "📿";
|
||||
} else if (item.type === "vestment") {
|
||||
typeEmoji = "👘";
|
||||
}
|
||||
|
||||
const equippedClass = item.equipped
|
||||
? " equipped"
|
||||
: "";
|
||||
const ownedClass = item.owned && !item.equipped
|
||||
? " owned"
|
||||
: "";
|
||||
const lockedClass = item.owned
|
||||
? ""
|
||||
: " locked";
|
||||
const cardClassName = `goddess-equipment-card rarity-${item.rarity}${equippedClass}${ownedClass}${lockedClass}`;
|
||||
|
||||
return (
|
||||
<div className={cardClassName}>
|
||||
<div className="equipment-card-header">
|
||||
<span className="equipment-type-icon">{typeEmoji}</span>
|
||||
<span className="equipment-name">{item.name}</span>
|
||||
<span
|
||||
className="equipment-rarity-badge"
|
||||
style={{ color: rarityColour[item.rarity] }}
|
||||
>
|
||||
{rarityLabel[item.rarity]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{item.setId === undefined
|
||||
? null
|
||||
: <p className="equipment-set">{"Set: "}{item.setId}</p>}
|
||||
<div className="equipment-card-actions">
|
||||
{item.owned && item.equipped
|
||||
? <span className="equipment-equipped-badge">{"✅ Equipped"}</span>
|
||||
: null}
|
||||
{item.owned && !item.equipped
|
||||
? <button
|
||||
className="btn-equip"
|
||||
onClick={handleEquip}
|
||||
type="button"
|
||||
>
|
||||
{"Equip"}
|
||||
</button>
|
||||
: null}
|
||||
{!item.owned && item.cost !== undefined
|
||||
? <button
|
||||
className="btn-buy"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
title={canAfford
|
||||
? ""
|
||||
: "Not enough resources"}
|
||||
type="button"
|
||||
>
|
||||
{"Buy — "}
|
||||
{costLabel(item.cost, formatNumber)}
|
||||
</button>
|
||||
: null}
|
||||
{!item.owned && item.cost === undefined
|
||||
? <span className="equipment-drop-hint">{"🎲 Boss Drop Only"}</span>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TabFilter = "all" | GoddessEquipmentType;
|
||||
|
||||
/**
|
||||
* Renders the goddess equipment panel, displaying all relics, vestments, and sigils.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
export const GoddessEquipmentPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ activeTab, setActiveTab ] = useState<TabFilter>("all");
|
||||
|
||||
if (state === null) {
|
||||
return <div className="panel"><p>{"Loading..."}</p></div>;
|
||||
}
|
||||
|
||||
const prayers = state.resources.prayers ?? 0;
|
||||
const divinity = state.resources.divinity ?? 0;
|
||||
const stardust = state.resources.stardust ?? 0;
|
||||
|
||||
const equipment = state.goddess?.equipment ?? [];
|
||||
|
||||
const filteredEquipment = activeTab === "all"
|
||||
? equipment
|
||||
: equipment.filter((item) => {
|
||||
return item.type === activeTab;
|
||||
});
|
||||
|
||||
const tabs: Array<{ id: TabFilter; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "relic", label: "📿 Relics" },
|
||||
{ id: "vestment", label: "👘 Vestments" },
|
||||
{ id: "sigil", label: "🔯 Sigils" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="goddess-equipment-panel">
|
||||
<div className="panel-resource-bar">
|
||||
<span className="resource-item">
|
||||
{"🙏 Prayers: "}
|
||||
{formatNumber(prayers)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"✨ Divinity: "}
|
||||
{formatNumber(divinity)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"⭐ Stardust: "}
|
||||
{formatNumber(stardust)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="equipment-tabs">
|
||||
{tabs.map((tab) => {
|
||||
function handleTabClick(): void {
|
||||
setActiveTab(tab.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-btn${activeTab === tab.id
|
||||
? " active"
|
||||
: ""}`}
|
||||
key={tab.id}
|
||||
onClick={handleTabClick}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="equipment-grid">
|
||||
{filteredEquipment.map((item) => {
|
||||
return (
|
||||
<GoddessEquipmentCard
|
||||
divinity={divinity}
|
||||
formatNumber={formatNumber}
|
||||
item={item}
|
||||
key={item.id}
|
||||
prayers={prayers}
|
||||
stardust={stardust}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredEquipment.length === 0
|
||||
? <p className="empty-state">
|
||||
{"No equipment in this category yet."}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* @file Goddess exploration panel component for exploring divine areas and collecting sacred materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
|
||||
/* eslint-disable max-statements -- Component function requires many state declarations and handlers */
|
||||
import { type JSX, useEffect, useRef, useState } from "react";
|
||||
import { checkGoddessExplorationClaimable } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
// eslint-disable-next-line stylistic/max-len -- import path cannot be shortened
|
||||
import { GODDESS_EXPLORATION_AREAS } from "../../data/goddessExplorationAreas.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import type {
|
||||
GoddessExploreClaimableResponse,
|
||||
GoddessExploreCollectResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerDay = 86_400;
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerDay) {
|
||||
const days = Math.floor(seconds / secondsPerDay);
|
||||
const remainingAfterDays = seconds % secondsPerDay;
|
||||
const hours = Math.floor(remainingAfterDays / secondsPerHour);
|
||||
return hours > 0
|
||||
? `${String(days)}d ${String(hours)}h`
|
||||
: `${String(days)}d`;
|
||||
}
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainingAfterHours = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainingAfterHours / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the time remaining for an exploration in progress.
|
||||
* Uses endsAt (server-computed) when available to avoid client/server clock drift.
|
||||
* @param endsAt - The server-computed completion timestamp, if available.
|
||||
* @param startedAt - The timestamp when exploration started.
|
||||
* @param durationSeconds - The total duration in seconds.
|
||||
* @returns The remaining seconds.
|
||||
*/
|
||||
const timeRemaining = (
|
||||
endsAt: number | undefined,
|
||||
startedAt: number,
|
||||
durationSeconds: number,
|
||||
): number => {
|
||||
if (endsAt !== undefined) {
|
||||
return Math.max(0, (endsAt - Date.now()) / 1000);
|
||||
}
|
||||
const elapsed = (Date.now() - startedAt) / 1000;
|
||||
return Math.max(0, durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface CollectResult {
|
||||
areaId: string;
|
||||
response: GoddessExploreCollectResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the goddess exploration panel for managing divine area explorations.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessExplorationPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
startGoddessExploration,
|
||||
collectGoddessExploration,
|
||||
formatNumber,
|
||||
} = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||
return (
|
||||
sessionStorage.getItem("elysium_goddess_explore_zone")
|
||||
?? "goddess_celestial_garden"
|
||||
);
|
||||
});
|
||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||
const [ claimableAreaIds, setClaimableAreaIds ]
|
||||
= useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const stateReference = useRef(state);
|
||||
stateReference.current = state;
|
||||
|
||||
const claimableReference = useRef(claimableAreaIds);
|
||||
claimableReference.current = claimableAreaIds;
|
||||
|
||||
useEffect(() => {
|
||||
const pollClaimable = async(): Promise<void> => {
|
||||
const currentState = stateReference.current;
|
||||
if (currentState === null) {
|
||||
return;
|
||||
}
|
||||
const inProgressArea = currentState.goddess?.exploration.areas.find(
|
||||
(a) => {
|
||||
return a.status === "in_progress";
|
||||
},
|
||||
);
|
||||
if (inProgressArea === undefined) {
|
||||
return;
|
||||
}
|
||||
if (claimableReference.current.has(inProgressArea.id)) {
|
||||
return;
|
||||
}
|
||||
const areaData = GODDESS_EXPLORATION_AREAS.find((a) => {
|
||||
return a.id === inProgressArea.id;
|
||||
});
|
||||
if (areaData === undefined) {
|
||||
return;
|
||||
}
|
||||
const remaining = timeRemaining(
|
||||
inProgressArea.endsAt,
|
||||
inProgressArea.startedAt ?? 0,
|
||||
areaData.durationSeconds,
|
||||
);
|
||||
if (remaining > 0) {
|
||||
return;
|
||||
}
|
||||
const result: GoddessExploreClaimableResponse
|
||||
= await checkGoddessExplorationClaimable(inProgressArea.id);
|
||||
if (result.claimable) {
|
||||
setClaimableAreaIds((previous) => {
|
||||
return new Set([ ...previous, inProgressArea.id ]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void pollClaimable();
|
||||
}, 1000);
|
||||
|
||||
return (): void => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
const explorationState = goddess?.exploration;
|
||||
const goddessZones = goddess?.zones ?? [];
|
||||
|
||||
const activeZone = goddessZones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
const zoneIsLocked = activeZone?.status === "locked";
|
||||
|
||||
const zoneAreas = GODDESS_EXPLORATION_AREAS.filter((area) => {
|
||||
return area.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const hasActiveExploration
|
||||
= explorationState?.areas.some((area) => {
|
||||
return area.status === "in_progress";
|
||||
}) ?? false;
|
||||
|
||||
async function handleStart(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
await startGoddessExploration(areaId);
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCollect(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
const result = await collectGoddessExploration(areaId);
|
||||
setLastResult({ areaId: areaId, response: result });
|
||||
setClaimableAreaIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(areaId);
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissResult(): void {
|
||||
setLastResult(null);
|
||||
}
|
||||
|
||||
function handleZoneSelect(id: string): void {
|
||||
setActiveZoneId(id);
|
||||
setLastResult(null);
|
||||
sessionStorage.setItem("elysium_goddess_explore_zone", id);
|
||||
}
|
||||
|
||||
const prayersChange = lastResult?.response.event?.prayersChange ?? 0;
|
||||
const discipleLostCount
|
||||
= lastResult?.response.event?.discipleLostCount ?? 0;
|
||||
|
||||
return (
|
||||
<section className="panel exploration-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🗺️ Divine Exploration"}</h2>
|
||||
</div>
|
||||
|
||||
{lastResult === null
|
||||
? null
|
||||
: <div className="exploration-result">
|
||||
<button
|
||||
className="exploration-result-close"
|
||||
onClick={handleDismissResult}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
{lastResult.response.foundNothing
|
||||
? <p className="exploration-nothing">
|
||||
{lastResult.response.nothingMessage}
|
||||
</p>
|
||||
: <>
|
||||
{lastResult.response.event === null
|
||||
? null
|
||||
: <p className="exploration-event-text">
|
||||
{lastResult.response.event.text}
|
||||
</p>
|
||||
}
|
||||
<div className="exploration-rewards">
|
||||
{prayersChange !== 0
|
||||
&& <span
|
||||
className={`reward-tag ${prayersChange > 0
|
||||
? ""
|
||||
: "negative"}`}
|
||||
>
|
||||
{"🙏 "}
|
||||
{prayersChange > 0
|
||||
? "+"
|
||||
: ""}
|
||||
{formatNumber(prayersChange)}
|
||||
{" prayers"}
|
||||
</span>
|
||||
}
|
||||
{discipleLostCount > 0
|
||||
&& <span className="reward-tag negative">
|
||||
{"👤 -"}
|
||||
{formatNumber(discipleLostCount)}
|
||||
{" disciples lost"}
|
||||
</span>
|
||||
}
|
||||
{lastResult.response.event?.materialGained !== null
|
||||
&& lastResult.response.event?.materialGained !== undefined
|
||||
? <span className="reward-tag material-tag">
|
||||
{"📦 +"}
|
||||
{lastResult.response.event.materialGained.quantity}{" "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */}
|
||||
{lastResult.response.event.materialGained.materialId.replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
)}
|
||||
{" (event)"}
|
||||
</span>
|
||||
: null}
|
||||
{lastResult.response.materialsFound.map((foundMaterial) => {
|
||||
return (
|
||||
<span
|
||||
className="reward-tag material-tag"
|
||||
key={foundMaterial.materialId}
|
||||
>
|
||||
{"📦 +"}
|
||||
{foundMaterial.quantity}{" "}
|
||||
{foundMaterial.materialId.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="zone-selector">
|
||||
{goddessZones.map((zone) => {
|
||||
const isLocked = zone.status === "locked";
|
||||
|
||||
function handleZoneClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
activeZoneId === zone.id
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
} ${isLocked
|
||||
? "zone-tab-locked"
|
||||
: ""}`}
|
||||
disabled={isLocked}
|
||||
key={zone.id}
|
||||
onClick={handleZoneClick}
|
||||
title={isLocked
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{zoneIsLocked
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This divine zone is locked."}</p>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<div className="exploration-list">
|
||||
{zoneAreas.map((area) => {
|
||||
const areaState = explorationState?.areas.find(
|
||||
(explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
},
|
||||
);
|
||||
const status = areaState?.status ?? "locked";
|
||||
const startedAt = areaState?.startedAt ?? 0;
|
||||
const endsAt = areaState?.endsAt;
|
||||
const isReady
|
||||
= status === "in_progress"
|
||||
&& claimableAreaIds.has(area.id);
|
||||
const isPending = pendingAreaId === area.id;
|
||||
|
||||
function handleStartClick(): void {
|
||||
void handleStart(area.id);
|
||||
}
|
||||
function handleCollectClick(): void {
|
||||
void handleCollect(area.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`exploration-card exploration-${status}`}
|
||||
key={area.id}
|
||||
>
|
||||
<img
|
||||
alt={area.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("explorations", area.id)}
|
||||
/>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
{areaState?.completedOnce === true
|
||||
? <span className="exploration-discovered">{" 📖"}</span>
|
||||
: null}
|
||||
</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 || hasActiveExploration}
|
||||
onClick={handleStartClick}
|
||||
title={
|
||||
hasActiveExploration
|
||||
? "A divine exploration is already in progress"
|
||||
: undefined
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Departing..."
|
||||
: `Explore (${formatDuration(area.durationSeconds)})`}
|
||||
</button>
|
||||
}
|
||||
{status === "in_progress" && !isReady
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
|
||||
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{status === "in_progress" && isReady
|
||||
? <button
|
||||
className="collect-button"
|
||||
disabled={isPending}
|
||||
onClick={handleCollectClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Collecting..."
|
||||
: "📦 Collect Results"}
|
||||
</button>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zoneAreas.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No exploration areas in this zone."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessExplorationPanel };
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* @file Read-only panel displaying goddess quests grouped by zone.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type {
|
||||
GoddessQuest,
|
||||
GoddessQuestReward,
|
||||
GoddessZone,
|
||||
} from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainderSeconds = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainderSeconds / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a human-readable label string for a goddess quest reward.
|
||||
* @param reward - The reward to describe.
|
||||
* @param formatNumber - The number formatter function.
|
||||
* @returns The label string, or an empty string for unknown types.
|
||||
*/
|
||||
const getRewardLabel = (
|
||||
reward: GoddessQuestReward,
|
||||
formatNumber: (value: number)=> string,
|
||||
): string => {
|
||||
if (reward.type === "prayers") {
|
||||
return `🙏 ${formatNumber(reward.amount ?? 0)} Prayers`;
|
||||
}
|
||||
if (reward.type === "divinity") {
|
||||
return `✨ ${formatNumber(reward.amount ?? 0)} Divinity`;
|
||||
}
|
||||
if (reward.type === "stardust") {
|
||||
return `⭐ ${formatNumber(reward.amount ?? 0)} Stardust`;
|
||||
}
|
||||
if (reward.type === "upgrade") {
|
||||
return "🔓 Upgrade Unlocked";
|
||||
}
|
||||
if (reward.type === "disciple") {
|
||||
return "👤 New Disciple Tier";
|
||||
}
|
||||
return "🛡️ Equipment Unlocked";
|
||||
};
|
||||
|
||||
interface GoddessQuestCardProperties {
|
||||
readonly quest: GoddessQuest;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly zoneIsOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single goddess quest card (read-only).
|
||||
* @param props - The component properties.
|
||||
* @param props.quest - The goddess quest to display.
|
||||
* @param props.unlockHint - The name of the prerequisite quest, if locked.
|
||||
* @param props.zoneIsOpen - Whether the quest's zone is currently unlocked.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessQuestCard = ({
|
||||
quest,
|
||||
unlockHint,
|
||||
zoneIsOpen,
|
||||
}: GoddessQuestCardProperties): JSX.Element => {
|
||||
const { formatNumber } = useGame();
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
<p className="quest-duration">
|
||||
{"⏱ "}
|
||||
{formatDuration(quest.durationSeconds)}
|
||||
</p>
|
||||
<div className="quest-rewards">
|
||||
{quest.rewards.map((reward, rewardIndex) => {
|
||||
return <span
|
||||
className="reward-tag"
|
||||
key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}
|
||||
>
|
||||
{getRewardLabel(reward, formatNumber)}
|
||||
</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quest-action">
|
||||
{quest.status === "locked" && !zoneIsOpen
|
||||
&& <span className="quest-badge locked">{"🔒 Zone Locked"}</span>
|
||||
}
|
||||
{quest.status === "locked" && zoneIsOpen
|
||||
? <>
|
||||
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
{unlockHint !== undefined
|
||||
&& <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{quest.status === "available"
|
||||
&& <span className="quest-badge available">{"📋 Available"}</span>
|
||||
}
|
||||
{quest.status === "active"
|
||||
&& <span className="quest-badge active">{"⏳ In Progress"}</span>
|
||||
}
|
||||
{quest.status === "completed"
|
||||
&& <span className="quest-badge completed">{"✅ Completed"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the goddess quests panel with zone selection and quest list.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessQuestsPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||
return sessionStorage.getItem("elysium_goddess_quest_zone")
|
||||
?? "goddess_celestial_garden";
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const goddessState = state.goddess;
|
||||
if (goddessState === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Goddess expansion not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, quests } = goddessState;
|
||||
|
||||
const activeZone = zones.find((zone: GoddessZone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
const zoneIsOpen = activeZone?.status === "unlocked";
|
||||
|
||||
const zoneQuests = quests.filter((quest: GoddessQuest) => {
|
||||
return quest.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const questNameById = new Map(
|
||||
quests.map((quest: GoddessQuest) => {
|
||||
return [ quest.id, quest.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
const getUnlockHint = (quest: GoddessQuest): string | undefined => {
|
||||
if (quest.status !== "locked" || quest.prerequisiteIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const [ prereqId ] = quest.prerequisiteIds;
|
||||
if (prereqId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return questNameById.get(prereqId);
|
||||
};
|
||||
|
||||
function handleZoneSelect(zoneId: string): void {
|
||||
setActiveZoneId(zoneId);
|
||||
sessionStorage.setItem("elysium_goddess_quest_zone", zoneId);
|
||||
}
|
||||
|
||||
const completedCount = zoneQuests.filter((quest: GoddessQuest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<section className="panel goddess-quests-panel">
|
||||
<h2>{"Goddess Quests"}</h2>
|
||||
<div className="zone-filter-buttons">
|
||||
{zones.map((zone: GoddessZone) => {
|
||||
function handleClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
return <button
|
||||
className={`zone-filter-button ${zone.id === activeZoneId
|
||||
? "active"
|
||||
: ""} ${zone.status === "locked"
|
||||
? "zone-locked"
|
||||
: ""}`}
|
||||
key={zone.id}
|
||||
onClick={handleClick}
|
||||
title={zone.status === "locked"
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.emoji}
|
||||
{" "}
|
||||
{zone.name}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
{activeZone !== undefined
|
||||
&& <div className="zone-info">
|
||||
<p className="zone-description">{activeZone.description}</p>
|
||||
<p className="zone-progress">
|
||||
{String(completedCount)}
|
||||
{" / "}
|
||||
{String(zoneQuests.length)}
|
||||
{" quests completed"}
|
||||
</p>
|
||||
{activeZone.status === "locked"
|
||||
&& <p className="zone-locked-notice">
|
||||
{"🔒 This zone is locked. Defeat the required goddess boss"}
|
||||
{" to unlock it."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div className="quest-list">
|
||||
{zoneQuests.length === 0
|
||||
? <p className="empty-state">{"No quests in this zone."}</p>
|
||||
: zoneQuests.map((quest: GoddessQuest) => {
|
||||
return <GoddessQuestCard
|
||||
key={quest.id}
|
||||
quest={quest}
|
||||
unlockHint={getUnlockHint(quest)}
|
||||
zoneIsOpen={zoneIsOpen}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessQuestsPanel };
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @file Goddess upgrades panel for purchasing goddess-realm upgrades.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Upgrade card has inherently high branching across categories */
|
||||
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { GoddessUpgrade } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* Formats a goddess upgrade cost as a readable string.
|
||||
* @param upgrade - The goddess upgrade.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns The formatted cost string.
|
||||
*/
|
||||
const costLabel = (
|
||||
upgrade: GoddessUpgrade,
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (upgrade.costPrayers > 0) {
|
||||
parts.push(`🙏 ${formatNumber(upgrade.costPrayers)}`);
|
||||
}
|
||||
if (upgrade.costDivinity > 0) {
|
||||
parts.push(`✨ ${formatNumber(upgrade.costDivinity)}`);
|
||||
}
|
||||
if (upgrade.costStardust > 0) {
|
||||
parts.push(`⭐ ${formatNumber(upgrade.costStardust)}`);
|
||||
}
|
||||
return parts.length > 0
|
||||
? parts.join(" ")
|
||||
: "Free";
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a human-readable label for a goddess upgrade target.
|
||||
* @param target - The upgrade target string.
|
||||
* @returns The display label.
|
||||
*/
|
||||
const targetLabel = (target: GoddessUpgrade["target"]): string => {
|
||||
const labels: Record<GoddessUpgrade["target"], string> = {
|
||||
boss: "Boss",
|
||||
consecration: "Consecration",
|
||||
disciple: "Disciple",
|
||||
global: "Global",
|
||||
prayers: "Prayers",
|
||||
};
|
||||
return labels[target];
|
||||
};
|
||||
|
||||
interface GoddessUpgradeCardProperties {
|
||||
readonly upgrade: GoddessUpgrade;
|
||||
readonly prayers: number;
|
||||
readonly divinity: number;
|
||||
readonly stardust: number;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single goddess upgrade card.
|
||||
* @param props - The card properties.
|
||||
* @param props.upgrade - The goddess upgrade data.
|
||||
* @param props.prayers - The player's current prayers balance.
|
||||
* @param props.divinity - The player's current divinity balance.
|
||||
* @param props.stardust - The player's current stardust balance.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessUpgradeCard = ({
|
||||
upgrade,
|
||||
prayers,
|
||||
divinity,
|
||||
stardust,
|
||||
formatNumber,
|
||||
}: GoddessUpgradeCardProperties): JSX.Element => {
|
||||
const { buyGoddessUpgrade } = useGame();
|
||||
|
||||
const canAfford
|
||||
= prayers >= upgrade.costPrayers
|
||||
&& divinity >= upgrade.costDivinity
|
||||
&& stardust >= upgrade.costStardust;
|
||||
|
||||
async function handleBuy(): Promise<void> {
|
||||
await buyGoddessUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
const multiplierPct = Math.round((upgrade.multiplier - 1) * 100);
|
||||
|
||||
if (upgrade.purchased) {
|
||||
return (
|
||||
<div className="goddess-upgrade-card purchased">
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">
|
||||
{"✅ "}
|
||||
{upgrade.name}
|
||||
</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{upgrade.description}</p>
|
||||
<p className="upgrade-effect">{`×${String(upgrade.multiplier)} (+${String(multiplierPct)}%)`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked) {
|
||||
return (
|
||||
<div className={`goddess-upgrade-card available${canAfford
|
||||
? ""
|
||||
: " cannot-afford"}`}>
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">{upgrade.name}</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{upgrade.description}</p>
|
||||
<p className="upgrade-effect">{`×${String(upgrade.multiplier)} (+${String(multiplierPct)}%)`}</p>
|
||||
{upgrade.discipleId === undefined
|
||||
? null
|
||||
: <p className="upgrade-disciple">
|
||||
{"🧎 Disciple: "}
|
||||
{upgrade.discipleId}
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
className="btn-buy"
|
||||
disabled={!canAfford}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- intentional async handler
|
||||
onClick={handleBuy}
|
||||
title={canAfford
|
||||
? ""
|
||||
: "Not enough resources"}
|
||||
type="button"
|
||||
>
|
||||
{"Buy — "}
|
||||
{costLabel(upgrade, formatNumber)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="goddess-upgrade-card locked">
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">{"🔒 ???"}</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{"Not yet unlocked."}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the goddess upgrades panel, displaying all available and purchased upgrades.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
export const GoddessUpgradesPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return <div className="panel"><p>{"Loading..."}</p></div>;
|
||||
}
|
||||
|
||||
const prayers = state.resources.prayers ?? 0;
|
||||
const divinity = state.resources.divinity ?? 0;
|
||||
const stardust = state.resources.stardust ?? 0;
|
||||
|
||||
const upgrades = state.goddess?.upgrades ?? [];
|
||||
|
||||
const purchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
});
|
||||
const available = upgrades.filter((upgrade) => {
|
||||
return upgrade.unlocked && !upgrade.purchased;
|
||||
});
|
||||
const locked = upgrades.filter((upgrade) => {
|
||||
return !upgrade.unlocked && !upgrade.purchased;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="goddess-upgrades-panel">
|
||||
<div className="panel-resource-bar">
|
||||
<span className="resource-item">
|
||||
{"🙏 Prayers: "}
|
||||
{formatNumber(prayers)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"✨ Divinity: "}
|
||||
{formatNumber(divinity)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"⭐ Stardust: "}
|
||||
{formatNumber(stardust)}
|
||||
</span>
|
||||
</div>
|
||||
{available.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Available Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{available.map((upgrade) => {
|
||||
return (
|
||||
<GoddessUpgradeCard
|
||||
divinity={divinity}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
prayers={prayers}
|
||||
stardust={stardust}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{locked.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Locked Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{locked.map((upgrade) => {
|
||||
return (
|
||||
<GoddessUpgradeCard
|
||||
divinity={divinity}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
prayers={prayers}
|
||||
stardust={stardust}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{purchased.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Purchased Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{purchased.map((upgrade) => {
|
||||
return (
|
||||
<GoddessUpgradeCard
|
||||
divinity={divinity}
|
||||
formatNumber={formatNumber}
|
||||
key={upgrade.id}
|
||||
prayers={prayers}
|
||||
stardust={stardust}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{upgrades.length === 0
|
||||
? <p className="empty-state">{"No goddess upgrades available yet."}</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file Goddess Zones panel — read-only view of all divine realms.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex panel with zone grid rendering */
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { GoddessZone } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ZoneCardProperties {
|
||||
readonly zone: GoddessZone;
|
||||
readonly isLocked: boolean;
|
||||
readonly unlockBossName: string | undefined;
|
||||
readonly unlockQuestName: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single goddess zone card.
|
||||
* @param props - The zone card properties.
|
||||
* @param props.zone - The zone data.
|
||||
* @param props.isLocked - Whether this zone is currently locked.
|
||||
* @param props.unlockBossName - Name of the boss required to unlock, if any.
|
||||
* @param props.unlockQuestName - Name of the quest required to unlock, if any.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessZoneCard = ({
|
||||
zone,
|
||||
isLocked,
|
||||
unlockBossName,
|
||||
unlockQuestName,
|
||||
}: ZoneCardProperties): JSX.Element => {
|
||||
return (
|
||||
<div className={`zone-card${isLocked
|
||||
? " locked"
|
||||
: ""}`}>
|
||||
<div className="zone-card-header">
|
||||
<span aria-hidden="true" className="zone-emoji">{zone.emoji}</span>
|
||||
<h3 className="zone-name">{zone.name}</h3>
|
||||
{isLocked
|
||||
? <span aria-label="Locked" className="zone-lock-icon">{"🔒"}</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<p className="zone-description">{zone.description}</p>
|
||||
|
||||
{isLocked
|
||||
&& (unlockBossName !== undefined || unlockQuestName !== undefined)
|
||||
? <div className="zone-unlock-requirements">
|
||||
<p className="zone-unlock-label">{"Unlock requirements:"}</p>
|
||||
{unlockBossName === undefined
|
||||
? null
|
||||
: <p className="zone-unlock-item">
|
||||
{"⚔️ Defeat: "}
|
||||
{unlockBossName}
|
||||
</p>
|
||||
}
|
||||
{unlockQuestName === undefined
|
||||
? null
|
||||
: <p className="zone-unlock-item">
|
||||
{"📜 Complete: "}
|
||||
{unlockQuestName}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
{isLocked
|
||||
? null
|
||||
: <span className="zone-badge unlocked">{"✨ Unlocked"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the Goddess Zones panel showing all 18 divine realms.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessZonesPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses: goddessBosses, quests: goddessQuests, zones } = goddess;
|
||||
|
||||
const defeatedBossIds = new Set(
|
||||
goddessBosses.
|
||||
filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).
|
||||
map((boss) => {
|
||||
return boss.id;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="panel goddess-zones-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🌟 Goddess Zones"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-grid">
|
||||
{zones.map((zone) => {
|
||||
const isLocked = zone.unlockBossId !== null
|
||||
&& !defeatedBossIds.has(zone.unlockBossId);
|
||||
|
||||
const unlockBoss = zone.unlockBossId === null
|
||||
? undefined
|
||||
: goddessBosses.find((boss) => {
|
||||
return boss.id === zone.unlockBossId;
|
||||
});
|
||||
|
||||
const unlockQuest = zone.unlockQuestId === null
|
||||
? undefined
|
||||
: goddessQuests.find((quest) => {
|
||||
return quest.id === zone.unlockQuestId;
|
||||
});
|
||||
|
||||
return (
|
||||
<GoddessZoneCard
|
||||
isLocked={isLocked}
|
||||
key={zone.id}
|
||||
unlockBossName={unlockBoss?.name}
|
||||
unlockQuestName={unlockQuest?.name}
|
||||
zone={zone}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessZonesPanel };
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @file Vampire achievements panel component displaying all vampire expansion achievements.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||
/* eslint-disable max-lines-per-function -- Achievement panel renders many achievement states */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { VampireAchievement, VampireState } from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Returns the plural form of a word based on a count.
|
||||
* @param count - The count to check.
|
||||
* @param word - The base word to pluralise.
|
||||
* @returns The pluralised word string.
|
||||
*/
|
||||
const pluralise = (count: number, word: string): string => {
|
||||
return count > 1
|
||||
? `${word}s`
|
||||
: word;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a human-readable condition description for a vampire achievement.
|
||||
* @param achievement - The vampire achievement to describe.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns A string describing the achievement condition.
|
||||
*/
|
||||
const conditionDescription = (
|
||||
achievement: VampireAchievement,
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalBloodEarned":
|
||||
return `Earn ${formatNumber(condition.amount)} total blood`;
|
||||
case "vampireBossesDefeated":
|
||||
return `Defeat ${String(condition.amount)} vampire ${pluralise(condition.amount, "boss")}`;
|
||||
case "vampireQuestsCompleted":
|
||||
return `Complete ${String(condition.amount)} vampire ${pluralise(condition.amount, "quest")}`;
|
||||
case "thrallTotal":
|
||||
return `Recruit ${formatNumber(condition.amount)} total ${pluralise(condition.amount, "thrall")}`;
|
||||
case "siringCount":
|
||||
return `Sire ${String(condition.amount)} ${pluralise(condition.amount, "time")}`;
|
||||
case "vampireEquipmentOwned":
|
||||
return `Own ${String(condition.amount)} vampire equipment ${pluralise(condition.amount, "item")}`;
|
||||
default:
|
||||
return "Unknown condition";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the player's current progress value toward a vampire achievement's unlock condition.
|
||||
* @param achievement - The achievement to evaluate progress for.
|
||||
* @param vampire - The current vampire state.
|
||||
* @returns The current numeric progress toward the achievement condition.
|
||||
*/
|
||||
const getCurrentProgress = (
|
||||
achievement: VampireAchievement,
|
||||
vampire: VampireState,
|
||||
): number => {
|
||||
const { condition } = achievement;
|
||||
switch (condition.type) {
|
||||
case "totalBloodEarned":
|
||||
return vampire.lifetimeBloodEarned;
|
||||
case "vampireBossesDefeated":
|
||||
return vampire.lifetimeBossesDefeated;
|
||||
case "vampireQuestsCompleted":
|
||||
return vampire.lifetimeQuestsCompleted;
|
||||
case "thrallTotal":
|
||||
return vampire.thralls.reduce((sum, thrall) => {
|
||||
return sum + thrall.count;
|
||||
}, 0);
|
||||
case "siringCount":
|
||||
return vampire.siring.count;
|
||||
case "vampireEquipmentOwned":
|
||||
return vampire.equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
interface VampireAchievementCardProperties {
|
||||
readonly achievement: VampireAchievement;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly progressValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single vampire achievement card.
|
||||
* @param props - The achievement card properties.
|
||||
* @param props.achievement - The achievement to display.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.progressValue - The player's current progress toward the unlock condition.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireAchievementCard = ({
|
||||
achievement,
|
||||
formatNumber,
|
||||
progressValue,
|
||||
}: VampireAchievementCardProperties): JSX.Element => {
|
||||
const isUnlocked = achievement.unlockedAt !== null;
|
||||
const cappedProgress = Math.min(progressValue, achievement.condition.amount);
|
||||
|
||||
return (
|
||||
<div className={`achievement-card ${isUnlocked
|
||||
? "unlocked"
|
||||
: "locked"}`}>
|
||||
<div className="achievement-icon">
|
||||
<span className="achievement-emoji">{achievement.icon}</span>
|
||||
</div>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
<p className="achievement-condition">
|
||||
{conditionDescription(achievement, formatNumber)}
|
||||
</p>
|
||||
{!isUnlocked
|
||||
&& <div className="achievement-progress">
|
||||
<progress
|
||||
max={achievement.condition.amount}
|
||||
value={cappedProgress}
|
||||
/>
|
||||
<span className="achievement-progress-label">
|
||||
{formatNumber(progressValue)}
|
||||
{" / "}
|
||||
{formatNumber(achievement.condition.amount)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{achievement.reward !== undefined
|
||||
&& <div className="achievement-reward">
|
||||
{achievement.reward.ichor !== undefined
|
||||
&& <p>
|
||||
{"💧 +"}
|
||||
{achievement.reward.ichor}
|
||||
{" Ichor"}
|
||||
</p>
|
||||
}
|
||||
{achievement.reward.soulShards !== undefined
|
||||
&& <p>
|
||||
{"💠 +"}
|
||||
{achievement.reward.soulShards}
|
||||
{" Soul Shards"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="achievement-status">
|
||||
{isUnlocked
|
||||
? <>
|
||||
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
|
||||
{achievement.unlockedAt !== null
|
||||
&& <span className="achievement-unlocked-at">
|
||||
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
</>
|
||||
: <span className="achievement-locked-badge">{"🔒"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the vampire achievements panel with all vampire expansion achievements.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireAchievementsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const achievementList = vampire.achievements;
|
||||
const unlocked = achievementList.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
});
|
||||
const locked = achievementList.filter((achievement) => {
|
||||
return achievement.unlockedAt === null;
|
||||
});
|
||||
const visible = showLocked
|
||||
? achievementList
|
||||
: unlocked;
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel achievement-panel vampire-achievements-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🩸 Vampire Achievements"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="achievement-progress">
|
||||
{unlocked.length}
|
||||
{" / "}
|
||||
{achievementList.length}
|
||||
{" unlocked"}
|
||||
</p>
|
||||
<div className="achievement-list">
|
||||
{visible.map((achievement) => {
|
||||
return (
|
||||
<VampireAchievementCard
|
||||
achievement={achievement}
|
||||
formatNumber={formatNumber}
|
||||
key={achievement.id}
|
||||
progressValue={getCurrentProgress(achievement, vampire)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireAchievementsPanel };
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* @file Awakening panel component for vampire meta-reset and soul shards upgrade shop.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-lines -- Large panel with awakening and shop tabs */
|
||||
/* eslint-disable max-statements -- Awakening panel manages many local state variables */
|
||||
/* eslint-disable stylistic/max-len -- Data content with long description strings */
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- SCREAMING_SNAKE_CASE is conventional for module-level data constants */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { AwakeningUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const finalVampireBossId = "eternal_darkness";
|
||||
|
||||
/**
|
||||
* Calculates the projected soul shards yield from an awakening.
|
||||
* Mirrors the server formula: MAX(1, FLOOR(SQRT(siringCount) * metaMultiplier)).
|
||||
* @param siringCount - The number of sirings completed before this awakening.
|
||||
* @param metaMultiplier - Multiplier from prior awakening upgrades applied to soul shards yield.
|
||||
* @returns The projected soul shards earned.
|
||||
*/
|
||||
const calculateSoulShardsYield = (
|
||||
siringCount: number,
|
||||
metaMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(1, Math.floor(Math.sqrt(siringCount) * metaMultiplier));
|
||||
};
|
||||
|
||||
const AWAKENING_UPGRADES: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: AwakeningUpgradeCategory;
|
||||
cost: number;
|
||||
multiplier: number;
|
||||
}> = [
|
||||
{
|
||||
category: "blood",
|
||||
cost: 10,
|
||||
description: "The awakened soul's hunger amplifies all blood income. All blood/s ×1.5.",
|
||||
id: "awakening_blood_1",
|
||||
multiplier: 1.5,
|
||||
name: "Soul Hunger I",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
cost: 50,
|
||||
description: "A second awakening sharpens the soul's drive to consume. All blood/s ×2.",
|
||||
id: "awakening_blood_2",
|
||||
multiplier: 2,
|
||||
name: "Soul Hunger II",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
cost: 200,
|
||||
description: "The awakened soul transcends ordinary hunger — all blood income triples. All blood/s ×3.",
|
||||
id: "awakening_blood_3",
|
||||
multiplier: 3,
|
||||
name: "Soul Hunger III",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 15,
|
||||
description: "The awakened soul's predatory edge carries through every thrall. All thrall combat power ×1.5.",
|
||||
id: "awakening_combat_1",
|
||||
multiplier: 1.5,
|
||||
name: "Awakened Predator I",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 75,
|
||||
description: "Soul shards resonate with battle instinct — combat power doubles. All thrall combat power ×2.",
|
||||
id: "awakening_combat_2",
|
||||
multiplier: 2,
|
||||
name: "Awakened Predator II",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
cost: 300,
|
||||
description: "Apex awakened combat mastery triples every thrall's fighting power. All thrall combat power ×3.",
|
||||
id: "awakening_combat_3",
|
||||
multiplier: 3,
|
||||
name: "Awakened Predator III",
|
||||
},
|
||||
{
|
||||
category: "siring_threshold",
|
||||
cost: 30,
|
||||
description: "Soul shards carry the memory of past sirings — the threshold lowers by 15%.",
|
||||
id: "awakening_threshold_1",
|
||||
multiplier: 0.85,
|
||||
name: "Soul Memory I",
|
||||
},
|
||||
{
|
||||
category: "siring_threshold",
|
||||
cost: 120,
|
||||
description: "The awakened soul remembers every siring — the threshold drops by a further 20%.",
|
||||
id: "awakening_threshold_2",
|
||||
multiplier: 0.8,
|
||||
name: "Soul Memory II",
|
||||
},
|
||||
{
|
||||
category: "siring_threshold",
|
||||
cost: 480,
|
||||
description: "Perfect soul memory collapses the siring threshold to a fraction of its original. Threshold ×0.7.",
|
||||
id: "awakening_threshold_3",
|
||||
multiplier: 0.7,
|
||||
name: "Soul Memory III",
|
||||
},
|
||||
{
|
||||
category: "siring_ichor",
|
||||
cost: 25,
|
||||
description: "Soul shards amplify the ichor extracted during each siring. Ichor per siring ×1.5.",
|
||||
id: "awakening_siring_ichor_1",
|
||||
multiplier: 1.5,
|
||||
name: "Ichor Resonance I",
|
||||
},
|
||||
{
|
||||
category: "siring_ichor",
|
||||
cost: 100,
|
||||
description: "The resonance deepens — siring yields twice the ichor. Ichor per siring ×2.",
|
||||
id: "awakening_siring_ichor_2",
|
||||
multiplier: 2,
|
||||
name: "Ichor Resonance II",
|
||||
},
|
||||
{
|
||||
category: "siring_ichor",
|
||||
cost: 400,
|
||||
description: "Peak resonance — each siring now yields three times the ichor. Ichor per siring ×3.",
|
||||
id: "awakening_siring_ichor_3",
|
||||
multiplier: 3,
|
||||
name: "Ichor Resonance III",
|
||||
},
|
||||
{
|
||||
category: "soulshards_meta",
|
||||
cost: 60,
|
||||
description: "The soul refines itself — future awakenings yield 50% more soul shards.",
|
||||
id: "awakening_meta_1",
|
||||
multiplier: 1.5,
|
||||
name: "Soul Refinement I",
|
||||
},
|
||||
{
|
||||
category: "soulshards_meta",
|
||||
cost: 250,
|
||||
description: "The awakened soul's self-improvement compounds — soul shard yields double.",
|
||||
id: "awakening_meta_2",
|
||||
multiplier: 2,
|
||||
name: "Soul Refinement II",
|
||||
},
|
||||
{
|
||||
category: "soulshards_meta",
|
||||
cost: 1000,
|
||||
description: "The apex of soul refinement — all future awakenings yield three times the soul shards.",
|
||||
id: "awakening_meta_3",
|
||||
multiplier: 3,
|
||||
name: "Soul Refinement III",
|
||||
},
|
||||
];
|
||||
|
||||
const categoryOrder: Array<AwakeningUpgradeCategory> = [
|
||||
"blood",
|
||||
"combat",
|
||||
"siring_threshold",
|
||||
"siring_ichor",
|
||||
"soulshards_meta",
|
||||
];
|
||||
|
||||
const AWAKENING_UPGRADE_CATEGORY_LABELS: Record<AwakeningUpgradeCategory, string> = {
|
||||
blood: "🩸 Blood Multipliers",
|
||||
combat: "⚔️ Combat Multipliers",
|
||||
siring_ichor: "💧 Siring Quality of Life — Ichor Yield",
|
||||
siring_threshold: "🎯 Siring Quality of Life — Threshold",
|
||||
soulshards_meta: "💠 Soul Shards Meta Upgrades",
|
||||
};
|
||||
|
||||
type AwakeningTab = "awaken" | "shop";
|
||||
|
||||
/**
|
||||
* Renders the awakening panel with vampire meta-reset and soul shards shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireAwakeningPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reloadSilent,
|
||||
formatInteger,
|
||||
awaken,
|
||||
buyAwakeningUpgrade,
|
||||
} = useGame();
|
||||
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
soulShardsEarned: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ awakeningError, setAwakeningError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<AwakeningTab>("awaken");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { siring, awakening, bosses } = vampire;
|
||||
|
||||
const hasDefeatedFinalBoss = bosses.some((boss) => {
|
||||
return boss.id === finalVampireBossId && boss.status === "defeated";
|
||||
});
|
||||
|
||||
const metaMultiplier = awakening.soulShardsMetaMultiplier;
|
||||
const soulShardsPreview = calculateSoulShardsYield(siring.count, metaMultiplier);
|
||||
const currentSoulShards = awakening.soulShards;
|
||||
const awakeningCount = awakening.count;
|
||||
|
||||
async function handleAwaken(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setAwakeningError(null);
|
||||
try {
|
||||
const data = await awaken();
|
||||
setResult({
|
||||
count: data.newAwakeningCount,
|
||||
soulShardsEarned: data.soulShardsEarned,
|
||||
});
|
||||
await reloadSilent();
|
||||
} catch (error_: unknown) {
|
||||
setAwakeningError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Awakening failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyAwakeningUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((catId) => {
|
||||
const label = AWAKENING_UPGRADE_CATEGORY_LABELS[catId];
|
||||
const upgrades = AWAKENING_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === catId;
|
||||
});
|
||||
return { catId, label, upgrades };
|
||||
});
|
||||
|
||||
function handleAwakenClick(): void {
|
||||
void handleAwaken();
|
||||
}
|
||||
|
||||
function handleAwakenTabClick(): void {
|
||||
setActiveTab("awaken");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel enlightenment-panel">
|
||||
<h2>{"💀 Awakening"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "awaken"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleAwakenTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Awaken"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"💠 Soul Shards Shop ("}
|
||||
{formatInteger(currentSoulShards)}
|
||||
{" soul shards)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "awaken"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Awakening is the ultimate vampire reset. It wipes "}
|
||||
<strong>{"everything"}</strong>
|
||||
{" in the vampire realm — blood, sirings, thralls, and upgrades"
|
||||
+ " — but grants "}
|
||||
<strong>{"Soul Shards"}</strong>
|
||||
{", a permanent vampire currency that survives all future resets."
|
||||
+ " Soul Shards power upgrades that permanently amplify every vampire run."}
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
{"More sirings = more Soul Shards."}
|
||||
{" Optimise your vampire run for maximum yield!"}
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{awakeningCount > 0
|
||||
? <p>
|
||||
{"Awakening count: "}
|
||||
<strong>{awakeningCount}</strong>
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
<p>
|
||||
{"Current Soul Shards: "}
|
||||
<strong>{formatInteger(currentSoulShards)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current siring count: "}
|
||||
<strong>{siring.count}</strong>
|
||||
</p>
|
||||
{hasDefeatedFinalBoss
|
||||
? <p className="echo-preview">
|
||||
{"Soul Shards on awakening: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatInteger(soulShardsPreview)}
|
||||
</strong>
|
||||
{metaMultiplier > 1
|
||||
? <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{metaMultiplier.toFixed(2)}
|
||||
{" meta bonus applied)"}
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Defeat the Eternal Darkness"}</strong>
|
||||
{" to unlock Awakening."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"The Eternal Darkness is the final boss of the Vampire realm."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to achieve Awakening. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleAwakenClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Awakening..."
|
||||
: `💀 Awaken (+${formatInteger(soulShardsPreview)} Soul Shards)`}
|
||||
</button>
|
||||
{awakeningError === null
|
||||
? null
|
||||
: <p className="error">{awakeningError}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Awakening achieved! Earned "}
|
||||
<strong>
|
||||
{formatInteger(result.soulShardsEarned)}
|
||||
{" Soul Shards"}
|
||||
</strong>
|
||||
{". This is Awakening "}
|
||||
{result.count}
|
||||
{". A new soul cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatInteger(currentSoulShards)}
|
||||
{" Soul Shards"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Soul Shard upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive all future sirings and awakenings."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ catId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={catId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased
|
||||
= awakening.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = currentSoulShards >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `💠 ${formatInteger(upgrade.cost)} Soul Shards`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="upgrade-buy-button"
|
||||
disabled={!canAfford || isLoading}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireAwakeningPanel };
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* @file Vampire Boss panel — challenge vampire realm bosses.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Panel with sub-component, modal, and zone filter */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Boss card requires many conditional render paths */
|
||||
/* eslint-disable max-statements -- Panel requires many variable declarations */
|
||||
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to this panel */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type {
|
||||
VampireBoss,
|
||||
VampireBossChallengeResponse,
|
||||
VampireZone,
|
||||
} from "@elysium/types";
|
||||
|
||||
interface VampireBossCardProperties {
|
||||
readonly boss: VampireBoss;
|
||||
readonly onChallenge: (bossId: string)=> void;
|
||||
readonly isChallenging: boolean;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly formatInteger: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single vampire boss card.
|
||||
* @param props - The boss card properties.
|
||||
* @param props.boss - The boss data.
|
||||
* @param props.onChallenge - Callback to challenge this boss.
|
||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.formatInteger - The integer formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireBossCard = ({
|
||||
boss,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
}: VampireBossCardProperties): JSX.Element => {
|
||||
const canChallenge
|
||||
= (boss.status === "available" || boss.status === "in_progress")
|
||||
&& !isChallenging;
|
||||
|
||||
const hpRatio = boss.currentHp / boss.maxHp;
|
||||
const hpPercent = hpRatio * 100;
|
||||
|
||||
function handleChallenge(): void {
|
||||
onChallenge(boss.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{boss.status === "locked" && unlockHint !== undefined
|
||||
? <p className="unlock-hint">{unlockHint}</p>
|
||||
: null}
|
||||
{boss.siringRequirement > 0
|
||||
? <p className="consecration-requirement">
|
||||
{"🩸 Requires Siring "}
|
||||
{boss.siringRequirement}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated"
|
||||
? <div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)}
|
||||
{" / "}
|
||||
{formatNumber(boss.maxHp)}
|
||||
{" HP"}
|
||||
</span>
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">
|
||||
{"💢 Boss DPS: "}
|
||||
{formatNumber(boss.damagePerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
{boss.bloodReward > 0
|
||||
&& <span>
|
||||
{"🩸 "}
|
||||
{formatNumber(boss.bloodReward)}
|
||||
{" Blood"}
|
||||
</span>
|
||||
}
|
||||
{boss.ichorReward > 0
|
||||
&& <span>
|
||||
{"💧 "}
|
||||
{formatInteger(boss.ichorReward)}
|
||||
{" Ichor"}
|
||||
</span>
|
||||
}
|
||||
{boss.soulShardsReward > 0
|
||||
&& <span>
|
||||
{"💠 "}
|
||||
{formatInteger(boss.soulShardsReward)}
|
||||
{" Soul Shards"}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
&& <span>
|
||||
{"🦇 "}
|
||||
{boss.equipmentRewards.length}
|
||||
{" Equipment"}
|
||||
</span>
|
||||
}
|
||||
{boss.status !== "defeated"
|
||||
&& boss.bountyIchor > 0
|
||||
&& boss.bountyIchorClaimed !== true
|
||||
? <span className="boss-bounty">
|
||||
{"💧 "}
|
||||
{boss.bountyIchor}
|
||||
{" Ichor (first kill)"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status === "available" || boss.status === "in_progress"
|
||||
? <button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={handleChallenge}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging
|
||||
? "🩸 Hunting…"
|
||||
: "🩸 Challenge"}
|
||||
</button>
|
||||
: null}
|
||||
|
||||
{boss.status === "defeated"
|
||||
? <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface VampireBattleModalProperties {
|
||||
readonly result: VampireBossChallengeResponse;
|
||||
readonly onDismiss: ()=> void;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly formatInteger: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the vampire battle result modal overlay.
|
||||
* @param props - The modal properties.
|
||||
* @param props.result - The battle result data.
|
||||
* @param props.onDismiss - Callback to dismiss the modal.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.formatInteger - The integer formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireBattleModal = ({
|
||||
result,
|
||||
onDismiss,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
}: VampireBattleModalProperties): JSX.Element => {
|
||||
return (
|
||||
<div aria-modal="true" className="battle-modal-overlay" role="dialog">
|
||||
<div className="battle-modal">
|
||||
<h2 className="battle-modal-title">
|
||||
{result.won
|
||||
? "🩸 Victory!"
|
||||
: "💀 Defeated!"}
|
||||
</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"⚔️ Your Thrall DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"💢 Boss DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"❤️ Boss HP Before"}</span>
|
||||
<span className="stat-value">
|
||||
{formatNumber(result.bossHpBefore)}
|
||||
{" / "}
|
||||
{formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"❤️ Boss HP After"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossNewHp)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"🛡️ Thrall HP Remaining"}</span>
|
||||
<span className="stat-value">
|
||||
{formatNumber(result.partyHpRemaining)}
|
||||
{" / "}
|
||||
{formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.won && result.rewards !== undefined
|
||||
? <div className="battle-rewards">
|
||||
<h3>{"Rewards"}</h3>
|
||||
{result.rewards.blood > 0
|
||||
? <p>
|
||||
{"🩸 "}
|
||||
{formatNumber(result.rewards.blood)}
|
||||
{" Blood"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.ichor > 0
|
||||
? <p>
|
||||
{"💧 "}
|
||||
{formatInteger(result.rewards.ichor)}
|
||||
{" Ichor"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.soulShards > 0
|
||||
? <p>
|
||||
{"💠 "}
|
||||
{formatInteger(result.rewards.soulShards)}
|
||||
{" Soul Shards"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.bountyIchor > 0
|
||||
? <p className="bounty-reward">
|
||||
{"💧 "}
|
||||
{formatInteger(result.rewards.bountyIchor)}
|
||||
{" Ichor (first kill bonus!)"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.upgradeIds.length > 0
|
||||
? <p>
|
||||
{"🔓 "}
|
||||
{result.rewards.upgradeIds.length}
|
||||
{" Upgrade(s) unlocked"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.equipmentIds.length > 0
|
||||
? <p>
|
||||
{"🦇 "}
|
||||
{result.rewards.equipmentIds.length}
|
||||
{" Equipment item(s) gained"}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
{result.casualties !== undefined && result.casualties.length > 0
|
||||
? <div className="battle-casualties">
|
||||
<h3>{"Casualties"}</h3>
|
||||
{result.casualties.map((casualty) => {
|
||||
return (
|
||||
<p key={casualty.thrallId}>
|
||||
{casualty.killed}
|
||||
{" "}
|
||||
{casualty.thrallId}
|
||||
{" lost"}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Dismiss"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the Vampire Boss panel with zone filtering and battle result modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireBossPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
challengeVampireBoss,
|
||||
vampireBattleResult,
|
||||
dismissVampireBattle,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
} = useGame();
|
||||
|
||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ activeZoneId, setActiveZoneId ] = useState<string | null>(() => {
|
||||
return sessionStorage.getItem("elysium_vampire_boss_zone");
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, quests, zones } = vampire;
|
||||
|
||||
async function handleChallenge(bossId: string): Promise<void> {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeVampireBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChallengeClick(bossId: string): void {
|
||||
void handleChallenge(bossId);
|
||||
}
|
||||
|
||||
function handleZoneSelect(zoneId: string): void {
|
||||
setActiveZoneId(zoneId);
|
||||
sessionStorage.setItem("elysium_vampire_boss_zone", zoneId);
|
||||
}
|
||||
|
||||
function handleShowAll(): void {
|
||||
setActiveZoneId(null);
|
||||
sessionStorage.removeItem("elysium_vampire_boss_zone");
|
||||
}
|
||||
|
||||
const filteredBosses = activeZoneId === null
|
||||
? bosses
|
||||
: bosses.filter((boss) => {
|
||||
return boss.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const bossUnlockHints = new Map<string, string>();
|
||||
for (const zone of zones) {
|
||||
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
|
||||
const zoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === zoneId;
|
||||
});
|
||||
for (let index = 0; index < zoneBosses.length; index = index + 1) {
|
||||
const boss = zoneBosses[index];
|
||||
if (boss === undefined || boss.status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
if (index === 0) {
|
||||
const parts: Array<string> = [];
|
||||
if (unlockBossId !== null) {
|
||||
const gateBoss = bosses.find((candidate) => {
|
||||
return candidate.id === unlockBossId;
|
||||
});
|
||||
if (gateBoss !== undefined) {
|
||||
parts.push(`🩸 Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
}
|
||||
if (unlockQuestId !== null) {
|
||||
const gateQuest = quests.find((candidate) => {
|
||||
return candidate.id === unlockQuestId;
|
||||
});
|
||||
if (gateQuest !== undefined) {
|
||||
parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const previousBoss = zoneBosses[index - 1];
|
||||
if (previousBoss !== undefined) {
|
||||
bossUnlockHints.set(boss.id, `🩸 Defeat: ${previousBoss.name} first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeZoneData: VampireZone | undefined = activeZoneId === null
|
||||
? undefined
|
||||
: zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel vampire-boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🩸 Vampire Bosses"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-selector">
|
||||
<button
|
||||
className={`zone-tab${activeZoneId === null
|
||||
? " zone-tab-active"
|
||||
: ""}`}
|
||||
onClick={handleShowAll}
|
||||
type="button"
|
||||
>
|
||||
{"All Zones"}
|
||||
</button>
|
||||
{zones.map((zone) => {
|
||||
function handleSelect(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab${zone.id === activeZoneId
|
||||
? " zone-tab-active"
|
||||
: ""}`}
|
||||
key={zone.id}
|
||||
onClick={handleSelect}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeZoneData?.status === "locked"
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This zone is locked."}</p>
|
||||
{activeZoneData.unlockBossId === null
|
||||
? null
|
||||
: <p>
|
||||
{"🩸 Defeat: "}
|
||||
{bosses.find((boss) => {
|
||||
return boss.id === activeZoneData.unlockBossId;
|
||||
})?.name ?? activeZoneData.unlockBossId}
|
||||
</p>}
|
||||
{activeZoneData.unlockQuestId === null
|
||||
? null
|
||||
: <p>
|
||||
{"📜 Complete: "}
|
||||
{quests.find((quest) => {
|
||||
return quest.id === activeZoneData.unlockQuestId;
|
||||
})?.name ?? activeZoneData.unlockQuestId}
|
||||
</p>}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="boss-list">
|
||||
{filteredBosses.map((boss) => {
|
||||
return (
|
||||
<VampireBossCard
|
||||
boss={boss}
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === boss.id}
|
||||
key={boss.id}
|
||||
onChallenge={handleChallengeClick}
|
||||
unlockHint={bossUnlockHints.get(boss.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredBosses.length === 0
|
||||
? <p className="empty-zone">{"No bosses to show in this zone."}</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{vampireBattleResult === null
|
||||
? null
|
||||
: <VampireBattleModal
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
onDismiss={dismissVampireBattle}
|
||||
result={vampireBattleResult}
|
||||
/>}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireBossPanel };
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file Vampire crafting panel component for crafting recipes from dark materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable max-nested-callbacks -- Nested recipe/material maps require nesting */
|
||||
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { VAMPIRE_RECIPES } from "../../data/vampireCraftingRecipes.js";
|
||||
import { VAMPIRE_MATERIALS } from "../../data/vampireMaterials.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
|
||||
const bonusLabel: Record<string, string> = {
|
||||
combat_power: "⚔️ Thrall Combat",
|
||||
essence_income: "💧 Ichor Income",
|
||||
gold_income: "🩸 Blood Income",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the vampire crafting panel for crafting recipes from dark materials.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireCraftingPanel = (): JSX.Element => {
|
||||
const { state, craftVampireRecipe, formatNumber } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||
return (
|
||||
sessionStorage.getItem("elysium_vampire_craft_zone")
|
||||
?? "vampire_haunted_catacombs"
|
||||
);
|
||||
});
|
||||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const playerMaterials = vampire.exploration.materials;
|
||||
const craftedIds = vampire.exploration.craftedRecipeIds;
|
||||
const vampireZones = vampire.zones;
|
||||
|
||||
const zoneRecipes = VAMPIRE_RECIPES.filter((recipe) => {
|
||||
return recipe.zoneId === activeZoneId;
|
||||
});
|
||||
const zoneMaterials = VAMPIRE_MATERIALS.filter((material) => {
|
||||
return material.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
function getQuantity(materialId: string): number {
|
||||
return (
|
||||
playerMaterials.find((playerMaterial) => {
|
||||
return playerMaterial.materialId === materialId;
|
||||
})?.quantity ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function canAffordRecipe(recipeId: string): boolean {
|
||||
const recipe = VAMPIRE_RECIPES.find((candidateRecipe) => {
|
||||
return candidateRecipe.id === recipeId;
|
||||
});
|
||||
if (recipe === undefined) {
|
||||
return false;
|
||||
}
|
||||
return recipe.requiredMaterials.every((request) => {
|
||||
return getQuantity(request.materialId) >= request.quantity;
|
||||
});
|
||||
}
|
||||
|
||||
function handleZoneSelect(zoneId: string): void {
|
||||
setActiveZoneId(zoneId);
|
||||
sessionStorage.setItem("elysium_vampire_craft_zone", zoneId);
|
||||
}
|
||||
|
||||
async function handleCraft(recipeId: string): Promise<void> {
|
||||
setPendingRecipeId(recipeId);
|
||||
try {
|
||||
await craftVampireRecipe(recipeId);
|
||||
} finally {
|
||||
setPendingRecipeId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel crafting-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚗️ Dark Crafting"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-selector">
|
||||
{vampireZones.map((zone) => {
|
||||
const isLocked = zone.status === "locked";
|
||||
|
||||
function handleZoneClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
activeZoneId === zone.id
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
} ${isLocked
|
||||
? "zone-tab-locked"
|
||||
: ""}`}
|
||||
disabled={isLocked}
|
||||
key={zone.id}
|
||||
onClick={handleZoneClick}
|
||||
title={isLocked
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="crafting-content">
|
||||
<div className="materials-section">
|
||||
<h3>{"📦 Dark 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
|
||||
className={`material-card rarity-${material.rarity} ${
|
||||
qty === 0
|
||||
? "material-empty"
|
||||
: ""
|
||||
}`}
|
||||
key={material.id}
|
||||
>
|
||||
<img
|
||||
alt={material.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("materials", material.id)}
|
||||
/>
|
||||
<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>{"📜 Dark 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 = canAffordRecipe(recipe.id);
|
||||
const isPending = pendingRecipeId === recipe.id;
|
||||
|
||||
function handleCraftClick(): void {
|
||||
void handleCraft(recipe.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`recipe-card ${
|
||||
crafted
|
||||
? "recipe-crafted"
|
||||
: ""
|
||||
} ${!affordable && !crafted
|
||||
? "recipe-unaffordable"
|
||||
: ""}`}
|
||||
key={recipe.id}
|
||||
>
|
||||
<img
|
||||
alt={recipe.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("recipes", recipe.id)}
|
||||
/>
|
||||
<div className="recipe-info">
|
||||
<h4>{recipe.name}</h4>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
<div className="recipe-bonus">
|
||||
<span className="bonus-label">
|
||||
{bonusLabel[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((request) => {
|
||||
const have = getQuantity(request.materialId);
|
||||
const enough = have >= request.quantity;
|
||||
const matName
|
||||
= VAMPIRE_MATERIALS.find((mat) => {
|
||||
return mat.id === request.materialId;
|
||||
})?.name ?? request.materialId;
|
||||
return (
|
||||
<span
|
||||
className={`req-tag ${
|
||||
enough
|
||||
? "req-met"
|
||||
: "req-missing"
|
||||
}`}
|
||||
key={request.materialId}
|
||||
>
|
||||
{matName}
|
||||
{": "}
|
||||
{formatNumber(have)}
|
||||
{"/"}
|
||||
{formatNumber(request.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={handleCraftClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Crafting..."
|
||||
: "⚗️ Craft"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireCraftingPanel };
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @file Vampire equipment panel for managing fangs, shrouds, and talismans.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- VampireEquipmentCard has many conditional render paths */
|
||||
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { VampireEquipment, VampireEquipmentType } from "@elysium/types";
|
||||
|
||||
const rarityColour: Record<string, string> = {
|
||||
common: "#9e9e9e",
|
||||
epic: "#9c27b0",
|
||||
legendary: "#ff9800",
|
||||
rare: "#2196f3",
|
||||
};
|
||||
|
||||
const rarityLabel: Record<string, string> = {
|
||||
common: "Common",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
rare: "Rare",
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a human-readable bonus description for a vampire equipment item.
|
||||
* @param item - The vampire equipment item.
|
||||
* @returns The formatted bonus description string.
|
||||
*/
|
||||
const bonusDescription = (item: VampireEquipment): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (item.bonus.bloodMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.bloodMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Blood/s`);
|
||||
}
|
||||
if (item.bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Thrall Combat`);
|
||||
}
|
||||
if (item.bonus.ichorMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.ichorMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Ichor/Siring`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a vampire equipment cost as a readable string.
|
||||
* @param cost - The cost object with blood, ichor, and soulShards.
|
||||
* @param cost.blood - The blood component of the cost.
|
||||
* @param cost.ichor - The ichor component of the cost.
|
||||
* @param cost.soulShards - The soulShards component of the cost.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns The formatted cost string.
|
||||
*/
|
||||
const costLabel = (
|
||||
cost: { blood: number; ichor: number; soulShards: number },
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (cost.blood > 0) {
|
||||
parts.push(`🩸 ${formatNumber(cost.blood)}`);
|
||||
}
|
||||
if (cost.ichor > 0) {
|
||||
parts.push(`💧 ${formatNumber(cost.ichor)}`);
|
||||
}
|
||||
if (cost.soulShards > 0) {
|
||||
parts.push(`💠 ${formatNumber(cost.soulShards)}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
interface VampireEquipmentCardProperties {
|
||||
readonly item: VampireEquipment;
|
||||
readonly blood: number;
|
||||
readonly ichor: number;
|
||||
readonly soulShards: number;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single vampire equipment card with buy/equip actions.
|
||||
* @param props - The card properties.
|
||||
* @param props.item - The vampire equipment data to display.
|
||||
* @param props.blood - The player's current blood balance.
|
||||
* @param props.ichor - The player's current ichor balance.
|
||||
* @param props.soulShards - The player's current soul shards balance.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireEquipmentCard = ({
|
||||
item,
|
||||
blood,
|
||||
ichor,
|
||||
soulShards,
|
||||
formatNumber,
|
||||
}: VampireEquipmentCardProperties): JSX.Element => {
|
||||
const { buyVampireEquipment, equipVampireEquipment } = useGame();
|
||||
|
||||
const canAfford = item.cost !== undefined
|
||||
&& blood >= item.cost.blood
|
||||
&& ichor >= item.cost.ichor
|
||||
&& soulShards >= item.cost.soulShards;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyVampireEquipment(item.id);
|
||||
}
|
||||
|
||||
function handleEquip(): void {
|
||||
equipVampireEquipment(item.id);
|
||||
}
|
||||
|
||||
let typeEmoji = "🔮";
|
||||
if (item.type === "fang") {
|
||||
typeEmoji = "🦷";
|
||||
} else if (item.type === "shroud") {
|
||||
typeEmoji = "🧣";
|
||||
}
|
||||
|
||||
const equippedClass = item.equipped
|
||||
? " equipped"
|
||||
: "";
|
||||
const ownedClass = item.owned && !item.equipped
|
||||
? " owned"
|
||||
: "";
|
||||
const lockedClass = item.owned
|
||||
? ""
|
||||
: " locked";
|
||||
const cardClassName
|
||||
= `goddess-equipment-card rarity-${item.rarity}${equippedClass}${ownedClass}${lockedClass}`;
|
||||
|
||||
return (
|
||||
<div className={cardClassName}>
|
||||
<div className="equipment-card-header">
|
||||
<span className="equipment-type-icon">{typeEmoji}</span>
|
||||
<span className="equipment-name">{item.name}</span>
|
||||
<span
|
||||
className="equipment-rarity-badge"
|
||||
style={{ color: rarityColour[item.rarity] }}
|
||||
>
|
||||
{rarityLabel[item.rarity]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{item.setId === undefined
|
||||
? null
|
||||
: <p className="equipment-set">{"Set: "}{item.setId}</p>}
|
||||
<div className="equipment-card-actions">
|
||||
{item.owned && item.equipped
|
||||
? <span className="equipment-equipped-badge">{"✅ Equipped"}</span>
|
||||
: null}
|
||||
{item.owned && !item.equipped
|
||||
? <button
|
||||
className="btn-equip"
|
||||
onClick={handleEquip}
|
||||
type="button"
|
||||
>
|
||||
{"Equip"}
|
||||
</button>
|
||||
: null}
|
||||
{!item.owned && item.cost !== undefined
|
||||
? <button
|
||||
className="btn-buy"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
title={canAfford
|
||||
? ""
|
||||
: "Not enough resources"}
|
||||
type="button"
|
||||
>
|
||||
{"Buy — "}
|
||||
{costLabel(item.cost, formatNumber)}
|
||||
</button>
|
||||
: null}
|
||||
{!item.owned && item.cost === undefined
|
||||
? <span className="equipment-drop-hint">{"🎲 Boss Drop Only"}</span>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TabFilter = "all" | VampireEquipmentType;
|
||||
|
||||
/**
|
||||
* Renders the vampire equipment panel, displaying all fangs, shrouds, and talismans.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireEquipmentPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ activeTab, setActiveTab ] = useState<TabFilter>("all");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { resources, vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const blood = resources.blood ?? 0;
|
||||
const { ichor } = vampire.siring;
|
||||
const { soulShards } = vampire.awakening;
|
||||
const { equipment } = vampire;
|
||||
|
||||
const filteredEquipment = activeTab === "all"
|
||||
? equipment
|
||||
: equipment.filter((item) => {
|
||||
return item.type === activeTab;
|
||||
});
|
||||
|
||||
const tabs: Array<{ id: TabFilter; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "fang", label: "🦷 Fangs" },
|
||||
{ id: "shroud", label: "🧣 Shrouds" },
|
||||
{ id: "talisman", label: "🔮 Talismans" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="panel goddess-equipment-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🦇 Vampire Equipment"}</h2>
|
||||
</div>
|
||||
<div className="panel-resource-bar">
|
||||
<span className="resource-item">
|
||||
{"🩸 Blood: "}
|
||||
{formatNumber(blood)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"💧 Ichor: "}
|
||||
{formatNumber(ichor)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"💠 Soul Shards: "}
|
||||
{formatNumber(soulShards)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="equipment-tabs">
|
||||
{tabs.map((tab) => {
|
||||
function handleTabClick(): void {
|
||||
setActiveTab(tab.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`tab-btn${activeTab === tab.id
|
||||
? " active"
|
||||
: ""}`}
|
||||
key={tab.id}
|
||||
onClick={handleTabClick}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="equipment-grid">
|
||||
{filteredEquipment.map((item) => {
|
||||
return (
|
||||
<VampireEquipmentCard
|
||||
blood={blood}
|
||||
formatNumber={formatNumber}
|
||||
ichor={ichor}
|
||||
item={item}
|
||||
key={item.id}
|
||||
soulShards={soulShards}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredEquipment.length === 0
|
||||
? <p className="empty-state">
|
||||
{"No equipment in this category yet."}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireEquipmentPanel };
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* @file Vampire exploration panel component for exploring dark areas and collecting materials.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
|
||||
/* eslint-disable max-statements -- Component function requires many state declarations and handlers */
|
||||
import { type JSX, useEffect, useRef, useState } from "react";
|
||||
import { checkVampireExplorationClaimable } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
// eslint-disable-next-line stylistic/max-len -- import path cannot be shortened
|
||||
import { VAMPIRE_EXPLORATION_AREAS } from "../../data/vampireExplorationAreas.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import type {
|
||||
VampireExploreClaimableResponse,
|
||||
VampireExploreCollectResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerDay = 86_400;
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerDay) {
|
||||
const days = Math.floor(seconds / secondsPerDay);
|
||||
const remainingAfterDays = seconds % secondsPerDay;
|
||||
const hours = Math.floor(remainingAfterDays / secondsPerHour);
|
||||
return hours > 0
|
||||
? `${String(days)}d ${String(hours)}h`
|
||||
: `${String(days)}d`;
|
||||
}
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainingAfterHours = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainingAfterHours / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the time remaining for an exploration in progress.
|
||||
* Uses endsAt (server-computed) when available to avoid client/server clock drift.
|
||||
* @param endsAt - The server-computed completion timestamp, if available.
|
||||
* @param startedAt - The timestamp when exploration started.
|
||||
* @param durationSeconds - The total duration in seconds.
|
||||
* @returns The remaining seconds.
|
||||
*/
|
||||
const timeRemaining = (
|
||||
endsAt: number | undefined,
|
||||
startedAt: number,
|
||||
durationSeconds: number,
|
||||
): number => {
|
||||
if (endsAt !== undefined) {
|
||||
return Math.max(0, (endsAt - Date.now()) / 1000);
|
||||
}
|
||||
const elapsed = (Date.now() - startedAt) / 1000;
|
||||
return Math.max(0, durationSeconds - elapsed);
|
||||
};
|
||||
|
||||
interface CollectResult {
|
||||
areaId: string;
|
||||
response: VampireExploreCollectResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the vampire exploration panel for managing dark area explorations.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireExplorationPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
startVampireExploration,
|
||||
collectVampireExploration,
|
||||
formatNumber,
|
||||
} = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||
return (
|
||||
sessionStorage.getItem("elysium_vampire_explore_zone")
|
||||
?? "vampire_haunted_catacombs"
|
||||
);
|
||||
});
|
||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||
const [ claimableAreaIds, setClaimableAreaIds ]
|
||||
= useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const stateReference = useRef(state);
|
||||
stateReference.current = state;
|
||||
|
||||
const claimableReference = useRef(claimableAreaIds);
|
||||
claimableReference.current = claimableAreaIds;
|
||||
|
||||
useEffect(() => {
|
||||
const pollClaimable = async(): Promise<void> => {
|
||||
const currentState = stateReference.current;
|
||||
if (currentState === null) {
|
||||
return;
|
||||
}
|
||||
const inProgressArea = currentState.vampire?.exploration.areas.find(
|
||||
(a) => {
|
||||
return a.status === "in_progress";
|
||||
},
|
||||
);
|
||||
if (inProgressArea === undefined) {
|
||||
return;
|
||||
}
|
||||
if (claimableReference.current.has(inProgressArea.id)) {
|
||||
return;
|
||||
}
|
||||
const areaData = VAMPIRE_EXPLORATION_AREAS.find((a) => {
|
||||
return a.id === inProgressArea.id;
|
||||
});
|
||||
if (areaData === undefined) {
|
||||
return;
|
||||
}
|
||||
const remaining = timeRemaining(
|
||||
inProgressArea.endsAt,
|
||||
inProgressArea.startedAt ?? 0,
|
||||
areaData.durationSeconds,
|
||||
);
|
||||
if (remaining > 0) {
|
||||
return;
|
||||
}
|
||||
const result: VampireExploreClaimableResponse
|
||||
= await checkVampireExplorationClaimable(inProgressArea.id);
|
||||
if (result.claimable) {
|
||||
setClaimableAreaIds((previous) => {
|
||||
return new Set([ ...previous, inProgressArea.id ]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void pollClaimable();
|
||||
}, 1000);
|
||||
|
||||
return (): void => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const explorationState = vampire.exploration;
|
||||
const vampireZones = vampire.zones;
|
||||
|
||||
const activeZone = vampireZones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
const zoneIsLocked = activeZone?.status === "locked";
|
||||
|
||||
const zoneAreas = VAMPIRE_EXPLORATION_AREAS.filter((area) => {
|
||||
return area.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const hasActiveExploration
|
||||
= explorationState.areas.some((area) => {
|
||||
return area.status === "in_progress";
|
||||
});
|
||||
|
||||
async function handleStart(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
await startVampireExploration(areaId);
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCollect(areaId: string): Promise<void> {
|
||||
setPendingAreaId(areaId);
|
||||
try {
|
||||
const result = await collectVampireExploration(areaId);
|
||||
setLastResult({ areaId: areaId, response: result });
|
||||
setClaimableAreaIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(areaId);
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismissResult(): void {
|
||||
setLastResult(null);
|
||||
}
|
||||
|
||||
function handleZoneSelect(id: string): void {
|
||||
setActiveZoneId(id);
|
||||
setLastResult(null);
|
||||
sessionStorage.setItem("elysium_vampire_explore_zone", id);
|
||||
}
|
||||
|
||||
const bloodChange = lastResult?.response.event?.bloodChange ?? 0;
|
||||
const ichorChange = lastResult?.response.event?.ichorChange ?? 0;
|
||||
const thrallLostCount = lastResult?.response.event?.thrallLostCount ?? 0;
|
||||
|
||||
return (
|
||||
<section className="panel exploration-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🗺️ Dark Exploration"}</h2>
|
||||
</div>
|
||||
|
||||
{lastResult === null
|
||||
? null
|
||||
: <div className="exploration-result">
|
||||
<button
|
||||
className="exploration-result-close"
|
||||
onClick={handleDismissResult}
|
||||
type="button"
|
||||
>
|
||||
{"✕"}
|
||||
</button>
|
||||
{lastResult.response.foundNothing
|
||||
? <p className="exploration-nothing">
|
||||
{lastResult.response.nothingMessage}
|
||||
</p>
|
||||
: <>
|
||||
{lastResult.response.event === null
|
||||
? null
|
||||
: <p className="exploration-event-text">
|
||||
{lastResult.response.event.text}
|
||||
</p>
|
||||
}
|
||||
<div className="exploration-rewards">
|
||||
{bloodChange !== 0
|
||||
&& <span
|
||||
className={`reward-tag ${bloodChange > 0
|
||||
? ""
|
||||
: "negative"}`}
|
||||
>
|
||||
{"🩸 "}
|
||||
{bloodChange > 0
|
||||
? "+"
|
||||
: ""}
|
||||
{formatNumber(bloodChange)}
|
||||
{" blood"}
|
||||
</span>
|
||||
}
|
||||
{ichorChange !== 0
|
||||
&& <span
|
||||
className={`reward-tag ${ichorChange > 0
|
||||
? ""
|
||||
: "negative"}`}
|
||||
>
|
||||
{"💧 "}
|
||||
{ichorChange > 0
|
||||
? "+"
|
||||
: ""}
|
||||
{formatNumber(ichorChange)}
|
||||
{" ichor"}
|
||||
</span>
|
||||
}
|
||||
{thrallLostCount > 0
|
||||
&& <span className="reward-tag negative">
|
||||
{"🧟 -"}
|
||||
{formatNumber(thrallLostCount)}
|
||||
{" thralls lost"}
|
||||
</span>
|
||||
}
|
||||
{lastResult.response.event?.materialGained !== null
|
||||
&& lastResult.response.event?.materialGained !== undefined
|
||||
? <span className="reward-tag material-tag">
|
||||
{"📦 +"}
|
||||
{lastResult.response.event.materialGained.quantity}{" "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long property chain cannot be shortened */}
|
||||
{lastResult.response.event.materialGained.materialId.replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
)}
|
||||
{" (event)"}
|
||||
</span>
|
||||
: null}
|
||||
{lastResult.response.materialsFound.map((foundMaterial) => {
|
||||
return (
|
||||
<span
|
||||
className="reward-tag material-tag"
|
||||
key={foundMaterial.materialId}
|
||||
>
|
||||
{"📦 +"}
|
||||
{foundMaterial.quantity}{" "}
|
||||
{foundMaterial.materialId.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="zone-selector">
|
||||
{vampireZones.map((zone) => {
|
||||
const isLocked = zone.status === "locked";
|
||||
|
||||
function handleZoneClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab ${
|
||||
activeZoneId === zone.id
|
||||
? "zone-tab-active"
|
||||
: ""
|
||||
} ${isLocked
|
||||
? "zone-tab-locked"
|
||||
: ""}`}
|
||||
disabled={isLocked}
|
||||
key={zone.id}
|
||||
onClick={handleZoneClick}
|
||||
title={isLocked
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{zoneIsLocked
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This vampire zone is locked."}</p>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
<div className="exploration-list">
|
||||
{zoneAreas.map((area) => {
|
||||
const areaState = explorationState.areas.find(
|
||||
(explorationArea) => {
|
||||
return explorationArea.id === area.id;
|
||||
},
|
||||
);
|
||||
const status = areaState?.status ?? "locked";
|
||||
const startedAt = areaState?.startedAt ?? 0;
|
||||
const endsAt = areaState?.endsAt;
|
||||
const isReady
|
||||
= status === "in_progress"
|
||||
&& claimableAreaIds.has(area.id);
|
||||
const isPending = pendingAreaId === area.id;
|
||||
|
||||
function handleStartClick(): void {
|
||||
void handleStart(area.id);
|
||||
}
|
||||
function handleCollectClick(): void {
|
||||
void handleCollect(area.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`exploration-card exploration-${status}`}
|
||||
key={area.id}
|
||||
>
|
||||
<img
|
||||
alt={area.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("explorations", area.id)}
|
||||
/>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
{areaState?.completedOnce === true
|
||||
? <span className="exploration-discovered">{" 📖"}</span>
|
||||
: null}
|
||||
</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 || hasActiveExploration}
|
||||
onClick={handleStartClick}
|
||||
title={
|
||||
hasActiveExploration
|
||||
? "A dark exploration is already in progress"
|
||||
: undefined
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Departing..."
|
||||
: `Explore (${formatDuration(area.durationSeconds)})`}
|
||||
</button>
|
||||
}
|
||||
{status === "in_progress" && !isReady
|
||||
&& <span className="quest-badge active">
|
||||
{"⏳ "}
|
||||
{/* eslint-disable-next-line stylistic/max-len -- long call cannot be shortened */}
|
||||
{formatDuration(Math.ceil(timeRemaining(endsAt, startedAt, area.durationSeconds)))}
|
||||
{" remaining"}
|
||||
</span>
|
||||
}
|
||||
{status === "in_progress" && isReady
|
||||
? <button
|
||||
className="collect-button"
|
||||
disabled={isPending}
|
||||
onClick={handleCollectClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Collecting..."
|
||||
: "📦 Collect Results"}
|
||||
</button>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{zoneAreas.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No exploration areas in this zone."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireExplorationPanel };
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @file Read-only panel displaying vampire quests grouped by zone.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type {
|
||||
VampireQuest,
|
||||
VampireQuestReward,
|
||||
VampireZone,
|
||||
} from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
* @param seconds - The total number of seconds to format.
|
||||
* @returns The formatted duration string.
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const secondsPerHour = 3600;
|
||||
const secondsPerMinute = 60;
|
||||
if (seconds >= secondsPerHour) {
|
||||
const hours = Math.floor(seconds / secondsPerHour);
|
||||
const remainderSeconds = seconds % secondsPerHour;
|
||||
const minutes = Math.floor(remainderSeconds / secondsPerMinute);
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
if (seconds >= secondsPerMinute) {
|
||||
const minutes = Math.floor(seconds / secondsPerMinute);
|
||||
const secs = seconds % secondsPerMinute;
|
||||
return `${String(minutes)}m ${String(secs)}s`;
|
||||
}
|
||||
return `${String(seconds)}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a human-readable label string for a vampire quest reward.
|
||||
* @param reward - The reward to describe.
|
||||
* @param formatNumber - The number formatter function.
|
||||
* @returns The label string for the given reward type.
|
||||
*/
|
||||
const getRewardLabel = (
|
||||
reward: VampireQuestReward,
|
||||
formatNumber: (value: number)=> string,
|
||||
): string => {
|
||||
if (reward.type === "blood") {
|
||||
return `🩸 ${formatNumber(reward.amount ?? 0)} Blood`;
|
||||
}
|
||||
if (reward.type === "ichor") {
|
||||
return `💧 ${formatNumber(reward.amount ?? 0)} Ichor`;
|
||||
}
|
||||
if (reward.type === "soulShards") {
|
||||
return `💠 ${formatNumber(reward.amount ?? 0)} Soul Shards`;
|
||||
}
|
||||
if (reward.type === "upgrade") {
|
||||
return "🔓 Upgrade Unlocked";
|
||||
}
|
||||
if (reward.type === "thrall") {
|
||||
return "🧟 New Thrall Tier";
|
||||
}
|
||||
return "🦇 Equipment Unlocked";
|
||||
};
|
||||
|
||||
interface VampireQuestCardProperties {
|
||||
readonly quest: VampireQuest;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly zoneIsOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single vampire quest card (read-only).
|
||||
* @param props - The component properties.
|
||||
* @param props.quest - The vampire quest to display.
|
||||
* @param props.unlockHint - The name of the prerequisite quest, if locked.
|
||||
* @param props.zoneIsOpen - Whether the quest's zone is currently unlocked.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireQuestCard = ({
|
||||
quest,
|
||||
unlockHint,
|
||||
zoneIsOpen,
|
||||
}: VampireQuestCardProperties): JSX.Element => {
|
||||
const { formatNumber } = useGame();
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
<p className="quest-duration">
|
||||
{"⏱ "}
|
||||
{formatDuration(quest.durationSeconds)}
|
||||
</p>
|
||||
<div className="quest-rewards">
|
||||
{quest.rewards.map((reward, rewardIndex) => {
|
||||
return <span
|
||||
className="reward-tag"
|
||||
key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}
|
||||
>
|
||||
{getRewardLabel(reward, formatNumber)}
|
||||
</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quest-action">
|
||||
{quest.status === "locked" && !zoneIsOpen
|
||||
&& <span className="quest-badge locked">{"🔒 Zone Locked"}</span>
|
||||
}
|
||||
{quest.status === "locked" && zoneIsOpen
|
||||
? <>
|
||||
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||
{unlockHint !== undefined
|
||||
&& <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{quest.status === "available"
|
||||
&& <span className="quest-badge available">{"📋 Available"}</span>
|
||||
}
|
||||
{quest.status === "active"
|
||||
&& <span className="quest-badge active">{"⏳ In Progress"}</span>
|
||||
}
|
||||
{quest.status === "completed"
|
||||
&& <span className="quest-badge completed">{"✅ Completed"}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the vampire quests panel with zone selection and quest list.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireQuestsPanel = (): JSX.Element => {
|
||||
const { state, toggleVampireAutoQuest } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||
return sessionStorage.getItem("elysium_vampire_quest_zone")
|
||||
?? "vampire_haunted_catacombs";
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const vampireState = state.vampire;
|
||||
if (vampireState === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Vampire expansion not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, quests, autoQuest } = vampireState;
|
||||
const autoQuestOn = autoQuest === true;
|
||||
|
||||
const activeZone = zones.find((zone: VampireZone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
const zoneIsOpen = activeZone?.status === "unlocked";
|
||||
|
||||
const zoneQuests = quests.filter((quest: VampireQuest) => {
|
||||
return quest.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const questNameById = new Map(
|
||||
quests.map((quest: VampireQuest) => {
|
||||
return [ quest.id, quest.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
const getUnlockHint = (quest: VampireQuest): string | undefined => {
|
||||
if (quest.status !== "locked" || quest.prerequisiteIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const [ prereqId ] = quest.prerequisiteIds;
|
||||
if (prereqId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return questNameById.get(prereqId);
|
||||
};
|
||||
|
||||
function handleZoneSelect(zoneId: string): void {
|
||||
setActiveZoneId(zoneId);
|
||||
sessionStorage.setItem("elysium_vampire_quest_zone", zoneId);
|
||||
}
|
||||
|
||||
const completedCount = zoneQuests.filter((quest: VampireQuest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<section className="panel vampire-quests-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Vampire Quests"}</h2>
|
||||
<button
|
||||
className={`auto-toggle ${autoQuestOn
|
||||
? "auto-on"
|
||||
: "auto-off"}`}
|
||||
onClick={toggleVampireAutoQuest}
|
||||
title={autoQuestOn
|
||||
? "Auto-Quest is ON — click to disable"
|
||||
: "Auto-Quest is OFF — click to enable"}
|
||||
type="button"
|
||||
>
|
||||
{autoQuestOn
|
||||
? "🤖 Auto-Quest: ON"
|
||||
: "🤖 Auto-Quest: OFF"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="zone-filter-buttons">
|
||||
{zones.map((zone: VampireZone) => {
|
||||
function handleClick(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
return <button
|
||||
className={`zone-filter-button ${zone.id === activeZoneId
|
||||
? "active"
|
||||
: ""} ${zone.status === "locked"
|
||||
? "zone-locked"
|
||||
: ""}`}
|
||||
key={zone.id}
|
||||
onClick={handleClick}
|
||||
title={zone.status === "locked"
|
||||
? "Zone locked"
|
||||
: zone.name}
|
||||
type="button"
|
||||
>
|
||||
{zone.emoji}
|
||||
{" "}
|
||||
{zone.name}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
{activeZone !== undefined
|
||||
&& <div className="zone-info">
|
||||
<p className="zone-description">{activeZone.description}</p>
|
||||
<p className="zone-progress">
|
||||
{String(completedCount)}
|
||||
{" / "}
|
||||
{String(zoneQuests.length)}
|
||||
{" quests completed"}
|
||||
</p>
|
||||
{activeZone.status === "locked"
|
||||
&& <p className="zone-locked-notice">
|
||||
{"🔒 This zone is locked. Defeat the required vampire boss"}
|
||||
{" to unlock it."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div className="quest-list">
|
||||
{zoneQuests.length === 0
|
||||
? <p className="empty-state">{"No quests in this zone."}</p>
|
||||
: zoneQuests.map((quest: VampireQuest) => {
|
||||
return <VampireQuestCard
|
||||
key={quest.id}
|
||||
quest={quest}
|
||||
unlockHint={getUnlockHint(quest)}
|
||||
zoneIsOpen={zoneIsOpen}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireQuestsPanel };
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* @file Siring panel component for vampire prestige and ichor upgrade shop.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-lines -- Large panel with siring and shop tabs */
|
||||
/* eslint-disable max-statements -- Siring panel manages many local state variables */
|
||||
/* eslint-disable stylistic/max-len -- Data content with long description strings */
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- SCREAMING_SNAKE_CASE is conventional for module-level data constants */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { SiringUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const baseSiringThreshold = 1_000_000;
|
||||
const ichorYieldDivisor = 50_000;
|
||||
|
||||
/**
|
||||
* Calculates the blood threshold required for the next siring.
|
||||
* Mirrors the server formula: BASE * (count + 1)^2 * thresholdMultiplier.
|
||||
* @param siringCount - The number of sirings completed so far.
|
||||
* @param thresholdMultiplier - An optional multiplier applied to the threshold.
|
||||
* @returns The blood amount required to sire.
|
||||
*/
|
||||
const calculateSiringThreshold = (
|
||||
siringCount: number,
|
||||
thresholdMultiplier = 1,
|
||||
): number => {
|
||||
return (
|
||||
baseSiringThreshold
|
||||
* Math.pow(siringCount + 1, 2)
|
||||
* thresholdMultiplier
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the projected ichor yield from a siring.
|
||||
* Mirrors the server formula: MAX(1, FLOOR(SQRT(totalBloodEarned / divisor) * ichorMultiplier)).
|
||||
* @param totalBloodEarned - Total blood earned in the current siring run.
|
||||
* @param ichorMultiplier - Multiplier applied to the ichor yield.
|
||||
* @returns The projected ichor earned.
|
||||
*/
|
||||
const calculateIchorYield = (
|
||||
totalBloodEarned: number,
|
||||
ichorMultiplier: number,
|
||||
): number => {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
Math.sqrt(totalBloodEarned / ichorYieldDivisor) * ichorMultiplier,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the siring production multiplier from the count.
|
||||
* Each siring adds 25% to the production multiplier.
|
||||
* @param count - The number of sirings completed.
|
||||
* @returns The computed production multiplier.
|
||||
*/
|
||||
const computeSiringProductionMultiplier = (count: number): number => {
|
||||
// eslint-disable-next-line stylistic/no-extra-parens -- Required by no-mixed-operators rule
|
||||
return 1 + (count * 0.25);
|
||||
};
|
||||
|
||||
const SIRING_UPGRADES: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: SiringUpgradeCategory;
|
||||
ichorCost: number;
|
||||
multiplier: number;
|
||||
}> = [
|
||||
{
|
||||
category: "blood",
|
||||
description: "The first drop of ichor transforms your blood instinct. All blood/s ×1.25.",
|
||||
ichorCost: 5,
|
||||
id: "siring_blood_1",
|
||||
multiplier: 1.25,
|
||||
name: "Ichor Awakening I",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "Sustained siring deepens the hunger that drives every thrall. All blood/s ×1.5.",
|
||||
ichorCost: 15,
|
||||
id: "siring_blood_2",
|
||||
multiplier: 1.5,
|
||||
name: "Ichor Awakening II",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "Each siring sharpens your command over the blood flow. All blood/s ×2.",
|
||||
ichorCost: 40,
|
||||
id: "siring_blood_3",
|
||||
multiplier: 2,
|
||||
name: "Ichor Awakening III",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "The bloodline resonates across every hunt and harvest. All blood/s ×5.",
|
||||
ichorCost: 120,
|
||||
id: "siring_blood_4",
|
||||
multiplier: 5,
|
||||
name: "Ichor Awakening IV",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "Total mastery of the siring-blood bond multiplies all income tenfold. All blood/s ×10.",
|
||||
ichorCost: 350,
|
||||
id: "siring_blood_5",
|
||||
multiplier: 10,
|
||||
name: "Ichor Awakening V",
|
||||
},
|
||||
{
|
||||
category: "blood",
|
||||
description: "The accumulated weight of many sirings floods every vein in your domain. All blood/s ×25.",
|
||||
ichorCost: 1000,
|
||||
id: "siring_blood_6",
|
||||
multiplier: 25,
|
||||
name: "Ichor Awakening VI",
|
||||
},
|
||||
{
|
||||
category: "thralls",
|
||||
description: "Sired blood flows through your thralls, amplifying their natural power. All thrall blood/s ×1.5.",
|
||||
ichorCost: 8,
|
||||
id: "siring_thralls_1",
|
||||
multiplier: 1.5,
|
||||
name: "Bloodline Bond I",
|
||||
},
|
||||
{
|
||||
category: "thralls",
|
||||
description: "The bond between sire and thrall deepens, multiplying their output. All thrall blood/s ×2.",
|
||||
ichorCost: 25,
|
||||
id: "siring_thralls_2",
|
||||
multiplier: 2,
|
||||
name: "Bloodline Bond II",
|
||||
},
|
||||
{
|
||||
category: "thralls",
|
||||
description: "Every thrall in your bloodline fights and works with supernatural coordination. All thrall blood/s ×3.",
|
||||
ichorCost: 75,
|
||||
id: "siring_thralls_3",
|
||||
multiplier: 3,
|
||||
name: "Bloodline Bond III",
|
||||
},
|
||||
{
|
||||
category: "thralls",
|
||||
description: "The siring bond reaches its apex — every thrall becomes an extension of your will. All thrall blood/s ×5.",
|
||||
ichorCost: 200,
|
||||
id: "siring_thralls_4",
|
||||
multiplier: 5,
|
||||
name: "Bloodline Bond IV",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Sired instincts sharpen your thralls' fighting edge. All thrall combat power ×1.5.",
|
||||
ichorCost: 12,
|
||||
id: "siring_combat_1",
|
||||
multiplier: 1.5,
|
||||
name: "Dark Predator I",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "The predator's cunning passed through siring doubles your combat effectiveness. All thrall combat power ×2.",
|
||||
ichorCost: 45,
|
||||
id: "siring_combat_2",
|
||||
multiplier: 2,
|
||||
name: "Dark Predator II",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "Centuries of accumulated battle memory flood into your line. All thrall combat power ×3.",
|
||||
ichorCost: 150,
|
||||
id: "siring_combat_3",
|
||||
multiplier: 3,
|
||||
name: "Dark Predator III",
|
||||
},
|
||||
{
|
||||
category: "combat",
|
||||
description: "The ultimate expression of vampire combat mastery through the siring ritual. All thrall combat power ×5.",
|
||||
ichorCost: 500,
|
||||
id: "siring_combat_4",
|
||||
multiplier: 5,
|
||||
name: "Dark Predator IV",
|
||||
},
|
||||
{
|
||||
category: "ichor",
|
||||
description: "The ritual of siring becomes more efficient, preserving greater ichor yield. Ichor per siring ×1.5.",
|
||||
ichorCost: 20,
|
||||
id: "siring_ichor_1",
|
||||
multiplier: 1.5,
|
||||
name: "Refined Siring I",
|
||||
},
|
||||
{
|
||||
category: "ichor",
|
||||
description: "Deeper siring mastery extracts twice the ichor from every reset. Ichor per siring ×2.",
|
||||
ichorCost: 60,
|
||||
id: "siring_ichor_2",
|
||||
multiplier: 2,
|
||||
name: "Refined Siring II",
|
||||
},
|
||||
{
|
||||
category: "ichor",
|
||||
description: "The siring ritual refined to its peak triples the ichor yield at reset. Ichor per siring ×3.",
|
||||
ichorCost: 180,
|
||||
id: "siring_ichor_3",
|
||||
multiplier: 3,
|
||||
name: "Refined Siring III",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Siring instinct reduces the blood threshold needed for the next siring by 10%.",
|
||||
ichorCost: 30,
|
||||
id: "siring_threshold_1",
|
||||
multiplier: 0.9,
|
||||
name: "Blood Efficiency I",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Further refinement lowers the siring threshold by an additional 15%.",
|
||||
ichorCost: 90,
|
||||
id: "siring_threshold_2",
|
||||
multiplier: 0.85,
|
||||
name: "Blood Efficiency II",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "The siring rite becomes almost effortless — threshold reduced by another 20%.",
|
||||
ichorCost: 270,
|
||||
id: "siring_threshold_3",
|
||||
multiplier: 0.8,
|
||||
name: "Blood Efficiency III",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Peak efficiency — the blood threshold for siring is reduced by a further 25%.",
|
||||
ichorCost: 800,
|
||||
id: "siring_threshold_4",
|
||||
multiplier: 0.75,
|
||||
name: "Blood Efficiency IV",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "An ancient siring ritual accelerates the arrival of the first thrall class after each siring.",
|
||||
ichorCost: 50,
|
||||
id: "siring_quick_start_1",
|
||||
multiplier: 1.5,
|
||||
name: "Quick Fledglings I",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "The first fledglings after siring arrive faster and work harder for longer.",
|
||||
ichorCost: 150,
|
||||
id: "siring_quick_start_2",
|
||||
multiplier: 2,
|
||||
name: "Quick Fledglings II",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "Your siring bloodline passively preserves a fraction of your thrall efficiency across resets.",
|
||||
ichorCost: 250,
|
||||
id: "siring_persistence_1",
|
||||
multiplier: 1.25,
|
||||
name: "Bloodline Memory I",
|
||||
},
|
||||
{
|
||||
category: "utility",
|
||||
description: "The bloodline memory deepens — even more efficiency is preserved through each siring.",
|
||||
ichorCost: 750,
|
||||
id: "siring_persistence_2",
|
||||
multiplier: 1.5,
|
||||
name: "Bloodline Memory II",
|
||||
},
|
||||
];
|
||||
|
||||
const categoryOrder: Array<SiringUpgradeCategory> = [
|
||||
"blood",
|
||||
"thralls",
|
||||
"combat",
|
||||
"ichor",
|
||||
"utility",
|
||||
];
|
||||
|
||||
const SIRING_UPGRADE_CATEGORY_LABELS: Record<SiringUpgradeCategory, string> = {
|
||||
blood: "🩸 Blood Multipliers",
|
||||
combat: "⚔️ Combat Multipliers",
|
||||
ichor: "💧 Ichor Yield",
|
||||
thralls: "🧟 Thrall Multipliers",
|
||||
utility: "🎯 Quality of Life",
|
||||
};
|
||||
|
||||
type SiringTab = "sire" | "shop";
|
||||
|
||||
/**
|
||||
* Renders the siring panel with vampire prestige and ichor shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireSiringPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
reloadSilent,
|
||||
formatInteger,
|
||||
formatNumber,
|
||||
sire,
|
||||
buySiringUpgrade,
|
||||
} = useGame();
|
||||
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
ichorEarned: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ siringError, setSiringError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
const [ activeTab, setActiveTab ] = useState<SiringTab>("sire");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { siring, awakening, totalBloodEarned } = vampire;
|
||||
|
||||
const thresholdSiringMultiplier = SIRING_UPGRADES.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.id.startsWith("siring_threshold_")
|
||||
&& siring.purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const combinedThresholdMultiplier
|
||||
= thresholdSiringMultiplier * awakening.soulShardsSiringThresholdMultiplier;
|
||||
const threshold = calculateSiringThreshold(siring.count, combinedThresholdMultiplier);
|
||||
const isEligible = totalBloodEarned >= threshold;
|
||||
|
||||
const ichorSiringMultiplier = SIRING_UPGRADES.filter((upgrade) => {
|
||||
return (
|
||||
upgrade.category === "ichor"
|
||||
&& siring.purchasedUpgradeIds.includes(upgrade.id)
|
||||
);
|
||||
}).reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const combinedIchorMultiplier
|
||||
= ichorSiringMultiplier * awakening.soulShardsSiringIchorMultiplier;
|
||||
const ichorPreview = calculateIchorYield(totalBloodEarned, combinedIchorMultiplier);
|
||||
|
||||
const nextMultiplier = computeSiringProductionMultiplier(siring.count + 1);
|
||||
const progressRatio = Math.min(totalBloodEarned / threshold, 1);
|
||||
const progressPct = (progressRatio * 100).toFixed(1);
|
||||
|
||||
const currentIchor = siring.ichor;
|
||||
|
||||
async function handleSire(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setSiringError(null);
|
||||
try {
|
||||
const data = await sire();
|
||||
setResult({
|
||||
count: data.newSiringCount,
|
||||
ichorEarned: data.ichorEarned,
|
||||
});
|
||||
await reloadSilent();
|
||||
} catch (error_: unknown) {
|
||||
setSiringError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Siring failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buySiringUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((categoryId) => {
|
||||
const label = SIRING_UPGRADE_CATEGORY_LABELS[categoryId];
|
||||
const upgrades = SIRING_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === categoryId;
|
||||
});
|
||||
return { categoryId, label, upgrades };
|
||||
});
|
||||
|
||||
function handleSireClick(): void {
|
||||
void handleSire();
|
||||
}
|
||||
|
||||
function handleSireTabClick(): void {
|
||||
setActiveTab("sire");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel consecration-panel">
|
||||
<h2>{"🩸 Siring"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "sire"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleSireTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Sire"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"💧 Ichor Shop ("}
|
||||
{formatInteger(currentIchor)}
|
||||
{" ichor)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "sire"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Siring is the vampire prestige layer. It resets your blood"
|
||||
+ " and vampire progress, but grants "}
|
||||
<strong>{"Ichor"}</strong>
|
||||
{" — a permanent vampire currency used to purchase powerful upgrades."
|
||||
+ " Each siring also permanently increases your blood/s multiplier."}
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{siring.count > 0
|
||||
? <p>
|
||||
{"Siring count: "}
|
||||
<strong>{siring.count}</strong>
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
<p>
|
||||
{"Current Ichor: "}
|
||||
<strong>{formatInteger(currentIchor)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Blood this run: "}
|
||||
<strong>{formatNumber(totalBloodEarned)}</strong>
|
||||
{" / "}
|
||||
<strong>{formatNumber(threshold)}</strong>
|
||||
</p>
|
||||
<div className="prestige-progress-bar">
|
||||
<div
|
||||
className="prestige-progress-fill"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="prestige-progress-label">
|
||||
{progressPct}
|
||||
{"% of threshold"}
|
||||
</p>
|
||||
{isEligible
|
||||
? <p className="echo-preview">
|
||||
{"Ichor on siring: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatInteger(ichorPreview)}
|
||||
</strong>
|
||||
{combinedIchorMultiplier > 1
|
||||
? <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{combinedIchorMultiplier.toFixed(2)}
|
||||
{" yield bonus applied)"}
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
<p>
|
||||
{"Next production multiplier: "}
|
||||
<strong>
|
||||
{"×"}
|
||||
{nextMultiplier.toFixed(2)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEligible
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Earn enough blood"}</strong>
|
||||
{" to unlock siring."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"You need "}
|
||||
{formatNumber(threshold)}
|
||||
{" total blood in the current run. You have "}
|
||||
{formatNumber(totalBloodEarned)}
|
||||
{"."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{isEligible
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to sire. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{" within this vampire run."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleSireClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Siring..."
|
||||
: `🩸 Sire (+${formatInteger(ichorPreview)} Ichor)`}
|
||||
</button>
|
||||
{siringError === null
|
||||
? null
|
||||
: <p className="error">{siringError}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Sired! Earned "}
|
||||
<strong>
|
||||
{formatInteger(result.ichorEarned)}
|
||||
{" Ichor"}
|
||||
</strong>
|
||||
{". This is Siring "}
|
||||
{result.count}
|
||||
{". A new bloodline cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatInteger(currentIchor)}
|
||||
{" Ichor"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Ichor upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive future sirings."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ categoryId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={categoryId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = siring.purchasedUpgradeIds.includes(upgrade.id);
|
||||
const canAfford = currentIchor >= upgrade.ichorCost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-upgrade-card ${
|
||||
purchased
|
||||
? "purchased"
|
||||
: ""
|
||||
} ${!canAfford && !purchased
|
||||
? "unaffordable"
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
<p className="upgrade-cost">
|
||||
{purchased
|
||||
? "✅ Purchased"
|
||||
: `💧 ${formatInteger(upgrade.ichorCost)} Ichor`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="upgrade-buy-button"
|
||||
disabled={!canAfford || isLoading}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireSiringPanel };
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @file Thralls panel component for purchasing vampire thralls.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable react/no-multi-comp -- ThrallCard sub-component is tightly coupled */
|
||||
/* eslint-disable complexity -- ThrallCard has inherent branching for batch/afford logic */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { VampireThrall } from "@elysium/types";
|
||||
|
||||
type BatchSize = 1 | 10 | "max";
|
||||
const batchOptions: Array<BatchSize> = [ 1, 10, "max" ];
|
||||
|
||||
const growthRate = 1.15;
|
||||
|
||||
/**
|
||||
* Computes the total blood cost to buy a batch of thralls.
|
||||
* @param thrall - The thrall tier to purchase.
|
||||
* @param quantity - The number to buy.
|
||||
* @returns The total blood cost.
|
||||
*/
|
||||
const computeBatchCost = (
|
||||
thrall: VampireThrall,
|
||||
quantity: number,
|
||||
): number => {
|
||||
let total = 0;
|
||||
for (let index = 0; index < quantity; index = index + 1) {
|
||||
const exponent = thrall.count + index;
|
||||
const cost = thrall.baseCost * Math.pow(growthRate, exponent);
|
||||
total = total + cost;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the maximum number of thralls affordable with the available blood.
|
||||
* @param thrall - The thrall tier.
|
||||
* @param blood - The available blood balance.
|
||||
* @returns The maximum affordable quantity.
|
||||
*/
|
||||
const computeMaxAffordable = (
|
||||
thrall: VampireThrall,
|
||||
blood: number,
|
||||
): number => {
|
||||
let total = 0;
|
||||
let quantity = 0;
|
||||
for (let index = 0; index < 100_000; index = index + 1) {
|
||||
const exponent = thrall.count + index;
|
||||
const cost = thrall.baseCost * Math.pow(growthRate, exponent);
|
||||
if (total + cost > blood) {
|
||||
break;
|
||||
}
|
||||
total = total + cost;
|
||||
quantity = quantity + 1;
|
||||
}
|
||||
return quantity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a localStorage string back into a valid BatchSize, defaulting to 1.
|
||||
* @param stored - The raw string from localStorage (or null if absent).
|
||||
* @returns A valid BatchSize value.
|
||||
*/
|
||||
const parseBatchSize = (stored: string | null): BatchSize => {
|
||||
if (stored === "max") {
|
||||
return "max";
|
||||
}
|
||||
if (stored === "10") {
|
||||
return 10;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
interface ThrallCardProperties {
|
||||
readonly thrall: VampireThrall;
|
||||
readonly blood: number;
|
||||
readonly selectedBatch: BatchSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single thrall purchase card.
|
||||
* @param props - The component properties.
|
||||
* @param props.thrall - The thrall tier to display.
|
||||
* @param props.blood - The player's current blood balance.
|
||||
* @param props.selectedBatch - The active batch size selection.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const ThrallCard = ({
|
||||
thrall,
|
||||
blood,
|
||||
selectedBatch,
|
||||
}: ThrallCardProperties): JSX.Element => {
|
||||
const { buyVampireThrall, formatNumber } = useGame();
|
||||
|
||||
const maxAffordable = computeMaxAffordable(thrall, blood);
|
||||
const effectiveBatch = selectedBatch === "max"
|
||||
? maxAffordable
|
||||
: selectedBatch;
|
||||
const batchCost = computeBatchCost(thrall, effectiveBatch);
|
||||
const canAffordBatch = blood >= batchCost && effectiveBatch > 0;
|
||||
|
||||
const singleCost = computeBatchCost(thrall, 1);
|
||||
|
||||
function handleBuy(): void {
|
||||
if (effectiveBatch > 0) {
|
||||
buyVampireThrall(thrall.id, effectiveBatch);
|
||||
}
|
||||
}
|
||||
|
||||
function getBuyButtonLabel(): string {
|
||||
if (selectedBatch === "max") {
|
||||
if (maxAffordable === 0) {
|
||||
return "Can't Afford";
|
||||
}
|
||||
return `Buy Max (×${String(maxAffordable)})`;
|
||||
}
|
||||
return `Buy ×${String(effectiveBatch)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`disciple-card ${thrall.unlocked
|
||||
? ""
|
||||
: "disciple-locked"}`}>
|
||||
<div className="disciple-header">
|
||||
<div className="disciple-title">
|
||||
<h3>{thrall.name}</h3>
|
||||
<span className="disciple-class">{thrall.class}</span>
|
||||
</div>
|
||||
<span className="disciple-count">
|
||||
{"×"}
|
||||
{formatNumber(thrall.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="disciple-income">
|
||||
{thrall.bloodPerSecond > 0
|
||||
&& <span className="income-tag">
|
||||
{"🩸 "}
|
||||
{formatNumber(thrall.bloodPerSecond)}
|
||||
{"/s blood"}
|
||||
</span>
|
||||
}
|
||||
{thrall.ichorPerSecond > 0
|
||||
&& <span className="income-tag">
|
||||
{"💧 "}
|
||||
{formatNumber(thrall.ichorPerSecond)}
|
||||
{"/s ichor"}
|
||||
</span>
|
||||
}
|
||||
<span className="combat-power-tag">
|
||||
{"⚔️ "}
|
||||
{formatNumber(thrall.combatPower)}
|
||||
{" combat power each"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="disciple-cost">
|
||||
<span className="cost-label">
|
||||
{"Next: 🩸 "}
|
||||
{formatNumber(singleCost)}
|
||||
</span>
|
||||
{selectedBatch !== 1
|
||||
&& effectiveBatch > 0
|
||||
&& <span className="cost-label">
|
||||
{selectedBatch === "max"
|
||||
? "Max"
|
||||
: String(selectedBatch)}
|
||||
{" (×"}
|
||||
{String(effectiveBatch)}
|
||||
{"): 🩸 "}
|
||||
{formatNumber(batchCost)}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
{thrall.unlocked
|
||||
? <button
|
||||
className="buy-disciple-button"
|
||||
disabled={!canAffordBatch}
|
||||
onClick={handleBuy}
|
||||
title={
|
||||
canAffordBatch
|
||||
? undefined
|
||||
: `Need 🩸 ${formatNumber(batchCost)} blood`
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{getBuyButtonLabel()}
|
||||
</button>
|
||||
: <span className="disciple-badge locked">{"🔒 Locked"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the thralls panel for purchasing vampire thralls.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireThrallsPanel = (): JSX.Element => {
|
||||
const { state, formatNumber, toggleVampireAutoThrall } = useGame();
|
||||
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
|
||||
return parseBatchSize(localStorage.getItem("elysium_thrall_batch"));
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const vampireState = state.vampire;
|
||||
if (vampireState === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Vampire expansion not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const blood = state.resources.blood ?? 0;
|
||||
const { thralls, autoThrall } = vampireState;
|
||||
const autoThrallOn = autoThrall === true;
|
||||
|
||||
function handleBatchSelect(batch: BatchSize): void {
|
||||
setSelectedBatch(batch);
|
||||
localStorage.setItem("elysium_thrall_batch", String(batch));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel disciples-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Thralls"}</h2>
|
||||
<button
|
||||
className={`auto-toggle ${autoThrallOn
|
||||
? "auto-on"
|
||||
: "auto-off"}`}
|
||||
onClick={toggleVampireAutoThrall}
|
||||
title={autoThrallOn
|
||||
? "Auto-Thrall is ON — click to disable"
|
||||
: "Auto-Thrall is OFF — click to enable"}
|
||||
type="button"
|
||||
>
|
||||
{autoThrallOn
|
||||
? "🤖 Auto-Thrall: ON"
|
||||
: "🤖 Auto-Thrall: OFF"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="disciples-balance">
|
||||
<span>
|
||||
{"🩸 Blood: "}
|
||||
<strong>{formatNumber(blood)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((batch) => {
|
||||
function handleClick(): void {
|
||||
handleBatchSelect(batch);
|
||||
}
|
||||
return <button
|
||||
className={`batch-button ${selectedBatch === batch
|
||||
? "active"
|
||||
: ""}`}
|
||||
key={String(batch)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{batch === "max"
|
||||
? "Max"
|
||||
: `×${String(batch)}`}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="disciples-list">
|
||||
{thralls.map((thrall: VampireThrall) => {
|
||||
return <ThrallCard
|
||||
blood={blood}
|
||||
key={thrall.id}
|
||||
selectedBatch={selectedBatch}
|
||||
thrall={thrall}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireThrallsPanel };
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @file Vampire upgrades panel for purchasing vampire-realm upgrades.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { VampireUpgrade } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* Formats a vampire upgrade cost as a readable string.
|
||||
* @param upgrade - The vampire upgrade.
|
||||
* @param formatNumber - The number formatting utility function.
|
||||
* @returns The formatted cost string.
|
||||
*/
|
||||
const costLabel = (
|
||||
upgrade: VampireUpgrade,
|
||||
formatNumber: (n: number)=> string,
|
||||
): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (upgrade.costBlood > 0) {
|
||||
parts.push(`🩸 ${formatNumber(upgrade.costBlood)}`);
|
||||
}
|
||||
if (upgrade.costIchor > 0) {
|
||||
parts.push(`💧 ${formatNumber(upgrade.costIchor)}`);
|
||||
}
|
||||
if (upgrade.costSoulShards > 0) {
|
||||
parts.push(`💠 ${formatNumber(upgrade.costSoulShards)}`);
|
||||
}
|
||||
return parts.length > 0
|
||||
? parts.join(" ")
|
||||
: "Free";
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a human-readable label for a vampire upgrade target.
|
||||
* @param target - The upgrade target string.
|
||||
* @returns The display label.
|
||||
*/
|
||||
const targetLabel = (target: VampireUpgrade["target"]): string => {
|
||||
const labels: Record<VampireUpgrade["target"], string> = {
|
||||
blood: "Blood",
|
||||
boss: "Boss",
|
||||
global: "Global",
|
||||
siring: "Siring",
|
||||
thrall: "Thrall",
|
||||
};
|
||||
return labels[target];
|
||||
};
|
||||
|
||||
interface VampireUpgradeCardProperties {
|
||||
readonly upgrade: VampireUpgrade;
|
||||
readonly blood: number;
|
||||
readonly ichor: number;
|
||||
readonly soulShards: number;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single vampire upgrade card.
|
||||
* @param props - The card properties.
|
||||
* @param props.upgrade - The vampire upgrade data.
|
||||
* @param props.blood - The player's current blood balance.
|
||||
* @param props.ichor - The player's current ichor balance.
|
||||
* @param props.soulShards - The player's current soul shards balance.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireUpgradeCard = ({
|
||||
upgrade,
|
||||
blood,
|
||||
ichor,
|
||||
soulShards,
|
||||
formatNumber,
|
||||
}: VampireUpgradeCardProperties): JSX.Element => {
|
||||
const { buyVampireUpgrade } = useGame();
|
||||
|
||||
const canAfford
|
||||
= blood >= upgrade.costBlood
|
||||
&& ichor >= upgrade.costIchor
|
||||
&& soulShards >= upgrade.costSoulShards;
|
||||
|
||||
async function handleBuy(): Promise<void> {
|
||||
await buyVampireUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
const multiplierPct = Math.round((upgrade.multiplier - 1) * 100);
|
||||
|
||||
if (upgrade.purchased) {
|
||||
return (
|
||||
<div className="goddess-upgrade-card purchased">
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">
|
||||
{"✅ "}
|
||||
{upgrade.name}
|
||||
</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{upgrade.description}</p>
|
||||
<p className="upgrade-effect">
|
||||
{`×${String(upgrade.multiplier)} (+${String(multiplierPct)}%)`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (upgrade.unlocked) {
|
||||
return (
|
||||
<div className={`goddess-upgrade-card available${canAfford
|
||||
? ""
|
||||
: " cannot-afford"}`}>
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">{upgrade.name}</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{upgrade.description}</p>
|
||||
<p className="upgrade-effect">
|
||||
{`×${String(upgrade.multiplier)} (+${String(multiplierPct)}%)`}
|
||||
</p>
|
||||
{upgrade.thrallId === undefined
|
||||
? null
|
||||
: <p className="upgrade-disciple">
|
||||
{"🧟 Thrall: "}
|
||||
{upgrade.thrallId}
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
className="btn-buy"
|
||||
disabled={!canAfford}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- intentional async handler
|
||||
onClick={handleBuy}
|
||||
title={canAfford
|
||||
? ""
|
||||
: "Not enough resources"}
|
||||
type="button"
|
||||
>
|
||||
{"Buy — "}
|
||||
{costLabel(upgrade, formatNumber)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="goddess-upgrade-card locked">
|
||||
<div className="upgrade-card-header">
|
||||
<span className="upgrade-name">{"🔒 ???"}</span>
|
||||
<span className="upgrade-target-badge">
|
||||
{targetLabel(upgrade.target)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="upgrade-description">{"Not yet unlocked."}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the vampire upgrades panel, displaying all available and purchased upgrades.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireUpgradesPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { resources, vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const blood = resources.blood ?? 0;
|
||||
const { ichor } = vampire.siring;
|
||||
const { soulShards } = vampire.awakening;
|
||||
const { upgrades } = vampire;
|
||||
|
||||
const purchased = upgrades.filter((upgrade) => {
|
||||
return upgrade.purchased;
|
||||
});
|
||||
const available = upgrades.filter((upgrade) => {
|
||||
return upgrade.unlocked && !upgrade.purchased;
|
||||
});
|
||||
const locked = upgrades.filter((upgrade) => {
|
||||
return !upgrade.unlocked && !upgrade.purchased;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel goddess-upgrades-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚔️ Vampire Upgrades"}</h2>
|
||||
</div>
|
||||
<div className="panel-resource-bar">
|
||||
<span className="resource-item">
|
||||
{"🩸 Blood: "}
|
||||
{formatNumber(blood)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"💧 Ichor: "}
|
||||
{formatNumber(ichor)}
|
||||
</span>
|
||||
<span className="resource-item">
|
||||
{"💠 Soul Shards: "}
|
||||
{formatNumber(soulShards)}
|
||||
</span>
|
||||
</div>
|
||||
{available.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Available Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{available.map((upgrade) => {
|
||||
return (
|
||||
<VampireUpgradeCard
|
||||
blood={blood}
|
||||
formatNumber={formatNumber}
|
||||
ichor={ichor}
|
||||
key={upgrade.id}
|
||||
soulShards={soulShards}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{locked.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Locked Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{locked.map((upgrade) => {
|
||||
return (
|
||||
<VampireUpgradeCard
|
||||
blood={blood}
|
||||
formatNumber={formatNumber}
|
||||
ichor={ichor}
|
||||
key={upgrade.id}
|
||||
soulShards={soulShards}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{purchased.length > 0
|
||||
? <section className="upgrades-section">
|
||||
<h3 className="section-heading">{"Purchased Upgrades"}</h3>
|
||||
<div className="upgrades-grid">
|
||||
{purchased.map((upgrade) => {
|
||||
return (
|
||||
<VampireUpgradeCard
|
||||
blood={blood}
|
||||
formatNumber={formatNumber}
|
||||
ichor={ichor}
|
||||
key={upgrade.id}
|
||||
soulShards={soulShards}
|
||||
upgrade={upgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
: null}
|
||||
{upgrades.length === 0
|
||||
? <p className="empty-state">{"No vampire upgrades available yet."}</p>
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireUpgradesPanel };
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file Vampire Zones panel — read-only view of all vampire realms.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex panel with zone grid rendering */
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
||||
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type { VampireZone } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ZoneCardProperties {
|
||||
readonly zone: VampireZone;
|
||||
readonly isLocked: boolean;
|
||||
readonly unlockBossName: string | undefined;
|
||||
readonly unlockQuestName: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single vampire zone card.
|
||||
* @param props - The zone card properties.
|
||||
* @param props.zone - The zone data.
|
||||
* @param props.isLocked - Whether this zone is currently locked.
|
||||
* @param props.unlockBossName - Name of the boss required to unlock, if any.
|
||||
* @param props.unlockQuestName - Name of the quest required to unlock, if any.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireZoneCard = ({
|
||||
zone,
|
||||
isLocked,
|
||||
unlockBossName,
|
||||
unlockQuestName,
|
||||
}: ZoneCardProperties): JSX.Element => {
|
||||
return (
|
||||
<div className={`zone-card${isLocked
|
||||
? " locked"
|
||||
: ""}`}>
|
||||
<div className="zone-card-header">
|
||||
<span aria-hidden="true" className="zone-emoji">{zone.emoji}</span>
|
||||
<h3 className="zone-name">{zone.name}</h3>
|
||||
{isLocked
|
||||
? <span aria-label="Locked" className="zone-lock-icon">{"🔒"}</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<p className="zone-description">{zone.description}</p>
|
||||
|
||||
{isLocked
|
||||
&& (unlockBossName !== undefined || unlockQuestName !== undefined)
|
||||
? <div className="zone-unlock-requirements">
|
||||
<p className="zone-unlock-label">{"Unlock requirements:"}</p>
|
||||
{unlockBossName === undefined
|
||||
? null
|
||||
: <p className="zone-unlock-item">
|
||||
{"🩸 Defeat: "}
|
||||
{unlockBossName}
|
||||
</p>
|
||||
}
|
||||
{unlockQuestName === undefined
|
||||
? null
|
||||
: <p className="zone-unlock-item">
|
||||
{"📜 Complete: "}
|
||||
{unlockQuestName}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
{isLocked
|
||||
? null
|
||||
: <span className="zone-badge unlocked">{"🩸 Unlocked"}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the Vampire Zones panel showing all 18 vampire realms.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const VampireZonesPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { vampire } = state;
|
||||
|
||||
if (vampire === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Vampire expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses: vampireBosses, quests: vampireQuests, zones } = vampire;
|
||||
|
||||
const defeatedBossIds = new Set(
|
||||
vampireBosses.
|
||||
filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).
|
||||
map((boss) => {
|
||||
return boss.id;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="panel vampire-zones-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"🗺️ Vampire Zones"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-grid">
|
||||
{zones.map((zone) => {
|
||||
const isLocked = zone.unlockBossId !== null
|
||||
&& !defeatedBossIds.has(zone.unlockBossId);
|
||||
|
||||
const unlockBoss = zone.unlockBossId === null
|
||||
? undefined
|
||||
: vampireBosses.find((boss) => {
|
||||
return boss.id === zone.unlockBossId;
|
||||
});
|
||||
|
||||
const unlockQuest = zone.unlockQuestId === null
|
||||
? undefined
|
||||
: vampireQuests.find((quest) => {
|
||||
return quest.id === zone.unlockQuestId;
|
||||
});
|
||||
|
||||
return (
|
||||
<VampireZoneCard
|
||||
isLocked={isLocked}
|
||||
key={zone.id}
|
||||
unlockBossName={unlockBoss?.name}
|
||||
unlockQuestName={unlockQuest?.name}
|
||||
zone={zone}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { VampireZonesPanel };
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
computeGoldPerSecond,
|
||||
computePartyCombatPower,
|
||||
computeProjectedRunestones,
|
||||
computeVampireBloodPerSecond,
|
||||
} from "../../engine/tick.js";
|
||||
import type { Resource } from "@elysium/types";
|
||||
|
||||
@@ -86,16 +87,22 @@ const ResourceBar = ({
|
||||
const [ isProfileOpen, setIsProfileOpen ] = useState(false);
|
||||
const [ isResourcesOpen, setIsResourcesOpen ] = useState(false);
|
||||
|
||||
const { gold, essence, crystals } = resources;
|
||||
const { gold, essence, crystals, prayers, divinity, stardust } = resources;
|
||||
const hasApotheosis = apotheosisCount > 0;
|
||||
const blood = resources.blood ?? 0;
|
||||
const ichor = state?.vampire?.siring.ichor ?? 0;
|
||||
const soulShards = state?.vampire?.awakening.soulShards ?? 0;
|
||||
let partyCombatPower = 0;
|
||||
let goldPerSecond = 0;
|
||||
let essencePerSecond = 0;
|
||||
let projectedRunestones = 0;
|
||||
let bloodPerSecond = 0;
|
||||
if (state !== null) {
|
||||
partyCombatPower = computePartyCombatPower(state);
|
||||
goldPerSecond = computeGoldPerSecond(state);
|
||||
essencePerSecond = computeEssencePerSecond(state);
|
||||
projectedRunestones = computeProjectedRunestones(state);
|
||||
bloodPerSecond = computeVampireBloodPerSecond(state);
|
||||
}
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
@@ -251,6 +258,85 @@ const ResourceBar = ({
|
||||
</span>
|
||||
<span className="resource-label">{"Combat Power"}</span>
|
||||
</div>
|
||||
<hr className="resources-divider" />
|
||||
<div className={`resource${hasApotheosis
|
||||
? ""
|
||||
: " resource-locked"}`}>
|
||||
<span className="resource-icon">{"🙏"}</span>
|
||||
<span className="resource-value">
|
||||
{hasApotheosis
|
||||
? formatNumber(prayers ?? 0)
|
||||
: "🔒"}
|
||||
</span>
|
||||
<span className="resource-label">{"Prayers"}</span>
|
||||
</div>
|
||||
<div className={`resource${hasApotheosis
|
||||
? ""
|
||||
: " resource-locked"}`}>
|
||||
<span className="resource-icon">{"✨"}</span>
|
||||
<span className="resource-value">
|
||||
{hasApotheosis
|
||||
? formatNumber(divinity ?? 0)
|
||||
: "🔒"}
|
||||
</span>
|
||||
<span className="resource-label">{"Divinity"}</span>
|
||||
</div>
|
||||
<div className={`resource${hasApotheosis
|
||||
? ""
|
||||
: " resource-locked"}`}>
|
||||
<span className="resource-icon">{"⭐"}</span>
|
||||
<span className="resource-value">
|
||||
{hasApotheosis
|
||||
? formatNumber(stardust ?? 0)
|
||||
: "🔒"}
|
||||
</span>
|
||||
<span className="resource-label">{"Stardust"}</span>
|
||||
</div>
|
||||
<hr className="resources-divider" />
|
||||
<div className={`resource${hasApotheosis
|
||||
? ""
|
||||
: " resource-locked"}`}>
|
||||
<span className="resource-icon">{"📈"}</span>
|
||||
<span className="resource-value">
|
||||
{hasApotheosis
|
||||
? formatNumber(bloodPerSecond)
|
||||
: "🔒"}
|
||||
</span>
|
||||
<span className="resource-label">{"Blood/s"}</span>
|
||||
</div>
|
||||
<div className={`resource${hasApotheosis
|
||||
? ""
|
||||
: " resource-locked"}`}>
|
||||
<span className="resource-icon">{"🩸"}</span>
|
||||
<span className="resource-value">
|
||||
{hasApotheosis
|
||||
? formatNumber(blood)
|
||||
: "🔒"}
|
||||
</span>
|
||||
<span className="resource-label">{"Blood"}</span>
|
||||
</div>
|
||||
<div className={`resource${hasApotheosis
|
||||
? ""
|
||||
: " resource-locked"}`}>
|
||||
<span className="resource-icon">{"💧"}</span>
|
||||
<span className="resource-value">
|
||||
{hasApotheosis
|
||||
? formatNumber(ichor)
|
||||
: "🔒"}
|
||||
</span>
|
||||
<span className="resource-label">{"Ichor"}</span>
|
||||
</div>
|
||||
<div className={`resource${hasApotheosis
|
||||
? ""
|
||||
: " resource-locked"}`}>
|
||||
<span className="resource-icon">{"💠"}</span>
|
||||
<span className="resource-value">
|
||||
{hasApotheosis
|
||||
? formatNumber(soulShards)
|
||||
: "🔒"}
|
||||
</span>
|
||||
<span className="resource-label">{"Soul Shards"}</span>
|
||||
</div>
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* @file Goddess crafting recipe data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
import type { CraftingRecipe } from "@elysium/types";
|
||||
|
||||
export const GODDESS_RECIPES: Array<CraftingRecipe> = [
|
||||
// ── Celestial Garden ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.1 },
|
||||
description: "An amplifier woven from divine petals and crystallised prayer. It does not make prayers louder — it makes them more true.",
|
||||
id: "prayer_amplifier",
|
||||
name: "Prayer Amplifier",
|
||||
requiredMaterials: [
|
||||
{ materialId: "divine_petal", quantity: 3 },
|
||||
{ materialId: "prayer_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.1 },
|
||||
description: "Celestial dust ground into prayer crystals creates a focus that sharpens disciples' divine combat instincts.",
|
||||
id: "celestial_focus",
|
||||
name: "Celestial Focus",
|
||||
requiredMaterials: [
|
||||
{ materialId: "celestial_dust", quantity: 2 },
|
||||
{ materialId: "prayer_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
// ── Crystal Sanctum ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.15 },
|
||||
description: "Holy ink dissolved in sanctum water, then set with shard dust. Disciples who drink it can recite any prayer they have heard exactly once.",
|
||||
id: "oracle_potion",
|
||||
name: "Oracle Potion",
|
||||
requiredMaterials: [
|
||||
{ materialId: "holy_ink", quantity: 2 },
|
||||
{ materialId: "sanctum_shard", quantity: 3 },
|
||||
],
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.2 },
|
||||
description: "A lens ground from an oracle fragment and set in holy ink. Through it, the divine flow of the universe becomes briefly legible.",
|
||||
id: "lens_of_truth",
|
||||
name: "Lens of Truth",
|
||||
requiredMaterials: [
|
||||
{ materialId: "oracle_lens_fragment", quantity: 1 },
|
||||
{ materialId: "holy_ink", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
// ── Astral Cathedral ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description: "A balm prepared from seraph feathers and choir essence. Applied before battle, it gives disciples the brief sensation of having wings.",
|
||||
id: "seraph_balm",
|
||||
name: "Seraph Balm",
|
||||
requiredMaterials: [
|
||||
{ materialId: "seraph_feather", quantity: 3 },
|
||||
{ materialId: "choir_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.2 },
|
||||
description: "Astral glass dissolved in choir essence creates a vial of concentrated resonance. One drop per disciple per morning.",
|
||||
id: "astral_resonance_vial",
|
||||
name: "Astral Resonance Vial",
|
||||
requiredMaterials: [
|
||||
{ materialId: "astral_glass", quantity: 2 },
|
||||
{ materialId: "choir_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
// ── Empyrean Citadel ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.25 },
|
||||
description: "Empyrean ore refined with divine alloy dust and pressed into a blessing-coin. The citadel uses these to sanctify each new weapon forged.",
|
||||
id: "empyrean_blessing",
|
||||
name: "Empyrean Blessing",
|
||||
requiredMaterials: [
|
||||
{ materialId: "empyrean_ore", quantity: 3 },
|
||||
{ materialId: "divine_alloy", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
description: "A tincture prepared from a champion's medal and divine alloy filings. Dosed by champions before major engagements.",
|
||||
id: "champions_tincture",
|
||||
name: "Champion's Tincture",
|
||||
requiredMaterials: [
|
||||
{ materialId: "celestial_medal", quantity: 1 },
|
||||
{ materialId: "divine_alloy", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
// ── Primordial Springs ───────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.3 },
|
||||
description: "Creation water distilled with primordial essence — the closest thing to bottled genesis. Handle as if the universe is watching.",
|
||||
id: "springs_elixir",
|
||||
name: "Springs Elixir",
|
||||
requiredMaterials: [
|
||||
{ materialId: "creation_water", quantity: 3 },
|
||||
{ materialId: "primordial_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.35 },
|
||||
description: "A genesis crystal dissolved in creation water — a brew of pure origination. Disciples who drink it briefly remember what it felt like to not yet exist.",
|
||||
id: "genesis_brew",
|
||||
name: "Genesis Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "genesis_crystal", quantity: 1 },
|
||||
{ materialId: "creation_water", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
// ── Eternal Firmament ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.35 },
|
||||
description: "A ward inscribed on firmament stone using divine light shards as the writing medium. Disciples who carry it are protected by permanence itself.",
|
||||
id: "firmament_ward",
|
||||
name: "Firmament Ward",
|
||||
requiredMaterials: [
|
||||
{ materialId: "firmament_stone", quantity: 2 },
|
||||
{ materialId: "divine_light_shard", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.4 },
|
||||
description: "An eternity fragment set in divine light — a lantern that never dims because it is powered by time itself. It illuminates things that do not normally have light.",
|
||||
id: "eternity_lantern",
|
||||
name: "Eternity Lantern",
|
||||
requiredMaterials: [
|
||||
{ materialId: "eternity_fragment", quantity: 1 },
|
||||
{ materialId: "divine_light_shard", quantity: 3 },
|
||||
],
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
// ── Sacred Grove ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.4 },
|
||||
description: "Grove resin mixed with luminous leaf extract and set around sacred heartwood creates a talisman that grows warmer in the presence of sincere prayer.",
|
||||
id: "grove_talisman",
|
||||
name: "Grove Talisman",
|
||||
requiredMaterials: [
|
||||
{ materialId: "grove_resin", quantity: 3 },
|
||||
{ materialId: "luminous_leaf", quantity: 2 },
|
||||
{ materialId: "sacred_heartwood", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.4 },
|
||||
description: "Sacred heartwood carved into a pendant and sealed with grove resin. Disciples who wear it feel the grove's centuries of reverence as personal strength.",
|
||||
id: "heartwood_pendant",
|
||||
name: "Heartwood Pendant",
|
||||
requiredMaterials: [
|
||||
{ materialId: "sacred_heartwood", quantity: 2 },
|
||||
{ materialId: "grove_resin", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
// ── Luminous Expanse ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.45 },
|
||||
description: "Captured radiance compressed around a light core — a beacon that broadcasts the goddess's presence to disciples too far away to feel it unaided.",
|
||||
id: "radiance_beacon",
|
||||
name: "Radiance Beacon",
|
||||
requiredMaterials: [
|
||||
{ materialId: "captured_radiance", quantity: 3 },
|
||||
{ materialId: "light_core", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.45 },
|
||||
description: "A pool of radiance encased in glass and suspended on radiance pool solution — a focusing lens that amplifies divine light into something measurable.",
|
||||
id: "luminous_prism",
|
||||
name: "Luminous Prism",
|
||||
requiredMaterials: [
|
||||
{ materialId: "radiance_pool", quantity: 2 },
|
||||
{ materialId: "captured_radiance", quantity: 2 },
|
||||
{ materialId: "light_core", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
// ── Heavenly Forge ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.5 },
|
||||
description: "Forge scale layered over divine slag and inlaid with a forge gem — a gauntlet that channels the forge's sacred heat into every blow struck.",
|
||||
id: "forge_gauntlet",
|
||||
name: "Forge Gauntlet",
|
||||
requiredMaterials: [
|
||||
{ materialId: "forge_scale", quantity: 3 },
|
||||
{ materialId: "divine_slag", quantity: 2 },
|
||||
{ materialId: "forge_gem", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.5 },
|
||||
description: "A forge gem set in refined divine slag — a crucible that converts ambient prayer energy directly into essence. Runs continuously once lit.",
|
||||
id: "divine_crucible",
|
||||
name: "Divine Crucible",
|
||||
requiredMaterials: [
|
||||
{ materialId: "forge_gem", quantity: 2 },
|
||||
{ materialId: "divine_slag", quantity: 3 },
|
||||
],
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
// ── Oracle Sanctum ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.55 },
|
||||
description: "Vision residue suspended in prophecy crystal solution — an elixir that grants disciples fleeting precognitive awareness of lucrative opportunities.",
|
||||
id: "oracle_elixir",
|
||||
name: "Oracle Elixir",
|
||||
requiredMaterials: [
|
||||
{ materialId: "vision_residue", quantity: 3 },
|
||||
{ materialId: "prophecy_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.55 },
|
||||
description: "A fate shard mounted on a prophecy crystal matrix — when a disciple holds it before battle, they briefly see the outcome and can choose their approach accordingly.",
|
||||
id: "fate_compass",
|
||||
name: "Fate Compass",
|
||||
requiredMaterials: [
|
||||
{ materialId: "fate_shard", quantity: 1 },
|
||||
{ materialId: "prophecy_crystal", quantity: 3 },
|
||||
{ materialId: "vision_residue", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
// ── Seraph's Nest ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.6 },
|
||||
description: "Seraph down woven into a mantle and sealed with seraph primary barbs — wearing it is indistinguishable from being held by something enormous and gentle.",
|
||||
id: "seraph_mantle",
|
||||
name: "Seraph Mantle",
|
||||
requiredMaterials: [
|
||||
{ materialId: "seraph_down", quantity: 4 },
|
||||
{ materialId: "seraph_primary", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.6 },
|
||||
description: "An ascended quill carved into a focus rod — it channels divine will with zero resistance, as though the goddess herself were guiding every motion.",
|
||||
id: "ascended_focus",
|
||||
name: "Ascended Focus",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ascended_quill", quantity: 1 },
|
||||
{ materialId: "seraph_primary", quantity: 2 },
|
||||
{ materialId: "seraph_down", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
// ── Divine Archive ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.65 },
|
||||
description: "Celestial vellum stamped with archive seals and pressed into a portable codex — disciples who carry it can access divine knowledge in the field.",
|
||||
id: "field_codex",
|
||||
name: "Field Codex",
|
||||
requiredMaterials: [
|
||||
{ materialId: "celestial_vellum", quantity: 4 },
|
||||
{ materialId: "archive_seal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.65 },
|
||||
description: "A living codex page bound with archive seals — it updates itself with new divine knowledge continuously, and disciples gain essence simply by proximity.",
|
||||
id: "living_tome",
|
||||
name: "Living Tome",
|
||||
requiredMaterials: [
|
||||
{ materialId: "living_codex_page", quantity: 2 },
|
||||
{ materialId: "archive_seal", quantity: 2 },
|
||||
{ materialId: "celestial_vellum", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
// ── Consecrated Depths ───────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.7 },
|
||||
description: "Consecrated stone carved into armour plates and blessed with depth blessing — the armour remembers every prayer spoken over it and adds their weight to the wearer.",
|
||||
id: "depth_armour",
|
||||
name: "Depth Armour",
|
||||
requiredMaterials: [
|
||||
{ materialId: "consecrated_stone", quantity: 3 },
|
||||
{ materialId: "depth_blessing", quantity: 2 },
|
||||
{ materialId: "abyssal_gem", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.7 },
|
||||
description: "An abyssal gem set in depth blessing solution — a vessel that draws essence from the deepest consecrated places and stores it for release when needed.",
|
||||
id: "abyssal_vessel",
|
||||
name: "Abyssal Vessel",
|
||||
requiredMaterials: [
|
||||
{ materialId: "abyssal_gem", quantity: 2 },
|
||||
{ materialId: "depth_blessing", quantity: 3 },
|
||||
],
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
// ── Astral Confluence ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.75 },
|
||||
description: "Confluence shards woven into a prism using astral harmonics — it refracts divine energy across multiple streams simultaneously, multiplying its effective output.",
|
||||
id: "confluence_prism",
|
||||
name: "Confluence Prism",
|
||||
requiredMaterials: [
|
||||
{ materialId: "confluence_shard", quantity: 3 },
|
||||
{ materialId: "astral_harmonic", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.75 },
|
||||
description: "A convergence node bound in astral harmonic resonance — a weapon core that channels the power of seven converging astral streams into a single devastating point.",
|
||||
id: "convergence_core",
|
||||
name: "Convergence Core",
|
||||
requiredMaterials: [
|
||||
{ materialId: "convergence_node", quantity: 1 },
|
||||
{ materialId: "astral_harmonic", quantity: 3 },
|
||||
{ materialId: "confluence_shard", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
// ── Celestial Throne ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.8 },
|
||||
description: "Throne gold leaf pressed over a sovereignty gem — a signet whose mark carries the full weight of divine authority and cannot be questioned.",
|
||||
id: "sovereignty_signet",
|
||||
name: "Sovereignty Signet",
|
||||
requiredMaterials: [
|
||||
{ materialId: "throne_gold_leaf", quantity: 3 },
|
||||
{ materialId: "sovereignty_gem", quantity: 1 },
|
||||
],
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.8 },
|
||||
description: "A crown fragment set in sovereignty gem and trimmed with throne gold — the fragment remembers every ruling ever passed from the throne and channels that authority.",
|
||||
id: "crown_relic",
|
||||
name: "Crown Relic",
|
||||
requiredMaterials: [
|
||||
{ materialId: "crown_fragment", quantity: 1 },
|
||||
{ materialId: "sovereignty_gem", quantity: 2 },
|
||||
{ materialId: "throne_gold_leaf", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
// ── Infinite Choir ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.85 },
|
||||
description: "Choir notes compressed with divine resonance into a single instrument — playing it fills nearby disciples with the choir's infinite devotion and multiplies their essence output.",
|
||||
id: "resonance_instrument",
|
||||
name: "Resonance Instrument",
|
||||
requiredMaterials: [
|
||||
{ materialId: "choir_note", quantity: 4 },
|
||||
{ materialId: "divine_resonance", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.85 },
|
||||
description: "The sacred chord crystallised and mounted on a divine resonance matrix — its vibration disrupts the coherence of any force that opposes the goddess's will.",
|
||||
id: "sacred_chord_matrix",
|
||||
name: "Sacred Chord Matrix",
|
||||
requiredMaterials: [
|
||||
{ materialId: "sacred_chord", quantity: 1 },
|
||||
{ materialId: "divine_resonance", quantity: 2 },
|
||||
{ materialId: "choir_note", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
// ── The Veil ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.9 },
|
||||
description: "Veil thread woven with liminal essence — a cloak that allows the wearer to exist partially outside reality, drawing essence from both sides of the divide.",
|
||||
id: "liminal_cloak",
|
||||
name: "Liminal Cloak",
|
||||
requiredMaterials: [
|
||||
{ materialId: "veil_thread", quantity: 3 },
|
||||
{ materialId: "liminal_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.9 },
|
||||
description: "A beyond fragment encased in liminal essence and bound with veil thread — a weapon that strikes from a direction reality does not expect and cannot easily defend against.",
|
||||
id: "veil_piercer",
|
||||
name: "Veil Piercer",
|
||||
requiredMaterials: [
|
||||
{ materialId: "beyond_fragment", quantity: 1 },
|
||||
{ materialId: "liminal_essence", quantity: 3 },
|
||||
{ materialId: "veil_thread", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
// ── Divine Heart ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 2 },
|
||||
description: "Heart pulses suspended in divine love crystal matrix — an amplifier that broadcasts the goddess's love as a measurable economic force. Disciples work harder when they feel it.",
|
||||
id: "heart_amplifier",
|
||||
name: "Heart Amplifier",
|
||||
requiredMaterials: [
|
||||
{ materialId: "heart_pulse", quantity: 4 },
|
||||
{ materialId: "divine_love_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 2 },
|
||||
description: "Heart ichor distilled with divine love crystal into an essence that transforms willingness into unstoppable force. The goddess's love, weaponised. She approves.",
|
||||
id: "divine_heart_essence",
|
||||
name: "Divine Heart Essence",
|
||||
requiredMaterials: [
|
||||
{ materialId: "heart_ichor", quantity: 1 },
|
||||
{ materialId: "divine_love_crystal", quantity: 2 },
|
||||
{ materialId: "heart_pulse", quantity: 2 },
|
||||
],
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* @file Goddess exploration area data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
|
||||
export interface GoddessExplorationAreaSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
zoneId: string;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
export const GODDESS_EXPLORATION_AREAS: Array<GoddessExplorationAreaSummary> = [
|
||||
// ── Celestial Garden ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A sun-dappled clearing where divine flowers grow in patterns that mirror the constellation of the goddess's birth.",
|
||||
durationSeconds: 30,
|
||||
id: "garden_glade",
|
||||
name: "The Garden Glade",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "A vast meadow at the garden's edge where the divine light is diffused into something even novice disciples can bathe in safely.",
|
||||
durationSeconds: 60,
|
||||
id: "celestial_meadow",
|
||||
name: "The Celestial Meadow",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "A garden path lined with divine blooms whose petals chime softly in a wind that comes from no discernible direction.",
|
||||
durationSeconds: 90,
|
||||
id: "chiming_path",
|
||||
name: "The Chiming Path",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "The garden's sacred heart, where the oldest divine tree grows — its roots reaching down into the foundation of the goddess's realm.",
|
||||
durationSeconds: 120,
|
||||
id: "sacred_tree",
|
||||
name: "The Sacred Tree",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
// ── Crystal Sanctum ──────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Corridors of living crystal where divine knowledge has been stored in crystalline form — some of it loud enough to hear if you stand still.",
|
||||
durationSeconds: 180,
|
||||
id: "crystal_halls",
|
||||
name: "The Crystal Halls",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "A reading room where crystallised scripture lines the walls from floor to ceiling. The texts update themselves as new prayers are offered.",
|
||||
durationSeconds: 240,
|
||||
id: "scripture_room",
|
||||
name: "The Scripture Room",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The sanctum's innermost chamber, where the oracle constructs reside in silent contemplation. Visitors are permitted but not encouraged.",
|
||||
durationSeconds: 300,
|
||||
id: "oracle_chamber",
|
||||
name: "The Oracle's Chamber",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The sanctum's bell tower, whose crystal bells ring only when a mortal somewhere achieves genuine enlightenment.",
|
||||
durationSeconds: 420,
|
||||
id: "bell_tower",
|
||||
name: "The Bell Tower",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
// ── Astral Cathedral ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The high rafters of the cathedral where seraphs rest between duties. The light here has weight and warmth.",
|
||||
durationSeconds: 360,
|
||||
id: "seraph_roost",
|
||||
name: "The Seraph Roost",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "The cathedral's crypt, where past seraphs have left offerings — and where the offerings have taken on a life of their own.",
|
||||
durationSeconds: 480,
|
||||
id: "cathedral_crypt",
|
||||
name: "The Cathedral Crypt",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "The cathedral's central nave, where the choir sings continuously and the resonance physically alters the space around it.",
|
||||
durationSeconds: 540,
|
||||
id: "astral_nave",
|
||||
name: "The Astral Nave",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "The soaring spire above the cathedral — the highest point of any constructed divine structure, touching the boundary between the astral and the divine.",
|
||||
durationSeconds: 720,
|
||||
id: "cathedral_spire",
|
||||
name: "The Cathedral Spire",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
// ── Empyrean Citadel ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The citadel's weapon-forging district, where divine alloy is hammered into arms for the celestial host. The sound never stops.",
|
||||
durationSeconds: 600,
|
||||
id: "forge_district",
|
||||
name: "The Forge District",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "The outer ramparts of the citadel, where sentinels have stood watch for millennia. The view from here encompasses all of creation.",
|
||||
durationSeconds: 720,
|
||||
id: "outer_ramparts",
|
||||
name: "The Outer Ramparts",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "The great hall where the champions of the citadel are recognised and their deeds recorded on walls that will outlast the universe.",
|
||||
durationSeconds: 900,
|
||||
id: "champions_hall",
|
||||
name: "The Champions Hall",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "The citadel's war council chamber, where every major celestial engagement has been planned. The maps on the walls show conflicts that have not yet happened.",
|
||||
durationSeconds: 1200,
|
||||
id: "war_council",
|
||||
name: "The War Council",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
// ── Primordial Springs ───────────────────────────────────────────────────
|
||||
{
|
||||
description: "The main basin of the primordial springs — shallow enough to approach, deep enough to understand that you are standing in the source of everything.",
|
||||
durationSeconds: 900,
|
||||
id: "springs_basin",
|
||||
name: "The Springs Basin",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "A cascade where creation water falls from an impossible height before it reaches the basin — catching the spray is considered a blessing.",
|
||||
durationSeconds: 1200,
|
||||
id: "creation_cascade",
|
||||
name: "The Creation Cascade",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "The deepest accessible point of the springs — where the creation energy runs so thick it has begun to crystallise.",
|
||||
durationSeconds: 1800,
|
||||
id: "genesis_pools",
|
||||
name: "The Genesis Pools",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "An island in the springs' centre where nothing has been created yet — pure potential, unformed, waiting. Disciples report feeling deeply uncomfortable and deeply at peace simultaneously.",
|
||||
durationSeconds: 2400,
|
||||
id: "unformed_isle",
|
||||
name: "The Unformed Isle",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
// ── Eternal Firmament ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "An observatory built into the firmament's outer edge — the highest point from which the universe can be observed without leaving it.",
|
||||
durationSeconds: 1800,
|
||||
id: "eternal_observatory",
|
||||
name: "The Eternal Observatory",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "The firmament's boundary wall — where the divine realm ends and the void begins. Looking over the edge is permitted, but not recommended.",
|
||||
durationSeconds: 2700,
|
||||
id: "boundary_wall",
|
||||
name: "The Boundary Wall",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "The firmament's great archive — where every divine event, every prayer, every moment of faith is recorded in materials that cannot be destroyed.",
|
||||
durationSeconds: 3600,
|
||||
id: "divine_archive",
|
||||
name: "The Divine Archive",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "A lighthouse built at the firmament's highest point to guide returning divine beings home. Its light has not gone out in recorded history.",
|
||||
durationSeconds: 4800,
|
||||
id: "eternal_lighthouse",
|
||||
name: "The Eternal Lighthouse",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
// ── Sacred Grove ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The grove's outermost ring, where ancient trees have grown together into a living canopy that screens out all light save the divine.",
|
||||
durationSeconds: 3600,
|
||||
id: "canopy_ring",
|
||||
name: "The Canopy Ring",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "A clearing deep in the grove where divine light falls in columns and the ground glows softly with accumulated radiance.",
|
||||
durationSeconds: 5400,
|
||||
id: "radiant_clearing",
|
||||
name: "The Radiant Clearing",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "The base of the grove's elder tree — a tree so old its roots have grown into the foundations of divinity itself.",
|
||||
durationSeconds: 7200,
|
||||
id: "elder_roots",
|
||||
name: "The Elder Roots",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "The grove's summit — where the eldest trees have grown so tall their crowns pierce the veil and exist in two realms simultaneously.",
|
||||
durationSeconds: 10_800,
|
||||
id: "grove_summit",
|
||||
name: "The Grove Summit",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
// ── Luminous Expanse ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The shore of the luminous expanse — where solid ground meets the sea of light and the boundary shimmers with captured radiance.",
|
||||
durationSeconds: 5400,
|
||||
id: "luminous_shore",
|
||||
name: "The Luminous Shore",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "A deep pool in the expanse where radiance has collected over eons into a liquid form. Wading in it is an act of faith.",
|
||||
durationSeconds: 7200,
|
||||
id: "deep_pool",
|
||||
name: "The Deep Pool",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "The absolute centre of the luminous expanse — where all the light originates. The core pulses rhythmically, like breathing.",
|
||||
durationSeconds: 10_800,
|
||||
id: "radiance_core",
|
||||
name: "The Radiance Core",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "Columns of pure light rising from the expanse's floor to its ceiling — each one a prayer that became so strong it solidified.",
|
||||
durationSeconds: 14_400,
|
||||
id: "prayer_columns",
|
||||
name: "The Prayer Columns",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
// ── Heavenly Forge ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The antechamber of the heavenly forge — where materials are prepared and disciples learn what they are about to witness.",
|
||||
durationSeconds: 7200,
|
||||
id: "forge_antechamber",
|
||||
name: "The Forge Antechamber",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "The forge's central burning chamber — divine fire so hot it refines the soul of whatever is placed within it.",
|
||||
durationSeconds: 10_800,
|
||||
id: "burning_chamber",
|
||||
name: "The Burning Chamber",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "The cooling vault where newly forged divine items rest — the temperature here is merely scorching, and the rejected gems settle here.",
|
||||
durationSeconds: 14_400,
|
||||
id: "cooling_vault",
|
||||
name: "The Cooling Vault",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "The forge master's sanctum — a private workshop where the most powerful divine weapons have been conceived. The blueprints on the walls are weapons that have never needed to be made.",
|
||||
durationSeconds: 18_000,
|
||||
id: "forge_master_sanctum",
|
||||
name: "The Forge Master's Sanctum",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
// ── Oracle Sanctum ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The oracle sanctum's entrance hall — lined with prophecies that have already come true, displayed as a reminder of what is possible.",
|
||||
durationSeconds: 10_800,
|
||||
id: "prophecy_hall",
|
||||
name: "The Prophecy Hall",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The sanctum's vision pool — where oracles submerge themselves to receive prophecy. The water carries the echo of every vision ever seen here.",
|
||||
durationSeconds: 14_400,
|
||||
id: "vision_pool",
|
||||
name: "The Vision Pool",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The sanctum's sealed vault where unfulfilled prophecies are kept — each one waiting for its moment. The vault hums with potential.",
|
||||
durationSeconds: 18_000,
|
||||
id: "prophecy_vault",
|
||||
name: "The Prophecy Vault",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The highest oracle tower — where the most powerful seers work, receiving prophecy directly from the goddess without the pool as an intermediary.",
|
||||
durationSeconds: 25_200,
|
||||
id: "oracle_tower",
|
||||
name: "The Oracle Tower",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
// ── Seraph's Nest ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outer reaches of the seraph's nest — where young seraphs practice flight and lose feathers at a prodigious rate.",
|
||||
durationSeconds: 14_400,
|
||||
id: "nesting_grounds",
|
||||
name: "The Nesting Grounds",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "The nest's flight path — a long corridor of open sky where seraphs travel at full speed. Standing on the observation platform is exhilarating.",
|
||||
durationSeconds: 18_000,
|
||||
id: "flight_path",
|
||||
name: "The Flight Path",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "The inner nest where the eldest seraphs rest — so old they have transcended their original purpose and exist now as pure radiant being.",
|
||||
durationSeconds: 25_200,
|
||||
id: "elder_nest",
|
||||
name: "The Elder Nest",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "The topmost pinnacle of the seraph's nest — where newly ascended seraphs make their first flight into the infinite divine sky.",
|
||||
durationSeconds: 32_400,
|
||||
id: "ascension_pinnacle",
|
||||
name: "The Ascension Pinnacle",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
// ── Divine Archive ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The archive's reading room — open to approved disciples, lined with texts that contain everything except what you happen to be looking for.",
|
||||
durationSeconds: 18_000,
|
||||
id: "reading_room",
|
||||
name: "The Reading Room",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "The archive's filing chambers — where seals are applied to documents and every record is formally entered into the divine record.",
|
||||
durationSeconds: 21_600,
|
||||
id: "filing_chambers",
|
||||
name: "The Filing Chambers",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "The restricted stacks — where texts too dangerous, too sacred, or too contradictory to be read by casual visitors are stored.",
|
||||
durationSeconds: 28_800,
|
||||
id: "restricted_stacks",
|
||||
name: "The Restricted Stacks",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "The archive's inner sanctum — where the most fundamental records of existence are kept. The room is aware of visitors and takes notes.",
|
||||
durationSeconds: 36_000,
|
||||
id: "archive_inner_sanctum",
|
||||
name: "The Archive Inner Sanctum",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
// ── Consecrated Depths ───────────────────────────────────────────────────
|
||||
{
|
||||
description: "The upper consecrated chambers — where generations of devotion have saturated the stone so thoroughly it generates warmth without fire.",
|
||||
durationSeconds: 21_600,
|
||||
id: "upper_chambers",
|
||||
name: "The Upper Chambers",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "The sacred springs of the consecrated depths — underground water that has absorbed so much blessing it produces light in complete darkness.",
|
||||
durationSeconds: 28_800,
|
||||
id: "sacred_springs",
|
||||
name: "The Sacred Springs",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "The abyssal chamber — the deepest point of the consecrated depths, where gem formations grow in complete darkness fed by divine groundwater.",
|
||||
durationSeconds: 36_000,
|
||||
id: "abyssal_chamber",
|
||||
name: "The Abyssal Chamber",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "The heart of the consecrated depths — where the first consecration rite was performed, and where all subsequent rites draw their power from.",
|
||||
durationSeconds: 46_800,
|
||||
id: "consecration_heart",
|
||||
name: "The Consecration Heart",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
// ── Astral Confluence ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The confluence's outer streams — where astral currents from different zones first meet and begin their complex negotiation of coexistence.",
|
||||
durationSeconds: 28_800,
|
||||
id: "outer_streams",
|
||||
name: "The Outer Streams",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "The harmonic bridge — a structure built where two major astral streams run parallel, bridging them for easier transit.",
|
||||
durationSeconds: 36_000,
|
||||
id: "harmonic_bridge",
|
||||
name: "The Harmonic Bridge",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "The confluence point — where seven astral streams meet simultaneously and the combined energy forms something that has no name in any divine language.",
|
||||
durationSeconds: 50_400,
|
||||
id: "confluence_point",
|
||||
name: "The Confluence Point",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "The still eye at the confluence's centre — where paradoxically the seven streams produce complete stillness. Nothing moves here. Everything is possible.",
|
||||
durationSeconds: 64_800,
|
||||
id: "still_eye",
|
||||
name: "The Still Eye",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
// ── Celestial Throne ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The throne room's antechamber — where petitioners wait to be heard and the air is thick with suppressed hope.",
|
||||
durationSeconds: 36_000,
|
||||
id: "throne_antechamber",
|
||||
name: "The Throne Antechamber",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "The throne room's gallery — where divine decisions are witnessed and the significance of each ruling is carved into the gallery walls in gold.",
|
||||
durationSeconds: 50_400,
|
||||
id: "throne_gallery",
|
||||
name: "The Throne Gallery",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "The throne room itself — the seat of divine authority, where the goddess makes her will known. Visitors are rare and never forget it.",
|
||||
durationSeconds: 72_000,
|
||||
id: "throne_room",
|
||||
name: "The Throne Room",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "The private garden behind the celestial throne — where the goddess retreats between audiences, and where all decisions that will be made have already been decided.",
|
||||
durationSeconds: 90_000,
|
||||
id: "private_garden",
|
||||
name: "The Private Garden",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
// ── Infinite Choir ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outer choir stalls — where the newest members of the infinite choir learn the oldest songs. Every mistake is a new prayer.",
|
||||
durationSeconds: 50_400,
|
||||
id: "outer_stalls",
|
||||
name: "The Outer Stalls",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The resonance chamber — where the choir's harmonics are amplified and directed. The walls physically vibrate with accumulated song.",
|
||||
durationSeconds: 64_800,
|
||||
id: "resonance_chamber",
|
||||
name: "The Resonance Chamber",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The sacred chord vault — where the fundamental harmonics of existence are preserved in crystalline form and protected from degradation.",
|
||||
durationSeconds: 79_200,
|
||||
id: "chord_vault",
|
||||
name: "The Chord Vault",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The conductor's podium at the infinite choir's centre — from here, every voice in the infinite song can be heard and directed. The conductor has never stopped.",
|
||||
durationSeconds: 97_200,
|
||||
id: "conductors_podium",
|
||||
name: "The Conductor's Podium",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
// ── The Veil ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The veil's outer face — where the divine realm ends in a shimmering boundary that is beautiful from this side and, reportedly, equally beautiful from the other.",
|
||||
durationSeconds: 64_800,
|
||||
id: "veil_outer_face",
|
||||
name: "The Veil's Outer Face",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
description: "The liminal space within the veil itself — where nothing is fully either side, and everything exists in a state of permanent becoming.",
|
||||
durationSeconds: 86_400,
|
||||
id: "liminal_space",
|
||||
name: "The Liminal Space",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
description: "The veil's inner face — where it can be seen from the divine side, and where the fragments of what lies beyond press closest.",
|
||||
durationSeconds: 108_000,
|
||||
id: "veil_inner_face",
|
||||
name: "The Veil's Inner Face",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
description: "The tear in the veil — a wound in the boundary that has been carefully managed for longer than recorded history. Looking through it is the closest to truth any mortal has come.",
|
||||
durationSeconds: 129_600,
|
||||
id: "veil_tear",
|
||||
name: "The Veil Tear",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
// ── Divine Heart ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outer chamber of the divine heart — where the pulse is felt as a physical pressure and the air tastes of warmth and purpose.",
|
||||
durationSeconds: 86_400,
|
||||
id: "heart_outer_chamber",
|
||||
name: "The Heart's Outer Chamber",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
description: "The love crystal garden — where the divine heart's love has crystallised over aeons into formations of impossible beauty.",
|
||||
durationSeconds: 108_000,
|
||||
id: "love_garden",
|
||||
name: "The Love Crystal Garden",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
description: "The inner sanctum of the divine heart — where the beating is so powerful it can be felt in the bones, and the warmth is the warmth of being loved without condition.",
|
||||
durationSeconds: 151_200,
|
||||
id: "heart_inner_sanctum",
|
||||
name: "The Heart's Inner Sanctum",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
description: "The divine heart itself — the source of all divinity, the origin of the goddess, the first thing that ever was and the last thing that will ever be.",
|
||||
durationSeconds: 172_800,
|
||||
id: "divine_heart_itself",
|
||||
name: "The Divine Heart",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* @file Goddess crafting material data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
import type { Material } from "@elysium/types";
|
||||
|
||||
export const GODDESS_MATERIALS: Array<Material> = [
|
||||
// ── Celestial Garden ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Petals from flowers that have never known anything but divine light. They crumble if touched by anything unworthy.",
|
||||
id: "divine_petal",
|
||||
name: "Divine Petal",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "Prayer energy that has crystallised over centuries of devotion. Each one holds a fragment of someone's deepest hope.",
|
||||
id: "prayer_crystal",
|
||||
name: "Prayer Crystal",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
{
|
||||
description: "Dust that falls from the celestial dome above — each mote a fragment of a star that finished its purpose and dissolved.",
|
||||
id: "celestial_dust",
|
||||
name: "Celestial Dust",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_celestial_garden",
|
||||
},
|
||||
// ── Crystal Sanctum ──────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Shards broken from the sanctum walls during divine resonance events. They hum faintly with stored knowledge.",
|
||||
id: "sanctum_shard",
|
||||
name: "Sanctum Shard",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Ink distilled from divine light and used to inscribe the sanctum's most sacred texts. It cannot write falsehoods.",
|
||||
id: "holy_ink",
|
||||
name: "Holy Ink",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
{
|
||||
description: "A fragment of an oracle's primary lens, shattered during a vision of catastrophic clarity. The vision was worth it.",
|
||||
id: "oracle_lens_fragment",
|
||||
name: "Oracle Lens Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_crystal_sanctum",
|
||||
},
|
||||
// ── Astral Cathedral ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A feather shed by a seraph during their first ascension. They shed exactly one. This is the rarest thing most people will ever hold.",
|
||||
id: "seraph_feather",
|
||||
name: "Seraph Feather",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "The concentrated resonance of the celestial choir — bottled by scholars who noticed that it had physical properties.",
|
||||
id: "choir_essence",
|
||||
name: "Choir Essence",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
{
|
||||
description: "Material formed where the cathedral's astral structure meets the void. Transparent, harder than diamond, warmer than sunlight.",
|
||||
id: "astral_glass",
|
||||
name: "Astral Glass",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_astral_cathedral",
|
||||
},
|
||||
// ── Empyrean Citadel ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Ore mined from the citadel's deepest foundations — dense with divine potential but raw and unrefined.",
|
||||
id: "empyrean_ore",
|
||||
name: "Empyrean Ore",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "Empyrean ore refined in the citadel's divine furnaces. The process requires both technical mastery and genuine faith.",
|
||||
id: "divine_alloy",
|
||||
name: "Divine Alloy",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
{
|
||||
description: "A medal awarded only to champions of the citadel's trials. Fewer than a hundred exist. Each one has a name engraved on the back.",
|
||||
id: "celestial_medal",
|
||||
name: "Celestial Medal",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_empyrean_citadel",
|
||||
},
|
||||
// ── Primordial Springs ───────────────────────────────────────────────────
|
||||
{
|
||||
description: "Water drawn directly from the springs of creation. It tastes of nothing. It heals everything. Handle carefully.",
|
||||
id: "creation_water",
|
||||
name: "Creation Water",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "The raw essence of creation — the stuff from which everything is made before it decides what to become.",
|
||||
id: "primordial_essence",
|
||||
name: "Primordial Essence",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
{
|
||||
description: "A crystal formed spontaneously when creation energy reaches critical density. Each one is unique and has never existed before.",
|
||||
id: "genesis_crystal",
|
||||
name: "Genesis Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_primordial_springs",
|
||||
},
|
||||
// ── Eternal Firmament ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Stone from the eternal firmament itself — impossibly dense, impossibly enduring. It does not weather. It does not age.",
|
||||
id: "firmament_stone",
|
||||
name: "Firmament Stone",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "A shard of divine light that has solidified — the kind of light that exists before it is observed, before it is named.",
|
||||
id: "divine_light_shard",
|
||||
name: "Divine Light Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
{
|
||||
description: "A fragment broken from eternity itself during a moment of divine turbulence. It is still vibrating. It will never stop.",
|
||||
id: "eternity_fragment",
|
||||
name: "Eternity Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_eternal_firmament",
|
||||
},
|
||||
// ── Sacred Grove ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Resin weeping from the sacred grove's eldest trees — each drop takes decades to form and carries the memory of every prayer offered beneath its branches.",
|
||||
id: "grove_resin",
|
||||
name: "Grove Resin",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "A leaf that has absorbed so much divine light it has become semi-translucent, like stained glass grown naturally from a living tree.",
|
||||
id: "luminous_leaf",
|
||||
name: "Luminous Leaf",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
{
|
||||
description: "Bark shed from the grove's most ancient tree — said to be the first thing the goddess ever touched. No axe can cut it. No fire can burn it.",
|
||||
id: "sacred_heartwood",
|
||||
name: "Sacred Heartwood",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_sacred_grove",
|
||||
},
|
||||
// ── Luminous Expanse ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The ambient radiance of the luminous expanse, captured in small crystalline vessels before it dissipates. Warm to the touch always.",
|
||||
id: "captured_radiance",
|
||||
name: "Captured Radiance",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "Where radiance pools deep enough, it begins to behave like water. This is a vial of that impossible substance.",
|
||||
id: "radiance_pool",
|
||||
name: "Radiance Pool",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
{
|
||||
description: "A perfect sphere of compressed luminous energy — formed only at the expanse's absolute centre, where the light meets itself coming back.",
|
||||
id: "light_core",
|
||||
name: "Light Core",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_luminous_expanse",
|
||||
},
|
||||
// ── Heavenly Forge ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Scale from a celestial creature shed near the forge — tempered by proximity to divine fire into something harder than most metals.",
|
||||
id: "forge_scale",
|
||||
name: "Forge Scale",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "The slag produced when divine alloy is refined to its purest form. Useless for most things. Priceless for the right ones.",
|
||||
id: "divine_slag",
|
||||
name: "Divine Slag",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
{
|
||||
description: "A gem formed in the forge's hottest chamber — absorbs heat and releases it as blessing energy over years. Handle with tongs.",
|
||||
id: "forge_gem",
|
||||
name: "Forge Gem",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_heavenly_forge",
|
||||
},
|
||||
// ── Oracle Sanctum ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The residue left behind when an oracle's vision ends — collected from the floor of the viewing chamber before it evaporates.",
|
||||
id: "vision_residue",
|
||||
name: "Vision Residue",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Crystals that form in the minds of oracles during particularly intense visions and are expelled as small shards afterward.",
|
||||
id: "prophecy_crystal",
|
||||
name: "Prophecy Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
{
|
||||
description: "A shard of pure foresight — carved from the moment between a prophecy being spoken and it being understood. Extremely dangerous to hold for long.",
|
||||
id: "fate_shard",
|
||||
name: "Fate Shard",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_oracle_sanctum",
|
||||
},
|
||||
// ── Seraph's Nest ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Down from the innermost layer of a seraph's plumage — softer than anything natural, warm as sunlight, impossible to soil.",
|
||||
id: "seraph_down",
|
||||
name: "Seraph Down",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "A primary feather from a seraph's wing — longer than a person is tall, capable of carrying aloft far more than its size suggests.",
|
||||
id: "seraph_primary",
|
||||
name: "Seraph Primary",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
{
|
||||
description: "The hollow quill of a fully ascended seraph — said to channel divine will as faithfully as any sacred instrument ever made.",
|
||||
id: "ascended_quill",
|
||||
name: "Ascended Quill",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_seraphs_nest",
|
||||
},
|
||||
// ── Divine Archive ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Vellum produced from materials that do not exist in the mortal world — can hold text that cannot be written on ordinary parchment.",
|
||||
id: "celestial_vellum",
|
||||
name: "Celestial Vellum",
|
||||
rarity: "common",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "A stamp used to seal the archive's most important documents — its mark cannot be forged and cannot be removed.",
|
||||
id: "archive_seal",
|
||||
name: "Archive Seal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
{
|
||||
description: "A codex page that has absorbed so much divine knowledge it has become semi-sentient. It resists being filed incorrectly.",
|
||||
id: "living_codex_page",
|
||||
name: "Living Codex Page",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_divine_archive",
|
||||
},
|
||||
// ── Consecrated Depths ───────────────────────────────────────────────────
|
||||
{
|
||||
description: "Stone from the deepest consecrated chambers — blessed so thoroughly by generations of ritual that it radiates faint warmth in complete darkness.",
|
||||
id: "consecrated_stone",
|
||||
name: "Consecrated Stone",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "Water from the depths' sacred underground springs — it has been blessed so many times that blessing it again produces light.",
|
||||
id: "depth_blessing",
|
||||
name: "Depth Blessing",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
{
|
||||
description: "A gem found only at the absolute lowest point of the consecrated depths — formed from minerals and divine energy in equal parts.",
|
||||
id: "abyssal_gem",
|
||||
name: "Abyssal Gem",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_consecrated_depths",
|
||||
},
|
||||
// ── Astral Confluence ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A shard of ley-material harvested where two astral streams cross — vibrates at two frequencies simultaneously and cannot decide which to settle on.",
|
||||
id: "confluence_shard",
|
||||
name: "Confluence Shard",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "The harmonic tone produced when multiple astral streams converge — bottled by scholars with sensitive enough ears to find it before it propagated away.",
|
||||
id: "astral_harmonic",
|
||||
name: "Astral Harmonic",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
{
|
||||
description: "A knot of astral energy so dense it has become material — formed only at confluence points of seven or more streams. Profoundly stable.",
|
||||
id: "convergence_node",
|
||||
name: "Convergence Node",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_astral_confluence",
|
||||
},
|
||||
// ── Celestial Throne ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Gold leaf beaten so thin it is translucent — used to gild the throne's ceremonial surfaces and shed during every royal audience.",
|
||||
id: "throne_gold_leaf",
|
||||
name: "Throne Gold Leaf",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "A gem that fell from the throne's armrest during a momentous divine decision. It carries the weight of that decision.",
|
||||
id: "sovereignty_gem",
|
||||
name: "Sovereignty Gem",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
{
|
||||
description: "A fragment of the divine crown — shed when the goddess channels her most absolute authority. Still crackles with that authority.",
|
||||
id: "crown_fragment",
|
||||
name: "Crown Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_celestial_throne",
|
||||
},
|
||||
// ── Infinite Choir ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A note from the infinite choir crystallised mid-air — visible proof that sound, given enough devotion, can become matter.",
|
||||
id: "choir_note",
|
||||
name: "Choir Note",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The resonant frequency of the infinite choir, captured in a tuning fork made of condensed praise. Struck, it harmonises everything nearby.",
|
||||
id: "divine_resonance",
|
||||
name: "Divine Resonance",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
{
|
||||
description: "The chord that underlies all sacred music — crystallised in a moment of perfect harmony that has not occurred before or since.",
|
||||
id: "sacred_chord",
|
||||
name: "Sacred Chord",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_infinite_choir",
|
||||
},
|
||||
// ── The Veil ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A thread of the veil itself — taken from where it has worn thinnest. Still partially transparent. Still partially something else.",
|
||||
id: "veil_thread",
|
||||
name: "Veil Thread",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
description: "The liminal substance that exists only at the veil's boundary — neither fully divine nor fully void, but something genuinely new.",
|
||||
id: "liminal_essence",
|
||||
name: "Liminal Essence",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
{
|
||||
description: "A fragment of what lies beyond the veil — contained only by the veil-thread it's wrapped in. Looking at it directly is inadvisable.",
|
||||
id: "beyond_fragment",
|
||||
name: "Beyond Fragment",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_veil",
|
||||
},
|
||||
// ── Divine Heart ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A pulse of the divine heart made tangible — each one a single beat, still warm, still rhythmic, still alive with purpose.",
|
||||
id: "heart_pulse",
|
||||
name: "Heart Pulse",
|
||||
rarity: "uncommon",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
description: "The pure love of the divine heart, distilled into crystalline form — the most powerful healing agent in existence and the most dangerous to waste.",
|
||||
id: "divine_love_crystal",
|
||||
name: "Divine Love Crystal",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
{
|
||||
description: "A droplet of ichor from the divine heart itself — the essence of divinity in its most concentrated form. Handle with absolute reverence.",
|
||||
id: "heart_ichor",
|
||||
name: "Heart Ichor",
|
||||
rarity: "rare",
|
||||
zoneId: "goddess_divine_heart",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* @file Vampire crafting recipe data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
import type { CraftingRecipe } from "@elysium/types";
|
||||
|
||||
export const VAMPIRE_RECIPES: Array<CraftingRecipe> = [
|
||||
// ── Haunted Catacombs ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.1 },
|
||||
description: "Bone dust boiled with grave essence produces a thick extract that resonates with the catacombs' ancient hunger. Those who consume it briefly see in total darkness.",
|
||||
id: "bone_dust_extract",
|
||||
name: "Bone Dust Extract",
|
||||
requiredMaterials: [
|
||||
{ materialId: "bone_dust", quantity: 3 },
|
||||
{ materialId: "grave_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.1 },
|
||||
description: "Catacomb ash worked into a paste with grave essence, then applied to weapons before battle. The ash remembers every fight these tunnels have witnessed.",
|
||||
id: "catacomb_tonic",
|
||||
name: "Catacomb Tonic",
|
||||
requiredMaterials: [
|
||||
{ materialId: "catacomb_ash", quantity: 2 },
|
||||
{ materialId: "grave_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
// ── Blood Mire ────────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.1 },
|
||||
description: "Mire sludge filtered through blood moss produces a dense poultice that, when applied correctly, amplifies the feeding reflex across all thralls in range.",
|
||||
id: "mire_poultice",
|
||||
name: "Mire Poultice",
|
||||
requiredMaterials: [
|
||||
{ materialId: "mire_sludge", quantity: 3 },
|
||||
{ materialId: "blood_moss", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.1 },
|
||||
description: "Blood moss steeped in crimson reed sap makes a foul-smelling brew that is nevertheless extremely popular before fights — it dulls pain and sharpens reflex.",
|
||||
id: "blood_moss_brew",
|
||||
name: "Blood Moss Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "blood_moss", quantity: 3 },
|
||||
{ materialId: "crimson_reed", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
// ── Obsidian Keep ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.15 },
|
||||
description: "Obsidian chips ground into a paste with iron shavings make an abrasive compound used to hone weapons. The resulting edge carries a trace of the Keep's blood magic.",
|
||||
id: "obsidian_edge",
|
||||
name: "Obsidian Edge Compound",
|
||||
requiredMaterials: [
|
||||
{ materialId: "obsidian_chip", quantity: 3 },
|
||||
{ materialId: "iron_shaving", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.15 },
|
||||
description: "Keep mortar dissolved into a slurry with iron shavings creates a sealant that, when applied to the feeding chambers, prevents blood loss between hunts.",
|
||||
id: "keep_mortar_mix",
|
||||
name: "Keep Mortar Mix",
|
||||
requiredMaterials: [
|
||||
{ materialId: "keep_mortar", quantity: 1 },
|
||||
{ materialId: "iron_shaving", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
// ── Crimson Citadel ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.15 },
|
||||
description: "Citadel stone powder mixed with blood bronze filings creates a seal that, when pressed into the architecture of a feeding ground, amplifies the blood yield of the space.",
|
||||
id: "citadel_seal",
|
||||
name: "Citadel Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "citadel_stone", quantity: 2 },
|
||||
{ materialId: "blood_bronze", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description: "Crimson silk wrapped around weapons before battle absorbs moonlight during the process. Thralls armed with these wrapped weapons fight with unusual composure.",
|
||||
id: "crimson_silk_wrap",
|
||||
name: "Crimson Silk Wrap",
|
||||
requiredMaterials: [
|
||||
{ materialId: "crimson_silk", quantity: 1 },
|
||||
{ materialId: "blood_bronze", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
// ── Shadow Court ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.2 },
|
||||
description: "Shadow thread woven into a net and suspended over feeding grounds creates an obscuring field that encourages prey to walk toward the hunter.",
|
||||
id: "shadow_thread_weave",
|
||||
name: "Shadow Thread Weave",
|
||||
requiredMaterials: [
|
||||
{ materialId: "shadow_thread", quantity: 4 },
|
||||
{ materialId: "whisper_ink", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.2 },
|
||||
description: "Whisper ink recorded with secrets about the ichor trade and sealed with court wax. Reading it reveals techniques for extracting greater ichor yield during the siring rite.",
|
||||
id: "whisper_ink_tome",
|
||||
name: "Whisper Ink Tome",
|
||||
requiredMaterials: [
|
||||
{ materialId: "whisper_ink", quantity: 2 },
|
||||
{ materialId: "court_wax", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
// ── Plague Ossuary ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.2 },
|
||||
description: "Plague ash worked into a paste with ossuary resin and applied to thrall weapons before battle. The pestilence that lingers in the ash makes opponents hesitate.",
|
||||
id: "plague_ash_remedy",
|
||||
name: "Plague Ash Weapon Coat",
|
||||
requiredMaterials: [
|
||||
{ materialId: "plague_ash", quantity: 3 },
|
||||
{ materialId: "ossuary_resin", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.2 },
|
||||
description: "Infected bone ground down and mixed with ossuary resin creates a sealant for feeding vessels that prevents spoilage and stretches each harvest considerably further.",
|
||||
id: "ossuary_resin_coat",
|
||||
name: "Ossuary Preservation Coat",
|
||||
requiredMaterials: [
|
||||
{ materialId: "infected_bone", quantity: 2 },
|
||||
{ materialId: "ossuary_resin", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
// ── Ashen Wastes ──────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.25 },
|
||||
description: "Volatile compounds produced when volcanic ash and cinder crystals are combined make an excellent weapon coating — the resulting strike burns in ways cold steel cannot.",
|
||||
id: "volcanic_ash_bomb",
|
||||
name: "Volcanic Ash Bomb",
|
||||
requiredMaterials: [
|
||||
{ materialId: "volcanic_ash", quantity: 3 },
|
||||
{ materialId: "cinder_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.25 },
|
||||
description: "Ashen cloth soaked in volcanic ash produces a wrapping for the body that insulates against heat and disperses the blood-scent of the wearer, making them harder to detect.",
|
||||
id: "ashen_cloth_wrap",
|
||||
name: "Ashen Cloth Wrapping",
|
||||
requiredMaterials: [
|
||||
{ materialId: "ashen_cloth", quantity: 2 },
|
||||
{ materialId: "volcanic_ash", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
// ── The Iron Gaol ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
description: "Iron rivets combined with a length of chain link produce a weapon wrap that adds both weight and containment glyph resonance to every strike.",
|
||||
id: "iron_chain_shackle",
|
||||
name: "Iron Chain Shackle",
|
||||
requiredMaterials: [
|
||||
{ materialId: "iron_rivet", quantity: 3 },
|
||||
{ materialId: "chain_link", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.3 },
|
||||
description: "Gaol stone ground and packed with iron rivets into a floor-sealing mortar. The despair absorbed into the stone makes the feeding ground more effective at producing passive blood.",
|
||||
id: "gaol_stone_mortar",
|
||||
name: "Gaol Stone Mortar",
|
||||
requiredMaterials: [
|
||||
{ materialId: "gaol_stone", quantity: 1 },
|
||||
{ materialId: "iron_rivet", quantity: 4 },
|
||||
],
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
// ── Veilborn Hollow ───────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.3 },
|
||||
description: "Veil thread woven through the structure of a feeding ground creates small tears in the boundary between worlds. Blood that passes through these tears is somehow more potent.",
|
||||
id: "veil_thread_weave",
|
||||
name: "Veil Thread Weave",
|
||||
requiredMaterials: [
|
||||
{ materialId: "veil_thread", quantity: 4 },
|
||||
{ materialId: "hollow_crystal", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.3 },
|
||||
description: "Phantom dust mixed with hollow crystal powder creates a potion that, when consumed, allows thralls to partially phase during the first moments of a fight — before the enemy can react.",
|
||||
id: "phantom_dust_potion",
|
||||
name: "Phantom Dust Potion",
|
||||
requiredMaterials: [
|
||||
{ materialId: "phantom_dust", quantity: 1 },
|
||||
{ materialId: "hollow_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
// ── Moonless Moor ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.35 },
|
||||
description: "Moor peat rendered with fog essence produces a slow-burning fuel that warms the feeding ground whilst simultaneously obscuring its location from outsiders.",
|
||||
id: "moor_peat_tonic",
|
||||
name: "Moor Peat Fuel",
|
||||
requiredMaterials: [
|
||||
{ materialId: "moor_peat", quantity: 3 },
|
||||
{ materialId: "fog_essence", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.35 },
|
||||
description: "A brew of night bloom petals steeped in fog essence produces a drink that heightens the predator's senses to impossible levels for a brief, battle-winning window.",
|
||||
id: "fog_essence_brew",
|
||||
name: "Fog Essence Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "fog_essence", quantity: 3 },
|
||||
{ materialId: "night_bloom", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
// ── The Sunken Crypt ──────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.4 },
|
||||
description: "Sunken stone coated in drowned silk becomes a permanent feeding vessel — the silk prevents evaporation and the stone's porous structure allows remarkable volume.",
|
||||
id: "sunken_stone_seal",
|
||||
name: "Sunken Stone Vessel",
|
||||
requiredMaterials: [
|
||||
{ materialId: "sunken_stone", quantity: 2 },
|
||||
{ materialId: "drowned_silk", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.4 },
|
||||
description: "Deep amber dissolved in a solvent derived from sunken stone — the resulting extract amplifies ichor yield during siring by resonating with the amber's preserved fragments.",
|
||||
id: "deep_amber_extract",
|
||||
name: "Deep Amber Extract",
|
||||
requiredMaterials: [
|
||||
{ materialId: "deep_amber", quantity: 1 },
|
||||
{ materialId: "sunken_stone", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
// ── Desecrated Sanctum ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.4 },
|
||||
description: "Defiled marble carved into a totem and inscribed with dark incense smoke. The desecrated memory in the marble makes it an effective focus for battle rites.",
|
||||
id: "defiled_marble_totem",
|
||||
name: "Defiled Marble Totem",
|
||||
requiredMaterials: [
|
||||
{ materialId: "defiled_marble", quantity: 3 },
|
||||
{ materialId: "dark_incense", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.4 },
|
||||
description: "Dark incense burned in a vessel made of sanctum glass creates a ritual smoke that saturates a feeding ground with the hunger of the desecrated, amplifying all blood yield.",
|
||||
id: "dark_incense_ritual",
|
||||
name: "Dark Incense Ritual",
|
||||
requiredMaterials: [
|
||||
{ materialId: "dark_incense", quantity: 2 },
|
||||
{ materialId: "sanctum_glass", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
// ── Carrion Peaks ─────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.45 },
|
||||
description: "Carrion bone worked into a talisman and inlaid with peak crystal shards creates a focus for the predator's instinct — thralls carrying it fight with the certainty of the high hunt.",
|
||||
id: "carrion_bone_talisman",
|
||||
name: "Carrion Bone Talisman",
|
||||
requiredMaterials: [
|
||||
{ materialId: "carrion_bone", quantity: 3 },
|
||||
{ materialId: "peak_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.45 },
|
||||
description: "Blood obsidian edges ground from peak crystal and bonded to carrion bone handles — weapons that are as much ritual object as instrument of predation.",
|
||||
id: "blood_obsidian_edge",
|
||||
name: "Blood Obsidian Edge",
|
||||
requiredMaterials: [
|
||||
{ materialId: "blood_obsidian", quantity: 1 },
|
||||
{ materialId: "peak_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
// ── The Bloodspire ────────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.5 },
|
||||
description: "Spire stone carved into a seal and inscribed with blood crystal resonance. When placed at the centre of a feeding ground, it draws blood from the surrounding area passively.",
|
||||
id: "spire_stone_seal",
|
||||
name: "Spire Stone Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "spire_stone", quantity: 3 },
|
||||
{ materialId: "blood_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.5 },
|
||||
description: "Ancient gore dissolved in a blood crystal suspension — a highly potent ichor catalyst that resonates with the Spire's pre-existing blood magic to enhance ichor production dramatically.",
|
||||
id: "blood_crystal_extract",
|
||||
name: "Blood Crystal Extract",
|
||||
requiredMaterials: [
|
||||
{ materialId: "blood_crystal", quantity: 3 },
|
||||
{ materialId: "ancient_gore", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
// ── Shroud of Eternity ────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.5 },
|
||||
description: "Eternity thread woven through a feeding space creates a temporal fold that causes each feeding to last slightly longer than it should. The blood never quite finishes flowing.",
|
||||
id: "eternity_thread_weave",
|
||||
name: "Eternity Thread Weave",
|
||||
requiredMaterials: [
|
||||
{ materialId: "eternity_thread", quantity: 4 },
|
||||
{ materialId: "shroud_dust", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.5 },
|
||||
description: "Timeless amber dissolved and reset in a shroud dust medium creates a capsule that, when broken before battle, briefly accelerates the thrall's perception of time.",
|
||||
id: "timeless_amber_brew",
|
||||
name: "Timeless Amber Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "timeless_amber", quantity: 1 },
|
||||
{ materialId: "shroud_dust", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
// ── The Abyssal Vault ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.6 },
|
||||
description: "Abyssal stone inscribed with void crystal dust creates a seal that, when placed in a feeding ground, creates a pocket of absolute silence — prey within it cannot call for help.",
|
||||
id: "abyssal_stone_seal",
|
||||
name: "Abyssal Stone Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "abyssal_stone", quantity: 3 },
|
||||
{ materialId: "void_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
bonus: { type: "combat_power", value: 1.6 },
|
||||
description: "Void crystal ground and bonded to vault iron makes a weapon component that strikes with the force of absolute inevitability — opponents don't question whether they will fall, only when.",
|
||||
id: "void_crystal_totem",
|
||||
name: "Void Crystal Weapon Core",
|
||||
requiredMaterials: [
|
||||
{ materialId: "void_crystal", quantity: 2 },
|
||||
{ materialId: "vault_iron", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
// ── Court of Whispers ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.6 },
|
||||
description: "Whisper parchment inscribed with silent ink contains the distilled knowledge of the Court's ichor trade. Reading it aloud triggers a resonance that permanently enhances ichor yield.",
|
||||
id: "whisper_parchment_tome",
|
||||
name: "Whisper Parchment Tome",
|
||||
requiredMaterials: [
|
||||
{ materialId: "whisper_parchment", quantity: 2 },
|
||||
{ materialId: "silent_ink", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.6 },
|
||||
description: "Silent ink mixed with court crystal powder creates a medium for a feeding ritual that cannot be detected by anyone not already participating — the blood flows and no one outside knows.",
|
||||
id: "silent_ink_ritual",
|
||||
name: "Silent Ink Ritual",
|
||||
requiredMaterials: [
|
||||
{ materialId: "silent_ink", quantity: 1 },
|
||||
{ materialId: "court_crystal", quantity: 3 },
|
||||
],
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
// ── The Eternal Abyss ─────────────────────────────────────────────────────
|
||||
{
|
||||
bonus: { type: "gold_income", value: 1.75 },
|
||||
description: "Void essence rendered in an eternal crystal medium produces a brew of impossible potency. Something about the combination makes every subsequent feeding feel like the first — and the first is always the best.",
|
||||
id: "void_essence_brew",
|
||||
name: "Void Essence Brew",
|
||||
requiredMaterials: [
|
||||
{ materialId: "void_essence", quantity: 3 },
|
||||
{ materialId: "eternal_crystal", quantity: 2 },
|
||||
],
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
bonus: { type: "essence_income", value: 1.75 },
|
||||
description: "An eternal crystal seal made with primordial ash creates a focus for ichor resonance that has no upper bound — the older the vampire who sets it, the more it yields.",
|
||||
id: "eternal_crystal_seal",
|
||||
name: "Eternal Crystal Seal",
|
||||
requiredMaterials: [
|
||||
{ materialId: "eternal_crystal", quantity: 3 },
|
||||
{ materialId: "primordial_ash", quantity: 1 },
|
||||
],
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @file Vampire equipment set data for the Elysium game.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs, SCREAMING_SNAKE constants, and numeric bonus keys are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Data content */
|
||||
import type { VampireEquipmentSet } from "@elysium/types";
|
||||
|
||||
const VAMPIRE_EQUIPMENT_SETS: Array<VampireEquipmentSet> = [
|
||||
{
|
||||
bonuses: {
|
||||
2: { bloodMultiplier: 1.15 },
|
||||
3: { combatMultiplier: 1.1 },
|
||||
},
|
||||
description: "The starter relics of a newly awakened vampire — mismatched, imperfect, and entirely adequate for the catacombs. Every legend begins with gear this humble.",
|
||||
id: "catacombs_hunter",
|
||||
name: "Catacomb Hunter",
|
||||
pieces: [ "shard_fang", "blood_fang", "tattered_shroud", "blood_shroud", "bone_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { bloodMultiplier: 1.2 },
|
||||
3: { combatMultiplier: 1.15 },
|
||||
},
|
||||
description: "Equipment forged in the fires of early conquest — in the mire's depths and the obsidian corridors. Functional, battle-tested, and smelling faintly of old blood.",
|
||||
id: "blood_stalker",
|
||||
name: "Blood Stalker",
|
||||
pieces: [ "war_fang", "obsidian_fang", "obsidian_shroud", "crimson_shroud", "blood_talisman", "obsidian_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { bloodMultiplier: 1.25 },
|
||||
3: { ichorMultiplier: 1.2 },
|
||||
},
|
||||
description: "The arms of a vampire who has learned to move through courts as easily as through darkness. These pieces announce arrival before the wearer does.",
|
||||
id: "crimson_regent",
|
||||
name: "Crimson Regent",
|
||||
pieces: [ "crimson_fang", "shadow_fang", "shadow_shroud", "plague_shroud", "crimson_talisman", "shadow_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.3 },
|
||||
3: { bloodMultiplier: 1.2 },
|
||||
},
|
||||
description: "Equipment sourced from the most dangerous zones of the middle realm — places where even other vampires refuse to hunt. The gear carries the memory of every survival it enabled.",
|
||||
id: "plague_bringer",
|
||||
name: "Plague Bringer",
|
||||
pieces: [ "plague_fang", "ashen_fang", "ashen_shroud", "iron_shroud", "plague_talisman", "ashen_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.35 },
|
||||
3: { bloodMultiplier: 1.25 },
|
||||
},
|
||||
description: "The arms of a vampire who has broken open prisons and walked through veils. These pieces have seen the inside of places most vampires only hear about in old stories.",
|
||||
id: "iron_jailer",
|
||||
name: "Iron Jailer",
|
||||
pieces: [ "iron_fang", "veil_fang", "veil_shroud", "moor_shroud", "iron_talisman", "veil_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { bloodMultiplier: 1.3 },
|
||||
3: { combatMultiplier: 1.3 },
|
||||
},
|
||||
description: "Equipment forged in the moonless reaches and recovered from sunken depths. The pieces were each retrieved at significant cost, which they repay with significant interest.",
|
||||
id: "moonlit_predator",
|
||||
name: "Moonlit Predator",
|
||||
pieces: [ "moonless_fang", "sunken_fang", "sunken_shroud", "sanctum_shroud", "moor_talisman", "sunken_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { combatMultiplier: 1.4 },
|
||||
3: { ichorMultiplier: 1.3 },
|
||||
},
|
||||
description: "The regalia of desecration and apex predation — taken from places where even the concept of sanctuary has been dismantled. Each piece is a monument to the absence of mercy.",
|
||||
id: "sanctum_desecrator",
|
||||
name: "Sanctum Desecrator",
|
||||
pieces: [ "sanctum_fang", "carrion_fang", "carrion_shroud", "spire_shroud", "sanctum_talisman", "carrion_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { bloodMultiplier: 1.4 },
|
||||
3: { combatMultiplier: 1.45 },
|
||||
},
|
||||
description: "The arms of a vampire who has conquered both time and blood — relics of the Bloodspire and the Shroud. These pieces are older than the zones they came from.",
|
||||
id: "eternal_tyrant",
|
||||
name: "Eternal Tyrant",
|
||||
pieces: [ "spire_fang", "shroud_fang", "eternity_shroud", "abyss_shroud", "spire_talisman", "eternity_talisman" ],
|
||||
},
|
||||
{
|
||||
bonuses: {
|
||||
2: { ichorMultiplier: 1.5 },
|
||||
3: { bloodMultiplier: 1.5 },
|
||||
},
|
||||
description: "The complete arms of a vampire who has stood at the edge of the void and returned. These pieces no longer belong to any zone. They belong to whatever you have become.",
|
||||
id: "void_sovereign",
|
||||
name: "Void Sovereign",
|
||||
pieces: [ "abyss_fang", "eternal_fang", "whisper_shroud", "eternal_shroud", "abyss_talisman", "whisper_talisman" ],
|
||||
},
|
||||
];
|
||||
|
||||
export { VAMPIRE_EQUIPMENT_SETS };
|
||||
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* @file Vampire exploration area data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
|
||||
export interface VampireExplorationAreaSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
zoneId: string;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
export const VAMPIRE_EXPLORATION_AREAS: Array<VampireExplorationAreaSummary> = [
|
||||
// ── Haunted Catacombs ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A collapsed funeral chamber where the ancient dead rest in crumbling alcoves. Bone dust coats every surface, and the air is thick with the smell of old stone and older blood.",
|
||||
durationSeconds: 30,
|
||||
id: "bone_chapel",
|
||||
name: "The Bone Chapel",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
description: "Row upon row of sealed burial niches line these tunnels, each one marked with a name no living tongue remembers. Something older than memory lingers between them.",
|
||||
durationSeconds: 60,
|
||||
id: "dusty_crypts",
|
||||
name: "The Dusty Crypts",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
description: "A vast underground hall lined with stacked bones arranged into grotesque patterns by some forgotten custodian. The walls seem to breathe.",
|
||||
durationSeconds: 90,
|
||||
id: "ossuary_hall",
|
||||
name: "The Ossuary Hall",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
description: "The deepest reachable chamber in the catacombs, sealed for centuries by iron doors and older wards. Whatever was locked inside has long since stopped trying to get out.",
|
||||
durationSeconds: 120,
|
||||
id: "deep_vault",
|
||||
name: "The Deep Vault",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
// ── Blood Mire ────────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outermost edge of the mire, where the ground turns soft and the water runs red at dusk. Easy hunting, if you do not mind wet feet.",
|
||||
durationSeconds: 45,
|
||||
id: "shallow_fens",
|
||||
name: "The Shallow Fens",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
description: "Channels of dark water choked with crimson reeds that drink from the blood-saturated soil. Navigating them requires patience your thralls are still learning.",
|
||||
durationSeconds: 90,
|
||||
id: "reed_channels",
|
||||
name: "The Reed Channels",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
description: "The heart of the mire, where the water no longer moves and the bog has absorbed decades of blood into its soil. Everything here smells of iron and rot.",
|
||||
durationSeconds: 135,
|
||||
id: "crimson_bog",
|
||||
name: "The Crimson Bog",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
description: "The lowest point of the mire, where the ground gives way entirely to a sunken pool of near-black water. What lies at the bottom has never been retrieved. Until now.",
|
||||
durationSeconds: 180,
|
||||
id: "sanguine_depths",
|
||||
name: "The Sanguine Depths",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
// ── Obsidian Keep ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outermost gate of the keep, where the obsidian walls begin and the guards — mortal and otherwise — maintain their first and last easy watch.",
|
||||
durationSeconds: 60,
|
||||
id: "gatehouse",
|
||||
name: "The Gatehouse",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
description: "The curtain walls of the keep, built from obsidian quarried under moonlight by those who knew what they were building. The stone holds memory.",
|
||||
durationSeconds: 120,
|
||||
id: "outer_walls",
|
||||
name: "The Outer Walls",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
description: "The central chamber of the keep, where its builders once gathered under vaulted obsidian ceilings to conduct their rites. The architecture is still intact. The builders are not.",
|
||||
durationSeconds: 180,
|
||||
id: "great_hall",
|
||||
name: "The Great Hall",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
description: "The uppermost tower of the keep, built entirely from a single vein of black volcanic glass. From here, on a clear night, you can see the edge of every zone you have claimed.",
|
||||
durationSeconds: 240,
|
||||
id: "black_spire",
|
||||
name: "The Black Spire",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
// ── Crimson Citadel ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outer gate of the citadel, forged from blood bronze and flanked by statues of former lords whose eyes still track movement. A well-travelled entrance. Well-guarded.",
|
||||
durationSeconds: 75,
|
||||
id: "bronze_gate",
|
||||
name: "The Bronze Gate",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
description: "The residential wing of the citadel, draped in crimson silk and furnished for lords who expected to live forever. Some of them still do.",
|
||||
durationSeconds: 150,
|
||||
id: "silk_quarters",
|
||||
name: "The Silk Quarters",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
description: "Deep in the citadel's undercroft, a forge still burns with a flame no mortal lit. The blood bronze used to build these walls was smelted here, and the furnace has never gone cold.",
|
||||
durationSeconds: 225,
|
||||
id: "blood_forge",
|
||||
name: "The Blood Forge",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
description: "The seat of power at the citadel's heart — a throne carved from a single block of blood-red stone, still warm. Whoever sat here last has not been gone long.",
|
||||
durationSeconds: 300,
|
||||
id: "crimson_throne",
|
||||
name: "The Crimson Throne",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
// ── Shadow Court ──────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The waiting room of the Shadow Court, where petitioners once sat in silence and shadows pooled at midday. The silence has never left.",
|
||||
durationSeconds: 90,
|
||||
id: "antechamber",
|
||||
name: "The Antechamber",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
description: "A vast archive where the Court recorded every whisper ever spoken within its walls — sealed in wax, transcribed in ink that never fades. Knowledge is power here, and power has a price.",
|
||||
durationSeconds: 180,
|
||||
id: "ink_library",
|
||||
name: "The Ink Library",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
description: "The ritual heart of the Court, where candles of black wax have burned for decades without ever shortening. The light they cast reveals things better left unseen.",
|
||||
durationSeconds: 270,
|
||||
id: "wax_sanctum",
|
||||
name: "The Wax Sanctum",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
description: "The Court's innermost chamber, where every whisper ever collected returns as an echo. The throne here is not a seat of power — it is a seat of listening. What it hears, it keeps.",
|
||||
durationSeconds: 360,
|
||||
id: "throne_of_whispers",
|
||||
name: "The Throne of Whispers",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
// ── Plague Ossuary ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The entrance to the ossuary, where plague-dead were brought centuries ago and never properly interred. The halls smell of ash and something sweeter, and worse.",
|
||||
durationSeconds: 105,
|
||||
id: "charnel_entry",
|
||||
name: "The Charnel Entry",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
description: "Open pits where the bodies were burned when the ossuary filled — the ash here is deep enough to sink to the knee. Nothing from the pits should still be moving.",
|
||||
durationSeconds: 210,
|
||||
id: "ash_pits",
|
||||
name: "The Ash Pits",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
description: "Sealed chambers deep in the ossuary where the plague's most potent victims were locked away in resin-hardened sarcophagi. The resin has held. Mostly.",
|
||||
durationSeconds: 315,
|
||||
id: "resin_vaults",
|
||||
name: "The Resin Vaults",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
description: "The source — the chamber where the plague began, sealed at the centre of the ossuary and never opened since. The air is wrong here. The stone is wrong. Even the dark is wrong.",
|
||||
durationSeconds: 420,
|
||||
id: "plague_heart",
|
||||
name: "The Plague Heart",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
// ── Ashen Wastes ──────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A vast expanse of volcanic cinder stretching to every horizon, broken only by the occasional column of frozen ash-smoke. Nothing grows here. Nothing needs to.",
|
||||
durationSeconds: 120,
|
||||
id: "cinder_fields",
|
||||
name: "The Cinder Fields",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
description: "The skeletal remains of a settlement that tried to endure in the wastes. The ash has worn everything soft. What is left is all hard edges and empty windows.",
|
||||
durationSeconds: 240,
|
||||
id: "cloth_ruins",
|
||||
name: "The Cloth Ruins",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
description: "A network of lava tubes beneath the wastes, cooled for centuries but still lined with cinder crystals that pulse faintly with residual heat. The air down here is breathable. Barely.",
|
||||
durationSeconds: 360,
|
||||
id: "crystal_caverns",
|
||||
name: "The Crystal Caverns",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
description: "The volcanic vent at the wastes' heart, where the original eruption began and where the heat never fully left. The stone here glows orange at the edges, even now.",
|
||||
durationSeconds: 480,
|
||||
id: "smouldering_core",
|
||||
name: "The Smouldering Core",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
// ── The Iron Gaol ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Row upon row of iron-barred cells stretching in both directions, each one sealed with a different lock. Most are still occupied.",
|
||||
durationSeconds: 135,
|
||||
id: "cell_blocks",
|
||||
name: "The Cell Blocks",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
description: "A long corridor where chains hang from the ceiling in dense curtains — restraints for things too large or too dangerous for conventional cells. Many are broken.",
|
||||
durationSeconds: 270,
|
||||
id: "chain_gallery",
|
||||
name: "The Chain Gallery",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
description: "The warden's personal quarters — austere, iron-furnished, and sealed from the inside. Whatever the warden was keeping in here, they were keeping it from both directions.",
|
||||
durationSeconds: 405,
|
||||
id: "wardens_quarter",
|
||||
name: "The Warden's Quarter",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
description: "The gaol's execution yard — an open courtyard paved with gaol-stone and stained too deep to clean. The block at the centre has never been removed. Neither has the axe.",
|
||||
durationSeconds: 540,
|
||||
id: "execution_ground",
|
||||
name: "The Execution Ground",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
// ── Veilborn Hollow ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outermost edge of the hollow, where the veil between the living and the dead thins enough to see through. The light here has a quality that makes distances unreliable.",
|
||||
durationSeconds: 150,
|
||||
id: "gossamer_entry",
|
||||
name: "The Gossamer Entry",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
description: "A grove of crystalline formations that grow where the veil touches the earth — hollow inside, resonating with the frequencies of the dead. They are beautiful. They are also listening.",
|
||||
durationSeconds: 300,
|
||||
id: "crystal_grove",
|
||||
name: "The Crystal Grove",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
description: "A low-lying stretch of the hollow where phantom-dust drifts in permanent suspension and the shapes within it are never quite random. Something is being communicated. You are not sure to whom.",
|
||||
durationSeconds: 450,
|
||||
id: "phantom_fen",
|
||||
name: "The Phantom Fen",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
description: "The hollow's centre — the point where the veil is not merely thin but absent entirely. Standing here, you can see both sides at once. It is not a comfortable thing to see.",
|
||||
durationSeconds: 600,
|
||||
id: "veils_heart",
|
||||
name: "The Veil's Heart",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
// ── Moonless Moor ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The moor's outermost fringe, where the ground turns soft and the fog rolls in from nowhere at all hours. Even locals stopped crossing it after dark centuries ago.",
|
||||
durationSeconds: 165,
|
||||
id: "boggy_fringe",
|
||||
name: "The Boggy Fringe",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
description: "A dense band of fog that sits permanently across the moor's midpoint — thick enough to lose direction, warm enough to suggest something breathing inside it.",
|
||||
durationSeconds: 330,
|
||||
id: "fog_bank",
|
||||
name: "The Fog Bank",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
description: "A clearing deep in the moor where night-blooming flowers grow in perfect concentric circles, as though planted by something with a very specific sense of geometry.",
|
||||
durationSeconds: 495,
|
||||
id: "night_bloom_meadow",
|
||||
name: "The Night Bloom Meadow",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
description: "The absolute centre of the moor — a place where even cloudy nights feel darker, where the fog does not drift but stands, and where the peat is warm underfoot for no reason anyone has ever explained.",
|
||||
durationSeconds: 660,
|
||||
id: "moonless_centre",
|
||||
name: "The Moonless Centre",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
// ── The Sunken Crypt ──────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The crypt's upper level, half-submerged — the water reached the first-floor windows decades ago and has not receded since. The stone here is slick and the light is wrong.",
|
||||
durationSeconds: 180,
|
||||
id: "flooded_entry",
|
||||
name: "The Flooded Entry",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
description: "The burial chambers of the crypt's noble occupants — sealed in silk-wrapped sarcophagi that have been submerged long enough for the silk to absorb everything the water carried.",
|
||||
durationSeconds: 360,
|
||||
id: "silk_tombs",
|
||||
name: "The Silk Tombs",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
description: "The deepest dry section of the crypt — a sealed vault where amber-preserved remains have sat in suspended darkness since before the flooding began.",
|
||||
durationSeconds: 540,
|
||||
id: "amber_vaults",
|
||||
name: "The Amber Vaults",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
description: "The crypt's lowest and oldest chamber, entirely submerged — accessible only by diving, and only by those who do not need to breathe. What is down here has been waiting a very long time.",
|
||||
durationSeconds: 720,
|
||||
id: "deepmost_chamber",
|
||||
name: "The Deepmost Chamber",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
// ── Desecrated Sanctum ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "What was once a place of worship, now stripped to the walls — every icon removed, every prayer answered with fire. The marble floor is cracked and the ceiling is open to the sky.",
|
||||
durationSeconds: 195,
|
||||
id: "broken_nave",
|
||||
name: "The Broken Nave",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The sanctum's chapel of glass — stained windows shattered inward, their shards still lying where they fell, catching the light in patterns that were never in the original design.",
|
||||
durationSeconds: 390,
|
||||
id: "glass_chapel",
|
||||
name: "The Glass Chapel",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Burial crypts beneath the sanctum where the incense has burned continuously since before the desecration — thick and sweet and wrong. The smoke does not dissipate.",
|
||||
durationSeconds: 585,
|
||||
id: "incense_crypts",
|
||||
name: "The Incense Crypts",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
description: "The sanctum's inner altar — the focal point of the original desecration, still active, still hungry. Whatever was summoned here did not leave when the ceremony ended.",
|
||||
durationSeconds: 780,
|
||||
id: "altar_of_defilement",
|
||||
name: "The Altar of Defilement",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
// ── Carrion Peaks ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The lower trails of the peaks, where scavengers have worn paths between the fallen. The carrion is old here — sun-bleached and picked clean — but not entirely without value.",
|
||||
durationSeconds: 210,
|
||||
id: "scavenger_trails",
|
||||
name: "The Scavenger Trails",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
description: "The upper ridges of the peaks, where the stone is shot through with veins of peak crystal that catch the moonlight and scatter it into something that has no business being beautiful up here.",
|
||||
durationSeconds: 420,
|
||||
id: "crystal_ridges",
|
||||
name: "The Crystal Ridges",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
description: "Vast slopes of accumulated bones — centuries of creatures and people brought to the peaks and left there. The obsidian runs through them like black veins through white marble.",
|
||||
durationSeconds: 630,
|
||||
id: "bone_slopes",
|
||||
name: "The Bone Slopes",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
description: "The absolute summit of the peaks — a plateau of pure blood obsidian worn smooth by wind and age. Nothing lives at this altitude. Nothing needs to. The view is extraordinary. The drop is absolute.",
|
||||
durationSeconds: 840,
|
||||
id: "obsidian_summit",
|
||||
name: "The Obsidian Summit",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
// ── The Bloodspire ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The base of the Bloodspire — a structure that was not built so much as grown, rising from the ground like something the earth decided to extrude. The stone here is warm.",
|
||||
durationSeconds: 225,
|
||||
id: "spire_approach",
|
||||
name: "The Spire Approach",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
description: "The interior of the spire's lower levels, where blood crystals grow in clusters from every surface — feeding on whatever the spire draws in through its walls.",
|
||||
durationSeconds: 450,
|
||||
id: "crystal_veins",
|
||||
name: "The Crystal Veins",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
description: "Mid-spire chambers where ancient gore has been compressed by the weight of the structure above — dark, dense, and potent with age. The smell is remarkable.",
|
||||
durationSeconds: 675,
|
||||
id: "gore_chambers",
|
||||
name: "The Gore Chambers",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
description: "The summit of the Bloodspire — a needle-point of pure spire stone surrounded by nothing but sky. Up here, the wind carries blood-mist from below, and the horizon curves in a direction it should not.",
|
||||
durationSeconds: 900,
|
||||
id: "spire_apex",
|
||||
name: "The Spire Apex",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
// ── Shroud of Eternity ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outermost boundary of the Shroud — where time begins to move at a slightly different rate and the light has a quality that makes everything look both older and newer than it is.",
|
||||
durationSeconds: 240,
|
||||
id: "shrouds_edge",
|
||||
name: "The Shroud's Edge",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
description: "A grove of ancient trees deep in the Shroud, where timeless amber drips from the bark in slow, golden beads — each one containing something that was old before the Shroud formed.",
|
||||
durationSeconds: 480,
|
||||
id: "amber_groves",
|
||||
name: "The Amber Groves",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
description: "A vast expanse of shroud-dust that has settled into dunes across the Shroud's interior — ancient particulate matter that carries age in every grain. Walking through it feels like walking through accumulated years.",
|
||||
durationSeconds: 720,
|
||||
id: "dust_wastes",
|
||||
name: "The Dust Wastes",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
description: "The Shroud's innermost point — where the threads of eternity are visible as literal filaments of light running through the air, and where time does not move in any direction at all.",
|
||||
durationSeconds: 960,
|
||||
id: "eternal_weave",
|
||||
name: "The Eternal Weave",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
// ── The Abyssal Vault ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The outer gates of the Vault — sealed by architects who understood what they were locking inside. The vault iron of the doors is centuries old and has never once considered rusting.",
|
||||
durationSeconds: 255,
|
||||
id: "vault_gates",
|
||||
name: "The Vault Gates",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
description: "The deep corridors of the Vault, carved from abyssal stone that absorbs light rather than reflecting it. Void crystals stud the walls like inverted stars.",
|
||||
durationSeconds: 510,
|
||||
id: "abyssal_corridors",
|
||||
name: "The Abyssal Corridors",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
description: "The inner sanctum of the Vault — the room that was sealed first and most thoroughly. The abyssal stone here has never seen light. The vault iron fittings are welded, not locked.",
|
||||
durationSeconds: 765,
|
||||
id: "inner_sanctum",
|
||||
name: "The Inner Sanctum",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
description: "The absolute bottom of the Vault — a chamber below the sanctum that was not in the original design. Whatever is down here dug its own way in, from below, and has been here longer than the Vault itself.",
|
||||
durationSeconds: 1020,
|
||||
id: "vault_nadir",
|
||||
name: "The Vault Nadir",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
// ── Court of Whispers ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The entrance hall of the Court, where every footstep is recorded in whisper-parchment that lines the walls like wallpaper. You can hear, faintly, every visitor who has ever entered.",
|
||||
durationSeconds: 270,
|
||||
id: "entrance_hall",
|
||||
name: "The Entrance Hall",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
description: "The Court's chamber of records — walls of court crystal shelving holding silent-ink manuscripts that contain every judgement ever rendered within these walls. The ink does not fade. It waits.",
|
||||
durationSeconds: 540,
|
||||
id: "records_chamber",
|
||||
name: "The Records Chamber",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
description: "The deliberation hall, where the Court's judgements were reached in absolute silence — the court crystal amplifying thought rather than sound. The silence here is structural.",
|
||||
durationSeconds: 810,
|
||||
id: "deliberation_hall",
|
||||
name: "The Deliberation Hall",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
description: "The Court's innermost chamber — the place where whispers become verdicts and verdicts become permanent. The silence here is not empty. It is full, to the point of pressure.",
|
||||
durationSeconds: 1080,
|
||||
id: "verdict_chamber",
|
||||
name: "The Verdict Chamber",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
// ── The Eternal Abyss ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The threshold of the Eternal Abyss — the last border before everything becomes something else entirely. The primordial ash begins here, drifting upward rather than settling.",
|
||||
durationSeconds: 300,
|
||||
id: "abyss_threshold",
|
||||
name: "The Abyss Threshold",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
description: "The upper reaches of the Abyss itself — not truly the bottom, but far enough in that up and down begin to lose consensus. Eternal crystals grow here from nothing, as though the Abyss is trying to fill itself.",
|
||||
durationSeconds: 600,
|
||||
id: "upper_abyss",
|
||||
name: "The Upper Abyss",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
description: "The deep Abyss — where void essence collects in pools that have no floor and emit no light. The primordial ash here is so thick it is almost solid. Something enormous moves below.",
|
||||
durationSeconds: 900,
|
||||
id: "deep_abyss",
|
||||
name: "The Deep Abyss",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
description: "The absolute bottom of the Eternal Abyss — or as close to it as anything has ever reached and returned from. Here, void essence and primordial ash and eternal crystal exist in equal measure, in a silence that predates sound.",
|
||||
durationSeconds: 1200,
|
||||
id: "abyss_floor",
|
||||
name: "The Abyss Floor",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* @file Vampire crafting material data for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs are conventional for game data */
|
||||
/* eslint-disable stylistic/max-len -- Long description strings cannot be split */
|
||||
/* eslint-disable max-lines -- Data file necessarily exceeds line limit */
|
||||
import type { Material } from "@elysium/types";
|
||||
|
||||
export const VAMPIRE_MATERIALS: Array<Material> = [
|
||||
// ── Haunted Catacombs ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Dust ground from the bones of vampires who rose, fought, and fell in these tunnels. It carries the faintest trace of their hunger.",
|
||||
id: "bone_dust",
|
||||
name: "Bone Dust",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
description: "The residue of a life that chose darkness. It pools in the lowest reaches of the catacombs, slowly thickening over centuries.",
|
||||
id: "grave_essence",
|
||||
name: "Grave Essence",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
{
|
||||
description: "Fine grey ash that accumulates wherever the undead have spent long centuries in stasis. Not quite earth, not quite flesh. Something in between.",
|
||||
id: "catacomb_ash",
|
||||
name: "Catacomb Ash",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_haunted_catacombs",
|
||||
},
|
||||
// ── Blood Mire ────────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Thick, crimson-tinted mud drawn from the deepest channels of the mire. It does not dry out. It does not wash off. It does not forget.",
|
||||
id: "mire_sludge",
|
||||
name: "Mire Sludge",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
description: "A flat-bladed moss that grows exclusively on surfaces saturated with old blood. Herbalists who have tried to study it have stopped trying.",
|
||||
id: "blood_moss",
|
||||
name: "Blood Moss",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
{
|
||||
description: "A hollow reed that grows where the mire runs deepest, with a faint red tint throughout its stem. If cut, it bleeds.",
|
||||
id: "crimson_reed",
|
||||
name: "Crimson Reed",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_blood_mire",
|
||||
},
|
||||
// ── Obsidian Keep ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "A sharp shard of the volcanic stone used to build the Keep. Each chip holds a fragment of the blood magic sealed into the walls during construction.",
|
||||
id: "obsidian_chip",
|
||||
name: "Obsidian Chip",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
description: "Iron filings scraped from the Keep's ancient weapons and restraints. Cold to the touch, even near fire. Even near blood.",
|
||||
id: "iron_shaving",
|
||||
name: "Iron Shaving",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
{
|
||||
description: "The bonding agent used to seal the Keep's stones together — mixed with ash, iron powder, and something that should have been left out. It cures permanently.",
|
||||
id: "keep_mortar",
|
||||
name: "Keep Mortar",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_obsidian_keep",
|
||||
},
|
||||
// ── Crimson Citadel ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Polished stone quarried from the Citadel's foundations. Every piece has been touched by so many vampire lords that it practically radiates authority.",
|
||||
id: "citadel_stone",
|
||||
name: "Citadel Stone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
description: "An alloy forged in blood-tempered furnaces, harder than ordinary bronze and carrying a subtle crimson sheen. The Citadel's armourers guard the recipe.",
|
||||
id: "blood_bronze",
|
||||
name: "Blood Bronze",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
{
|
||||
description: "Silk woven from threads that were dyed with diluted vampire essence and then dried for a century. The fabric changes colour subtly in moonlight.",
|
||||
id: "crimson_silk",
|
||||
name: "Crimson Silk",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_crimson_citadel",
|
||||
},
|
||||
// ── Shadow Court ──────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Thread spun from shadow itself — a process that requires both technical skill and a complete willingness to let go of daylight. Woven garments made from it are essentially invisible.",
|
||||
id: "shadow_thread",
|
||||
name: "Shadow Thread",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
description: "Ink prepared from whispered secrets — literally. The Court's scribes capture spoken confidences in a phial and render them down into pigment. Every document written with it is, technically, a confession.",
|
||||
id: "whisper_ink",
|
||||
name: "Whisper Ink",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
{
|
||||
description: "A heavy black wax used to seal the Court's most sensitive correspondences. Once set, it can only be broken by the vampire who pressed it. Forgeries have been attempted. None have survived the attempt.",
|
||||
id: "court_wax",
|
||||
name: "Court Wax",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_shadow_court",
|
||||
},
|
||||
// ── Plague Ossuary ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Grey ash remaining after the Ossuary's plague fires have consumed what they were fed. Mildly corrosive. Handle with care, and perhaps with gloves.",
|
||||
id: "plague_ash",
|
||||
name: "Plague Ash",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
description: "Bone harvested from vampires taken by the Ossuary's endemic pestilence. The infection did not die with them. It merely changed hosts.",
|
||||
id: "infected_bone",
|
||||
name: "Infected Bone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
{
|
||||
description: "A thick, pale resin that oozes from the Ossuary's walls in places where plague-magic has been concentrated longest. It hardens into a surprisingly effective sealant.",
|
||||
id: "ossuary_resin",
|
||||
name: "Ossuary Resin",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_plague_ossuary",
|
||||
},
|
||||
// ── Ashen Wastes ──────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Ash falling perpetually from the sky above the Wastes — the remains of a war that never finished burning. It is surprisingly good for preservation.",
|
||||
id: "volcanic_ash",
|
||||
name: "Volcanic Ash",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed where magical fire burned long enough to change the nature of the ground beneath it. They retain heat indefinitely.",
|
||||
id: "cinder_crystal",
|
||||
name: "Cinder Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
{
|
||||
description: "Cloth woven in the Wastes and saturated with ash over generations. It does not burn. It does not stain. It does not soften.",
|
||||
id: "ashen_cloth",
|
||||
name: "Ashen Cloth",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_ashen_wastes",
|
||||
},
|
||||
// ── The Iron Gaol ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The iron pins and fasteners used throughout the Gaol's construction. Each one is inscribed with a containment glyph. They do not loosen with time.",
|
||||
id: "iron_rivet",
|
||||
name: "Iron Rivet",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
description: "A single link from one of the Gaol's binding chains. Strong enough to hold an elder vampire. Heavier than it looks. Always cold.",
|
||||
id: "chain_link",
|
||||
name: "Chain Link",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
{
|
||||
description: "The stone quarried to build the Gaol's cells — dense, cold, and impregnated with centuries of accumulated despair. It absorbs magic rather than conducting it.",
|
||||
id: "gaol_stone",
|
||||
name: "Gaol Stone",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_iron_gaol",
|
||||
},
|
||||
// ── Veilborn Hollow ───────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Thread spun from the Veil itself — a substance that exists partially in the shadow-realm and partially in the real world. Objects made with it are somewhat difficult to focus on.",
|
||||
id: "veil_thread",
|
||||
name: "Veil Thread",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed at the point where the Veil touches the physical world — each one containing a frozen moment from the shadow-realm. Looking into them for too long is inadvisable.",
|
||||
id: "hollow_crystal",
|
||||
name: "Hollow Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
{
|
||||
description: "The physical residue of a spirit that has fully crossed the Veil — fine, weightless particles that drift upward rather than falling. They make excellent catalyst material.",
|
||||
id: "phantom_dust",
|
||||
name: "Phantom Dust",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_veilborn_hollow",
|
||||
},
|
||||
// ── Moonless Moor ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Dark, saturated peat from the deepest parts of the Moor. It burns slowly and produces a smoke that seems to attract predators rather than repel them.",
|
||||
id: "moor_peat",
|
||||
name: "Moor Peat",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
description: "The Moor's perpetual fog condensed and collected. It does not evaporate in warmth, which is how you know it is not ordinary fog.",
|
||||
id: "fog_essence",
|
||||
name: "Fog Essence",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
{
|
||||
description: "A rare plant that flowers only in absolute darkness. Its bloom is bioluminescent, which is the only way anyone has ever found one.",
|
||||
id: "night_bloom",
|
||||
name: "Night Bloom",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_moonless_moor",
|
||||
},
|
||||
// ── The Sunken Crypt ──────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Stone recovered from the deepest chambers — porous, dark, and reeking of salt water and old blood. Everything sealed in these chambers has soaked into it.",
|
||||
id: "sunken_stone",
|
||||
name: "Sunken Stone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
description: "Silk preserved in the crypt's submerged chambers for so long that it has taken on properties of neither cloth nor water. Soft, cold, and permanent.",
|
||||
id: "drowned_silk",
|
||||
name: "Drowned Silk",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
{
|
||||
description: "Amber formed from resin that seeped into the crypt's lower levels and hardened around fragments of vampire essence. Each piece traps something that was still alive when it solidified.",
|
||||
id: "deep_amber",
|
||||
name: "Deep Amber",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_sunken_crypt",
|
||||
},
|
||||
// ── Desecrated Sanctum ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Polished marble torn from the Sanctum's original construction — its sacred inscriptions scraped away, but the stone remembers. It resists dark enchantments more than it should.",
|
||||
id: "defiled_marble",
|
||||
name: "Defiled Marble",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Fragments of the Sanctum's original windows — glass that was made to hold sacred light. Now it holds nothing, and the emptiness feels intentional.",
|
||||
id: "sanctum_glass",
|
||||
name: "Sanctum Glass",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
{
|
||||
description: "Incense burned in rituals designed to invert the Sanctum's sacred purpose. The smoke still rises the wrong way — downward.",
|
||||
id: "dark_incense",
|
||||
name: "Dark Incense",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_desecrated_sanctum",
|
||||
},
|
||||
// ── Carrion Peaks ─────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Bone fragments from creatures that have lived and died on the Peaks for generations — stripped clean, bleached white, and still faintly warm.",
|
||||
id: "carrion_bone",
|
||||
name: "Carrion Bone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
description: "Crystals found only in the Peaks' highest reaches — formed by a convergence of altitude, cold, and old hunting magic. Sharp enough to cut through standard vampire hide.",
|
||||
id: "peak_crystal",
|
||||
name: "Peak Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
{
|
||||
description: "Obsidian that has absorbed vampire blood through direct contact during battles at the Peaks' summits. The two materials have merged into something neither purely mineral nor purely vital.",
|
||||
id: "blood_obsidian",
|
||||
name: "Blood Obsidian",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_carrion_peaks",
|
||||
},
|
||||
// ── The Bloodspire ────────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The crystallised blood that forms the Spire's outer walls. Dense as stone, warm as fresh blood. It grows back if broken off.",
|
||||
id: "spire_stone",
|
||||
name: "Spire Stone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
description: "Crystals grown at the Spire's interior junctions — formed where the architecture deliberately folds blood-magic into the structure of the building. Each one pulses faintly.",
|
||||
id: "blood_crystal",
|
||||
name: "Blood Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
{
|
||||
description: "Residue harvested from the Spire's deepest chambers — a thick, dark ichor that predates even the building that contains it. It does not react to any known magical reagent. It reacts to intent.",
|
||||
id: "ancient_gore",
|
||||
name: "Ancient Gore",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_bloodspire",
|
||||
},
|
||||
// ── Shroud of Eternity ────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Thread woven from the Shroud's temporal fabric — each strand has already lived through several possible futures and settled on none of them. Things made from it feel slightly out of phase.",
|
||||
id: "eternity_thread",
|
||||
name: "Eternity Thread",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
description: "Dust collected from the Shroud's boundary regions — the physical remnant of time that moved too slowly and eventually stopped. It drifts in currents that do not correspond to any wind.",
|
||||
id: "shroud_dust",
|
||||
name: "Shroud Dust",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
{
|
||||
description: "Amber formed in the Shroud's temporal anomalies — trapping moments that exist outside of normal time. The things preserved inside are still happening.",
|
||||
id: "timeless_amber",
|
||||
name: "Timeless Amber",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_shroud_of_eternity",
|
||||
},
|
||||
// ── The Abyssal Vault ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Stone from the Vault's outer walls — quarried from a place that exists below the normal underground, in a layer of the world that does not have a name.",
|
||||
id: "abyssal_stone",
|
||||
name: "Abyssal Stone",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed in absolute void — places within the Vault where nothing has ever existed. Their interiors are genuinely empty in a way that normal empty space is not.",
|
||||
id: "void_crystal",
|
||||
name: "Void Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
{
|
||||
description: "Iron refined in the Vault's deepest forges — as cold as absolute zero, as hard as any known material. It does not rust. It does not bend. It does not forgive.",
|
||||
id: "vault_iron",
|
||||
name: "Vault Iron",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_abyssal_vault",
|
||||
},
|
||||
// ── Court of Whispers ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "Parchment prepared from the skin of failed spies — a Court tradition that serves both as record and deterrent. Every document written on it contains the memory of its source.",
|
||||
id: "whisper_parchment",
|
||||
name: "Whisper Parchment",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed where the Court's intelligence network has concentrated the most secrets in the least space. They vibrate at a frequency only very old vampires can hear.",
|
||||
id: "court_crystal",
|
||||
name: "Court Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
{
|
||||
description: "Ink rendered from secrets so dangerous that even writing them down is a risk. The Court uses it for its most sensitive documents. The ink knows what it says.",
|
||||
id: "silent_ink",
|
||||
name: "Silent Ink",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_court_of_whispers",
|
||||
},
|
||||
// ── The Eternal Abyss ─────────────────────────────────────────────────────
|
||||
{
|
||||
description: "The primal substance that exists at the bottom of the vampire world — neither matter nor energy, but something that predates both. Handling it requires understanding it, which may be impossible.",
|
||||
id: "void_essence",
|
||||
name: "Void Essence",
|
||||
rarity: "common",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
description: "Crystals formed at the intersection of the vampire realm and whatever exists beyond it. Each one contains a fragment of something genuinely ancient — older than the first vampire, older than the concept of blood.",
|
||||
id: "eternal_crystal",
|
||||
name: "Eternal Crystal",
|
||||
rarity: "uncommon",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
{
|
||||
description: "Ash from things that existed before the concept of fire. It does not look like ordinary ash. It does not behave like ordinary ash. It simply is.",
|
||||
id: "primordial_ash",
|
||||
name: "Primordial Ash",
|
||||
rarity: "rare",
|
||||
zoneId: "vampire_eternal_abyss",
|
||||
},
|
||||
];
|
||||
@@ -16,11 +16,17 @@ import {
|
||||
type Achievement,
|
||||
type Equipment,
|
||||
type GameState,
|
||||
type GoddessAchievement,
|
||||
type GoddessState,
|
||||
type VampireAchievement,
|
||||
type VampireState,
|
||||
computeSetBonuses,
|
||||
computeVampireSetBonuses,
|
||||
getActiveCompanionBonus,
|
||||
} from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||
import { VAMPIRE_EQUIPMENT_SETS } from "../data/vampireEquipmentSets.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
/**
|
||||
@@ -83,6 +89,118 @@ const checkAchievements = (state: GameState): Array<Achievement> => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks all goddess achievements against a snapshot of the goddess state
|
||||
* and returns an updated achievements array, marking newly-met conditions
|
||||
* with the current timestamp.
|
||||
* @param goddess - The current (or projected) goddess state.
|
||||
* @param now - Current Unix timestamp in milliseconds.
|
||||
* @returns Updated goddess achievements array with newly unlocked ones timestamped.
|
||||
*/
|
||||
const checkGoddessAchievements = (
|
||||
goddess: GoddessState,
|
||||
now: number,
|
||||
): Array<GoddessAchievement> => {
|
||||
return goddess.achievements.map((achievement) => {
|
||||
if (achievement.unlockedAt !== null) {
|
||||
return achievement;
|
||||
}
|
||||
|
||||
const { condition } = achievement;
|
||||
let met = false;
|
||||
|
||||
switch (condition.type) {
|
||||
case "totalPrayersEarned":
|
||||
met = goddess.lifetimePrayersEarned >= condition.amount;
|
||||
break;
|
||||
case "goddessBossesDefeated":
|
||||
met = goddess.lifetimeBossesDefeated >= condition.amount;
|
||||
break;
|
||||
case "goddessQuestsCompleted":
|
||||
met = goddess.lifetimeQuestsCompleted >= condition.amount;
|
||||
break;
|
||||
case "discipleTotal":
|
||||
met
|
||||
= goddess.disciples.reduce((sum, disciple) => {
|
||||
return sum + disciple.count;
|
||||
}, 0) >= condition.amount;
|
||||
break;
|
||||
case "consecrationCount":
|
||||
met = goddess.consecration.count >= condition.amount;
|
||||
break;
|
||||
case "goddessEquipmentOwned":
|
||||
met
|
||||
= goddess.equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length >= condition.amount;
|
||||
break;
|
||||
default:
|
||||
// eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive
|
||||
/* v8 ignore next -- @preserve */ break;
|
||||
}
|
||||
|
||||
return met
|
||||
? { ...achievement, unlockedAt: now }
|
||||
: achievement;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks all vampire achievements against a snapshot of the vampire state
|
||||
* and returns an updated achievements array, marking newly-met conditions
|
||||
* with the current timestamp.
|
||||
* @param vampire - The current (or projected) vampire state.
|
||||
* @param now - Current Unix timestamp in milliseconds.
|
||||
* @returns Updated vampire achievements array with newly unlocked ones timestamped.
|
||||
*/
|
||||
const checkVampireAchievements = (
|
||||
vampire: VampireState,
|
||||
now: number,
|
||||
): Array<VampireAchievement> => {
|
||||
return vampire.achievements.map((achievement) => {
|
||||
if (achievement.unlockedAt !== null) {
|
||||
return achievement;
|
||||
}
|
||||
|
||||
const { condition } = achievement;
|
||||
let met = false;
|
||||
|
||||
switch (condition.type) {
|
||||
case "totalBloodEarned":
|
||||
met = vampire.lifetimeBloodEarned >= condition.amount;
|
||||
break;
|
||||
case "vampireBossesDefeated":
|
||||
met = vampire.lifetimeBossesDefeated >= condition.amount;
|
||||
break;
|
||||
case "vampireQuestsCompleted":
|
||||
met = vampire.lifetimeQuestsCompleted >= condition.amount;
|
||||
break;
|
||||
case "thrallTotal":
|
||||
met
|
||||
= vampire.thralls.reduce((sum, thrall) => {
|
||||
return sum + thrall.count;
|
||||
}, 0) >= condition.amount;
|
||||
break;
|
||||
case "siringCount":
|
||||
met = vampire.siring.count >= condition.amount;
|
||||
break;
|
||||
case "vampireEquipmentOwned":
|
||||
met
|
||||
= vampire.equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length >= condition.amount;
|
||||
break;
|
||||
default:
|
||||
// eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive
|
||||
/* v8 ignore next -- @preserve */ break;
|
||||
}
|
||||
|
||||
return met
|
||||
? { ...achievement, unlockedAt: now }
|
||||
: achievement;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
||||
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
||||
@@ -450,6 +568,79 @@ export const computePartyCombatPower = (state: GameState): number => {
|
||||
* companionCombatMult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the effective blood earned per second from all thralls,
|
||||
* applying all active multipliers (upgrades, siring, awakening, equipment, sets, crafting).
|
||||
* @param state - The current game state.
|
||||
* @returns Blood per second as a number.
|
||||
*/
|
||||
export const computeVampireBloodPerSecond = (state: GameState): number => {
|
||||
if (state.vampire === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const { vampire } = state;
|
||||
|
||||
const equippedItems = vampire.equipment.filter((item) => {
|
||||
return item.equipped;
|
||||
});
|
||||
const equipmentBloodMultiplier = equippedItems.reduce((mult, item) => {
|
||||
return mult * (item.bonus.bloodMultiplier ?? 1);
|
||||
}, 1);
|
||||
const setBloodMultiplier = computeVampireSetBonuses(
|
||||
equippedItems.map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
VAMPIRE_EQUIPMENT_SETS,
|
||||
).bloodMultiplier;
|
||||
|
||||
const ichorBloodMult = vampire.siring.ichorBloodMultiplier ?? 1;
|
||||
const { soulShardsBloodMultiplier } = vampire.awakening;
|
||||
const { craftedBloodMultiplier } = vampire.exploration;
|
||||
|
||||
let globalBloodMult = 1;
|
||||
let globalUpgradeMult = 1;
|
||||
for (const upgrade of vampire.upgrades) {
|
||||
if (upgrade.purchased) {
|
||||
if (upgrade.target === "blood") {
|
||||
globalBloodMult = globalBloodMult * upgrade.multiplier;
|
||||
} else if (upgrade.target === "global") {
|
||||
globalUpgradeMult = globalUpgradeMult * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bloodPerSecond = 0;
|
||||
for (const thrall of vampire.thralls) {
|
||||
if (!thrall.unlocked || thrall.count === 0) {
|
||||
continue;
|
||||
}
|
||||
let thrallUpgradeMult = 1;
|
||||
for (const upgrade of vampire.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "thrall"
|
||||
&& upgrade.thrallId === thrall.id
|
||||
) {
|
||||
thrallUpgradeMult = thrallUpgradeMult * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
const upgradeMultiplier = thrallUpgradeMult * globalUpgradeMult;
|
||||
const contribution
|
||||
= thrall.bloodPerSecond
|
||||
* thrall.count
|
||||
* upgradeMultiplier
|
||||
* globalBloodMult
|
||||
* vampire.siring.productionMultiplier
|
||||
* ichorBloodMult
|
||||
* soulShardsBloodMultiplier
|
||||
* craftedBloodMultiplier
|
||||
* equipmentBloodMultiplier
|
||||
* setBloodMultiplier;
|
||||
bloodPerSecond = bloodPerSecond + contribution;
|
||||
}
|
||||
return bloodPerSecond;
|
||||
};
|
||||
|
||||
const basePrestigeThreshold = 1_000_000;
|
||||
const runestonesPerPrestigeLevelClient = 20;
|
||||
const maxBaseRunestones = 200;
|
||||
@@ -770,6 +961,552 @@ export const applyTick = (
|
||||
: upgrade;
|
||||
});
|
||||
|
||||
// --- Goddess tick ---
|
||||
let prayersGained = 0;
|
||||
let divinityGained = 0;
|
||||
let stardustFromQuests = 0;
|
||||
// eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations
|
||||
let updatedGoddess: GoddessState | undefined = undefined;
|
||||
|
||||
let bloodGainedVampire = 0;
|
||||
// eslint-disable-next-line no-undef-init -- required by @typescript-eslint/init-declarations
|
||||
let updatedVampire: VampireState | undefined = undefined;
|
||||
|
||||
if (
|
||||
state.apotheosis !== undefined
|
||||
&& state.apotheosis.count > 0
|
||||
&& state.goddess !== undefined
|
||||
) {
|
||||
const { goddess } = state;
|
||||
|
||||
// Collect all multipliers for prayers/divinity income
|
||||
const consecMult = goddess.consecration.productionMultiplier;
|
||||
const divinityPrayersMult
|
||||
= goddess.consecration.divinityPrayersMultiplier ?? 1;
|
||||
const divinityDisciplesMult
|
||||
= goddess.consecration.divinityDisciplesMultiplier ?? 1;
|
||||
const stardustPrayersMult = goddess.enlightenment.stardustPrayersMultiplier;
|
||||
const craftedPrayersMult = goddess.exploration.craftedPrayersMultiplier;
|
||||
const craftedDivinityMult = goddess.exploration.craftedDivinityMultiplier;
|
||||
|
||||
let globalPrayersMult = 1;
|
||||
for (const upgrade of goddess.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "prayers") {
|
||||
globalPrayersMult = globalPrayersMult * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
for (const disciple of goddess.disciples) {
|
||||
if (disciple.count === 0) {
|
||||
continue;
|
||||
}
|
||||
let discipleUpgradeMult = 1;
|
||||
for (const upgrade of goddess.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "disciple"
|
||||
&& upgrade.discipleId === disciple.id
|
||||
) {
|
||||
discipleUpgradeMult = discipleUpgradeMult * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
const prayersTick
|
||||
= disciple.prayersPerSecond
|
||||
* disciple.count
|
||||
* discipleUpgradeMult
|
||||
* globalPrayersMult
|
||||
* consecMult
|
||||
* divinityPrayersMult
|
||||
* divinityDisciplesMult
|
||||
* stardustPrayersMult
|
||||
* craftedPrayersMult
|
||||
* deltaSeconds;
|
||||
prayersGained = prayersGained + prayersTick;
|
||||
const divinityTick
|
||||
= disciple.divinityPerSecond
|
||||
* disciple.count
|
||||
* discipleUpgradeMult
|
||||
* consecMult
|
||||
* divinityDisciplesMult
|
||||
* craftedDivinityMult
|
||||
* deltaSeconds;
|
||||
divinityGained = divinityGained + divinityTick;
|
||||
}
|
||||
|
||||
// Process goddess quest timers
|
||||
let goddessQuestPrayersGained = 0;
|
||||
let goddessQuestDivinityGained = 0;
|
||||
let updatedGoddessDisciples = goddess.disciples;
|
||||
let updatedGoddessEquipment = goddess.equipment;
|
||||
let updatedGoddessUpgrades = goddess.upgrades;
|
||||
let goddessQuestsThisTick = 0;
|
||||
|
||||
const updatedGoddessQuests = goddess.quests.map((quest) => {
|
||||
const questDurationMs = quest.durationSeconds * 1000;
|
||||
const questExpiry
|
||||
= quest.startedAt === undefined
|
||||
? Infinity
|
||||
: quest.startedAt + questDurationMs;
|
||||
if (quest.status !== "active" || now < questExpiry) {
|
||||
return quest;
|
||||
}
|
||||
|
||||
goddessQuestsThisTick = goddessQuestsThisTick + 1;
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "prayers" && reward.amount !== undefined) {
|
||||
goddessQuestPrayersGained
|
||||
= goddessQuestPrayersGained + reward.amount;
|
||||
} else if (
|
||||
reward.type === "divinity"
|
||||
&& reward.amount !== undefined
|
||||
) {
|
||||
goddessQuestDivinityGained
|
||||
= goddessQuestDivinityGained + reward.amount;
|
||||
} else if (
|
||||
reward.type === "stardust"
|
||||
&& reward.amount !== undefined
|
||||
) {
|
||||
stardustFromQuests = stardustFromQuests + reward.amount;
|
||||
} else if (
|
||||
reward.type === "upgrade"
|
||||
&& reward.targetId !== undefined
|
||||
) {
|
||||
const { targetId } = reward;
|
||||
updatedGoddessUpgrades = updatedGoddessUpgrades.map((upgrade) => {
|
||||
return upgrade.id === targetId
|
||||
? { ...upgrade, unlocked: true }
|
||||
: upgrade;
|
||||
});
|
||||
} else if (
|
||||
reward.type === "disciple"
|
||||
&& reward.targetId !== undefined
|
||||
) {
|
||||
const { targetId } = reward;
|
||||
updatedGoddessDisciples = updatedGoddessDisciples.map((disciple) => {
|
||||
return disciple.id === targetId
|
||||
? { ...disciple, unlocked: true }
|
||||
: disciple;
|
||||
});
|
||||
} else if (
|
||||
reward.type === "equipment"
|
||||
&& reward.targetId !== undefined
|
||||
) {
|
||||
const rewardTargetId = reward.targetId;
|
||||
const currentEquipment = updatedGoddessEquipment;
|
||||
updatedGoddessEquipment = currentEquipment.map((item) => {
|
||||
if (item.id !== rewardTargetId) {
|
||||
return item;
|
||||
}
|
||||
const slotEmpty = !currentEquipment.some((other) => {
|
||||
return other.type === item.type && other.equipped;
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
equipped: slotEmpty || item.equipped,
|
||||
owned: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ...quest, status: "completed" as const };
|
||||
});
|
||||
|
||||
// Unlock goddess quests whose prerequisites are now all completed
|
||||
const completedGoddessIds = new Set(
|
||||
updatedGoddessQuests.
|
||||
filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).
|
||||
map((quest) => {
|
||||
return quest.id;
|
||||
}),
|
||||
);
|
||||
const defeatedBossIds = new Set(
|
||||
goddess.bosses.
|
||||
filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).
|
||||
map((boss) => {
|
||||
return boss.id;
|
||||
}),
|
||||
);
|
||||
// Unlock goddess zones whose boss + quest requirements are now met
|
||||
const updatedGoddessZones = goddess.zones.map((zone) => {
|
||||
if (zone.status === "unlocked") {
|
||||
return zone;
|
||||
}
|
||||
const bossOk
|
||||
= zone.unlockBossId === null
|
||||
|| defeatedBossIds.has(zone.unlockBossId);
|
||||
const questOk
|
||||
= zone.unlockQuestId === null
|
||||
|| completedGoddessIds.has(zone.unlockQuestId);
|
||||
if (bossOk && questOk) {
|
||||
return { ...zone, status: "unlocked" as const };
|
||||
}
|
||||
return zone;
|
||||
});
|
||||
|
||||
const fullyUpdatedGoddessZones = updatedGoddessZones;
|
||||
const allUnlockedGoddessZoneIds = new Set(
|
||||
fullyUpdatedGoddessZones.
|
||||
filter((zone) => {
|
||||
return zone.status === "unlocked";
|
||||
}).
|
||||
map((zone) => {
|
||||
return zone.id;
|
||||
}),
|
||||
);
|
||||
|
||||
const fullyUpdatedGoddessQuests = updatedGoddessQuests.map((quest) => {
|
||||
if (quest.status !== "locked") {
|
||||
return quest;
|
||||
}
|
||||
if (!allUnlockedGoddessZoneIds.has(quest.zoneId)) {
|
||||
return quest;
|
||||
}
|
||||
if (
|
||||
quest.prerequisiteIds.every((id) => {
|
||||
return completedGoddessIds.has(id);
|
||||
})
|
||||
) {
|
||||
return { ...quest, status: "available" as const };
|
||||
}
|
||||
return quest;
|
||||
});
|
||||
|
||||
// Compute updated lifetime counters
|
||||
const totalPrayersThisTick
|
||||
= prayersGained + goddessQuestPrayersGained;
|
||||
const updatedTotalPrayersEarned
|
||||
= goddess.totalPrayersEarned + totalPrayersThisTick;
|
||||
const updatedLifetimePrayersEarned
|
||||
= goddess.lifetimePrayersEarned + totalPrayersThisTick;
|
||||
const updatedLifetimeQuestsCompleted
|
||||
= goddess.lifetimeQuestsCompleted + goddessQuestsThisTick;
|
||||
|
||||
// Build snapshot for achievement check
|
||||
const goddessSnapshot: GoddessState = {
|
||||
...goddess,
|
||||
disciples: updatedGoddessDisciples,
|
||||
equipment: updatedGoddessEquipment,
|
||||
lifetimeBossesDefeated: goddess.lifetimeBossesDefeated,
|
||||
lifetimePrayersEarned: updatedLifetimePrayersEarned,
|
||||
lifetimeQuestsCompleted: updatedLifetimeQuestsCompleted,
|
||||
quests: fullyUpdatedGoddessQuests,
|
||||
upgrades: updatedGoddessUpgrades,
|
||||
zones: fullyUpdatedGoddessZones,
|
||||
};
|
||||
const updatedGoddessAchievements
|
||||
= checkGoddessAchievements(goddessSnapshot, now);
|
||||
let divinityFromAchievements = 0;
|
||||
let stardustFromAchievements = 0;
|
||||
for (const [ index, achievement ] of updatedGoddessAchievements.entries()) {
|
||||
if (
|
||||
goddess.achievements[index]?.unlockedAt === null
|
||||
&& achievement.unlockedAt !== null
|
||||
) {
|
||||
divinityFromAchievements
|
||||
= divinityFromAchievements + (achievement.reward?.divinity ?? 0);
|
||||
stardustFromAchievements
|
||||
= stardustFromAchievements + (achievement.reward?.stardust ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
updatedGoddess = {
|
||||
...goddessSnapshot,
|
||||
achievements: updatedGoddessAchievements,
|
||||
lastTickAt: now,
|
||||
totalPrayersEarned: updatedTotalPrayersEarned,
|
||||
};
|
||||
|
||||
// Include quest divinity + achievement divinity in this tick's total
|
||||
divinityGained
|
||||
= divinityGained
|
||||
+ goddessQuestDivinityGained
|
||||
+ divinityFromAchievements;
|
||||
stardustFromQuests = stardustFromQuests + stardustFromAchievements;
|
||||
}
|
||||
|
||||
// --- Vampire tick ---
|
||||
if (state.vampire !== undefined) {
|
||||
const { vampire } = state;
|
||||
|
||||
// Compute vampire equipment multipliers once for the tick
|
||||
const vampireEquippedItems = vampire.equipment.filter((item) => {
|
||||
return item.equipped;
|
||||
});
|
||||
const vampireEquipmentBloodMult = vampireEquippedItems.reduce(
|
||||
(mult, item) => {
|
||||
return mult * (item.bonus.bloodMultiplier ?? 1);
|
||||
},
|
||||
1,
|
||||
);
|
||||
const vampireSetBloodMult = computeVampireSetBonuses(
|
||||
vampireEquippedItems.map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
VAMPIRE_EQUIPMENT_SETS,
|
||||
).bloodMultiplier;
|
||||
|
||||
const ichorBloodMult = vampire.siring.ichorBloodMultiplier ?? 1;
|
||||
const {
|
||||
soulShards: currentSoulShards,
|
||||
soulShardsBloodMultiplier,
|
||||
} = vampire.awakening;
|
||||
const { craftedBloodMultiplier } = vampire.exploration;
|
||||
|
||||
// Compute global vampire upgrade multipliers
|
||||
let globalBloodMult = 1;
|
||||
let globalUpgradeMult = 1;
|
||||
for (const upgrade of vampire.upgrades) {
|
||||
if (upgrade.purchased) {
|
||||
if (upgrade.target === "blood") {
|
||||
globalBloodMult = globalBloodMult * upgrade.multiplier;
|
||||
} else if (upgrade.target === "global") {
|
||||
globalUpgradeMult = globalUpgradeMult * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Passive income from thralls
|
||||
let bloodFromThralls = 0;
|
||||
let ichorFromThralls = 0;
|
||||
for (const thrall of vampire.thralls) {
|
||||
if (!thrall.unlocked || thrall.count === 0) {
|
||||
continue;
|
||||
}
|
||||
let thrallUpgradeMult = 1;
|
||||
for (const upgrade of vampire.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "thrall"
|
||||
&& upgrade.thrallId === thrall.id
|
||||
) {
|
||||
thrallUpgradeMult = thrallUpgradeMult * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
const upgradeMultiplier = thrallUpgradeMult * globalUpgradeMult;
|
||||
const bloodContribution
|
||||
= thrall.bloodPerSecond
|
||||
* thrall.count
|
||||
* upgradeMultiplier
|
||||
* globalBloodMult
|
||||
* vampire.siring.productionMultiplier
|
||||
* ichorBloodMult
|
||||
* soulShardsBloodMultiplier
|
||||
* craftedBloodMultiplier
|
||||
* vampireEquipmentBloodMult
|
||||
* vampireSetBloodMult
|
||||
* deltaSeconds;
|
||||
bloodFromThralls = bloodFromThralls + bloodContribution;
|
||||
const ichorContribution
|
||||
= thrall.ichorPerSecond * thrall.count * deltaSeconds;
|
||||
ichorFromThralls = ichorFromThralls + ichorContribution;
|
||||
}
|
||||
|
||||
// Process vampire quest timers
|
||||
let vampireQuestBloodGained = 0;
|
||||
let vampireQuestIchorGained = 0;
|
||||
let vampireQuestSoulShardsGained = 0;
|
||||
let updatedVampireUpgrades = vampire.upgrades;
|
||||
let updatedVampireThralls = vampire.thralls;
|
||||
let updatedVampireEquipment = vampire.equipment;
|
||||
let vampireQuestsThisTick = 0;
|
||||
|
||||
const updatedVampireQuests = vampire.quests.map((quest) => {
|
||||
const questDurationMs = quest.durationSeconds * 1000;
|
||||
const questExpiry
|
||||
= quest.startedAt === undefined
|
||||
? Infinity
|
||||
: quest.startedAt + questDurationMs;
|
||||
if (quest.status !== "active" || now < questExpiry) {
|
||||
return quest;
|
||||
}
|
||||
|
||||
vampireQuestsThisTick = vampireQuestsThisTick + 1;
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "blood" && reward.amount !== undefined) {
|
||||
vampireQuestBloodGained = vampireQuestBloodGained + reward.amount;
|
||||
} else if (reward.type === "ichor" && reward.amount !== undefined) {
|
||||
vampireQuestIchorGained = vampireQuestIchorGained + reward.amount;
|
||||
} else if (
|
||||
reward.type === "soulShards"
|
||||
&& reward.amount !== undefined
|
||||
) {
|
||||
vampireQuestSoulShardsGained
|
||||
= vampireQuestSoulShardsGained + reward.amount;
|
||||
} else if (
|
||||
reward.type === "upgrade"
|
||||
&& reward.targetId !== undefined
|
||||
) {
|
||||
const { targetId } = reward;
|
||||
updatedVampireUpgrades = updatedVampireUpgrades.map((upgrade) => {
|
||||
return upgrade.id === targetId
|
||||
? { ...upgrade, unlocked: true }
|
||||
: upgrade;
|
||||
});
|
||||
} else if (
|
||||
reward.type === "thrall"
|
||||
&& reward.targetId !== undefined
|
||||
) {
|
||||
const { targetId } = reward;
|
||||
updatedVampireThralls = updatedVampireThralls.map((thrall) => {
|
||||
return thrall.id === targetId
|
||||
? { ...thrall, unlocked: true }
|
||||
: thrall;
|
||||
});
|
||||
} else if (
|
||||
reward.type === "equipment"
|
||||
&& reward.targetId !== undefined
|
||||
) {
|
||||
const rewardTargetId = reward.targetId;
|
||||
const currentEquipment = updatedVampireEquipment;
|
||||
updatedVampireEquipment = currentEquipment.map((item) => {
|
||||
if (item.id !== rewardTargetId) {
|
||||
return item;
|
||||
}
|
||||
const slotEmpty = !currentEquipment.some((other) => {
|
||||
return other.type === item.type && other.equipped;
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
equipped: slotEmpty || item.equipped,
|
||||
owned: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ...quest, status: "completed" as const };
|
||||
});
|
||||
|
||||
// Unlock vampire quests whose prerequisites are met and zone is unlocked
|
||||
const completedVampireIds = new Set(
|
||||
updatedVampireQuests.
|
||||
filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).
|
||||
map((quest) => {
|
||||
return quest.id;
|
||||
}),
|
||||
);
|
||||
|
||||
const defeatedVampireBossIds = new Set(
|
||||
vampire.bosses.
|
||||
filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).
|
||||
map((boss) => {
|
||||
return boss.id;
|
||||
}),
|
||||
);
|
||||
|
||||
// Unlock vampire zones whose boss + quest requirements are now met
|
||||
const updatedVampireZones = vampire.zones.map((zone) => {
|
||||
if (zone.status === "unlocked") {
|
||||
return zone;
|
||||
}
|
||||
const bossOk
|
||||
= zone.unlockBossId === null
|
||||
|| defeatedVampireBossIds.has(zone.unlockBossId);
|
||||
const questOk
|
||||
= zone.unlockQuestId === null
|
||||
|| completedVampireIds.has(zone.unlockQuestId);
|
||||
if (bossOk && questOk) {
|
||||
return { ...zone, status: "unlocked" as const };
|
||||
}
|
||||
return zone;
|
||||
});
|
||||
|
||||
const allUnlockedVampireZoneIds = new Set(
|
||||
updatedVampireZones.
|
||||
filter((zone) => {
|
||||
return zone.status === "unlocked";
|
||||
}).
|
||||
map((zone) => {
|
||||
return zone.id;
|
||||
}),
|
||||
);
|
||||
|
||||
const fullyUpdatedVampireQuests = updatedVampireQuests.map((quest) => {
|
||||
if (quest.status !== "locked") {
|
||||
return quest;
|
||||
}
|
||||
if (!allUnlockedVampireZoneIds.has(quest.zoneId)) {
|
||||
return quest;
|
||||
}
|
||||
if (
|
||||
quest.prerequisiteIds.every((id) => {
|
||||
return completedVampireIds.has(id);
|
||||
})
|
||||
) {
|
||||
return { ...quest, status: "available" as const };
|
||||
}
|
||||
return quest;
|
||||
});
|
||||
|
||||
// Compute updated lifetime counters
|
||||
const totalBloodThisTick = bloodFromThralls + vampireQuestBloodGained;
|
||||
const updatedTotalBloodEarned
|
||||
= vampire.totalBloodEarned + totalBloodThisTick;
|
||||
const updatedLifetimeBloodEarned
|
||||
= vampire.lifetimeBloodEarned + totalBloodThisTick;
|
||||
const updatedLifetimeQuestsCompleted
|
||||
= vampire.lifetimeQuestsCompleted + vampireQuestsThisTick;
|
||||
|
||||
// Build snapshot for achievement check
|
||||
const vampireSnapshot: VampireState = {
|
||||
...vampire,
|
||||
equipment: updatedVampireEquipment,
|
||||
lifetimeBloodEarned: updatedLifetimeBloodEarned,
|
||||
lifetimeQuestsCompleted: updatedLifetimeQuestsCompleted,
|
||||
quests: fullyUpdatedVampireQuests,
|
||||
thralls: updatedVampireThralls,
|
||||
totalBloodEarned: updatedTotalBloodEarned,
|
||||
upgrades: updatedVampireUpgrades,
|
||||
zones: updatedVampireZones,
|
||||
};
|
||||
|
||||
const updatedVampireAchievements
|
||||
= checkVampireAchievements(vampireSnapshot, now);
|
||||
let ichorFromAchievements = 0;
|
||||
let soulShardsFromAchievements = 0;
|
||||
for (const [ index, achievement ] of updatedVampireAchievements.entries()) {
|
||||
if (
|
||||
vampire.achievements[index]?.unlockedAt === null
|
||||
&& achievement.unlockedAt !== null
|
||||
) {
|
||||
ichorFromAchievements
|
||||
= ichorFromAchievements + (achievement.reward?.ichor ?? 0);
|
||||
soulShardsFromAchievements
|
||||
= soulShardsFromAchievements + (achievement.reward?.soulShards ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
bloodGainedVampire = totalBloodThisTick;
|
||||
|
||||
updatedVampire = {
|
||||
...vampireSnapshot,
|
||||
achievements: updatedVampireAchievements,
|
||||
awakening: {
|
||||
...vampire.awakening,
|
||||
soulShards:
|
||||
currentSoulShards
|
||||
+ vampireQuestSoulShardsGained
|
||||
+ soulShardsFromAchievements,
|
||||
},
|
||||
lastTickAt: now,
|
||||
siring: {
|
||||
...vampire.siring,
|
||||
ichor:
|
||||
vampire.siring.ichor
|
||||
+ ichorFromThralls
|
||||
+ vampireQuestIchorGained
|
||||
+ ichorFromAchievements,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const goldValue = capResource(state.resources.gold + goldGained + questGold);
|
||||
const essenceValue = capResource(
|
||||
state.resources.essence + essenceGained + questEssence,
|
||||
@@ -781,11 +1518,23 @@ export const applyTick = (
|
||||
...state,
|
||||
resources: {
|
||||
...state.resources,
|
||||
blood: capResource(
|
||||
(state.resources.blood ?? 0) + bloodGainedVampire,
|
||||
),
|
||||
crystals: capResource(
|
||||
state.resources.crystals + questCrystals + challengeCrystals,
|
||||
),
|
||||
divinity: capResource(
|
||||
(state.resources.divinity ?? 0) + divinityGained,
|
||||
),
|
||||
essence: essenceValue,
|
||||
gold: goldValue,
|
||||
prayers: capResource(
|
||||
(state.resources.prayers ?? 0) + prayersGained,
|
||||
),
|
||||
stardust: capResource(
|
||||
(state.resources.stardust ?? 0) + stardustFromQuests,
|
||||
),
|
||||
},
|
||||
...updatedDailyChallenges === undefined
|
||||
? {}
|
||||
@@ -807,6 +1556,12 @@ export const applyTick = (
|
||||
}),
|
||||
},
|
||||
},
|
||||
...updatedGoddess === undefined
|
||||
? {}
|
||||
: { goddess: updatedGoddess },
|
||||
...updatedVampire === undefined
|
||||
? {}
|
||||
: { vampire: updatedVampire },
|
||||
adventurers: updatedAdventurers,
|
||||
bosses: updatedBosses,
|
||||
equipment: updatedEquipmentReference,
|
||||
|
||||
@@ -4678,3 +4678,740 @@ body::before {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* ===================== MODE BAR ===================== */
|
||||
.mode-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--colour-surface-2);
|
||||
border-bottom: 1px solid var(--colour-border);
|
||||
overflow-x: auto;
|
||||
transition: background 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
.mode-button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 1rem;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-button:hover:not(:disabled) {
|
||||
background: var(--colour-surface);
|
||||
color: var(--colour-text);
|
||||
}
|
||||
|
||||
.mode-button.active {
|
||||
background: var(--colour-accent);
|
||||
border-color: var(--colour-accent-light);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mode-button.locked {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.mode-lock {
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
/* ===================== GODDESS THEME ===================== */
|
||||
body.goddess-mode {
|
||||
--colour-bg: #03080f;
|
||||
--colour-surface: #081422;
|
||||
--colour-surface-2: #0b1c30;
|
||||
--colour-border: #173554;
|
||||
--colour-accent: #1d6fa6;
|
||||
--colour-accent-light: #3b9fd6;
|
||||
--colour-gold: #f5c842;
|
||||
--colour-text: #e4f2fc;
|
||||
--colour-text-muted: #7aadcc;
|
||||
}
|
||||
|
||||
body,
|
||||
.resource-bar,
|
||||
.mode-bar,
|
||||
.tab-bar,
|
||||
.game-content,
|
||||
.game-sidebar,
|
||||
.panel,
|
||||
.panel-header {
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
background 0.3s,
|
||||
border-color 0.3s,
|
||||
color 0.3s;
|
||||
}
|
||||
|
||||
/* ===================== GODDESS TAB BAR ===================== */
|
||||
.goddess-tab-bar .tab-button.active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--colour-accent),
|
||||
var(--colour-accent-light)
|
||||
);
|
||||
border-color: var(--colour-accent-light);
|
||||
}
|
||||
|
||||
/* ===================== RESOURCE LOCKED STATE ===================== */
|
||||
.resource-locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.resource-locked .resource-value {
|
||||
color: var(--colour-text-muted);
|
||||
}
|
||||
|
||||
.resources-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--colour-border);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* ===================== GODDESS PLACEHOLDER ===================== */
|
||||
.goddess-placeholder {
|
||||
align-items: center;
|
||||
color: var(--colour-text-muted);
|
||||
display: flex;
|
||||
font-size: 1.1rem;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ===================== GODDESS ZONES PANEL ===================== */
|
||||
.goddess-zones-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.zone-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.zone-card {
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.zone-card.locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.zone-card-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.zone-emoji {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.zone-name {
|
||||
flex: 1;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zone-lock-icon {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.zone-description {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.zone-unlock-requirements {
|
||||
background: var(--colour-surface-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.zone-unlock-label {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.zone-unlock-item {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.zone-badge {
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
.zone-badge.unlocked {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--colour-success);
|
||||
}
|
||||
|
||||
/* ===================== GODDESS BOSS PANEL (additions) ===================== */
|
||||
.goddess-boss-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.boss-card.boss-available {
|
||||
border-color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.boss-card.boss-locked {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.consecration-requirement {
|
||||
color: var(--colour-warning);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.exploration-zone-locked-hint {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* ===================== GODDESS QUEST PANEL (additions) ===================== */
|
||||
.goddess-quests-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.zone-filter-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.zone-filter-button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.zone-filter-button:hover:not(:disabled) {
|
||||
background: var(--colour-surface-2);
|
||||
color: var(--colour-text);
|
||||
}
|
||||
|
||||
.zone-filter-button.active {
|
||||
background: var(--colour-accent);
|
||||
border-color: var(--colour-accent-light);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.zone-filter-button.zone-locked {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.zone-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.zone-progress {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.zone-locked-notice {
|
||||
background: var(--colour-surface-2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.quest-card.quest-locked {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.quest-card.quest-available {
|
||||
border-color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.quest-action {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* ===================== DISCIPLES PANEL ===================== */
|
||||
.disciples-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.disciples-balance {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.disciples-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.disciple-card {
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.disciple-card.disciple-locked {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.disciple-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.disciple-title {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.disciple-class {
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
border-radius: 999px;
|
||||
color: var(--colour-accent-light);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
.disciple-count {
|
||||
color: var(--colour-gold);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.disciple-income {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.income-tag {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: 999px;
|
||||
color: var(--colour-success);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
.combat-power-tag {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 999px;
|
||||
color: var(--colour-error);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
.disciple-cost {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cost-label {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.buy-disciple-button {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0.9rem;
|
||||
}
|
||||
|
||||
.disciple-badge {
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
.disciple-badge.locked {
|
||||
background: var(--colour-surface-2);
|
||||
color: var(--colour-text-muted);
|
||||
}
|
||||
|
||||
/* ===================== GODDESS EQUIPMENT PANEL ===================== */
|
||||
.goddess-equipment-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.equipment-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: var(--colour-surface-2);
|
||||
color: var(--colour-text);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--colour-accent);
|
||||
border-color: var(--colour-accent-light);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.equipment-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.goddess-equipment-card {
|
||||
background: var(--colour-surface);
|
||||
border: 2px solid var(--colour-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.goddess-equipment-card.equipped {
|
||||
border-color: var(--colour-gold);
|
||||
box-shadow: 0 0 10px rgba(245, 200, 66, 0.2);
|
||||
}
|
||||
|
||||
.goddess-equipment-card.owned {
|
||||
border-color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.goddess-equipment-card.locked {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.goddess-equipment-card.rarity-common {
|
||||
border-color: #9e9e9e;
|
||||
}
|
||||
|
||||
.goddess-equipment-card.rarity-rare {
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.goddess-equipment-card.rarity-epic {
|
||||
border-color: #9c27b0;
|
||||
}
|
||||
|
||||
.goddess-equipment-card.rarity-legendary {
|
||||
border-color: #ff9800;
|
||||
box-shadow: 0 0 8px rgba(255, 152, 0, 0.2);
|
||||
}
|
||||
|
||||
.equipment-card-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.equipment-type-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.equipment-name {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.equipment-rarity-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.equipment-description {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.equipment-bonus {
|
||||
color: var(--colour-success);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.equipment-set {
|
||||
color: var(--colour-gold);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.equipment-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.equipment-equipped-badge {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border-radius: 999px;
|
||||
color: var(--colour-success);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.7rem;
|
||||
}
|
||||
|
||||
.equipment-drop-hint {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-equip,
|
||||
.btn-buy {
|
||||
background: var(--colour-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.8rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-equip:hover,
|
||||
.btn-buy:hover:not(:disabled) {
|
||||
background: var(--colour-accent-light);
|
||||
}
|
||||
|
||||
.btn-buy:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ===================== GODDESS UPGRADES PANEL ===================== */
|
||||
.goddess-upgrades-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-resource-bar {
|
||||
background: var(--colour-surface-2);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
|
||||
.panel-resource-bar .resource-item {
|
||||
color: var(--colour-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upgrades-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.upgrades-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.goddess-upgrade-card {
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.goddess-upgrade-card.purchased {
|
||||
border-color: var(--colour-success);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.goddess-upgrade-card.available {
|
||||
border-color: var(--colour-accent);
|
||||
}
|
||||
|
||||
.goddess-upgrade-card.locked {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.goddess-upgrade-card.cannot-afford {
|
||||
border-color: var(--colour-border);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.upgrade-card-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.upgrade-name {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upgrade-target-badge {
|
||||
background: var(--colour-surface-2);
|
||||
border-radius: 999px;
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.55rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.upgrade-description {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upgrade-effect {
|
||||
color: var(--colour-success);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upgrade-disciple {
|
||||
color: var(--colour-accent-light);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* ===================== GODDESS CONSECRATION / ENLIGHTENMENT ===================== */
|
||||
.consecration-panel,
|
||||
.enlightenment-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.consecration-toast,
|
||||
.enlightenment-toast {
|
||||
background: var(--colour-surface-2);
|
||||
border-left: 4px solid var(--colour-accent);
|
||||
border-radius: var(--radius);
|
||||
color: var(--colour-text);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
/* ===================== GODDESS ACHIEVEMENTS PANEL ===================== */
|
||||
.goddess-achievements-panel,
|
||||
.achievement-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.achievement-progress-label {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ===================== VAMPIRE THEME ===================== */
|
||||
body.vampire-mode {
|
||||
--colour-bg: #1a0a0a;
|
||||
--colour-surface: #2d1515;
|
||||
--colour-surface-2: #3d2020;
|
||||
--colour-border: #5c3d3d;
|
||||
--colour-accent: #c41e3a;
|
||||
--colour-accent-light: #e84c3d;
|
||||
--colour-gold: #d4a574;
|
||||
--colour-text: #f5e6e6;
|
||||
--colour-text-muted: #b8a8a8;
|
||||
}
|
||||
|
||||
/* ===================== VAMPIRE TAB BAR ===================== */
|
||||
.vampire-tab-bar .tab-button.active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--colour-accent),
|
||||
var(--colour-accent-light)
|
||||
);
|
||||
border-color: var(--colour-accent-light);
|
||||
}
|
||||
|
||||
/* ===================== VAMPIRE PLACEHOLDER ===================== */
|
||||
.vampire-placeholder {
|
||||
align-items: center;
|
||||
color: var(--colour-text-muted);
|
||||
display: flex;
|
||||
font-size: 1.1rem;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user