feat: goddess expansion chunks 6–9 — UI panels, tick engine, CSS theme, about page

- Add 11 goddess panels (zones, bosses, quests, disciples, equipment,
  upgrades, consecration, enlightenment, crafting, exploration, achievements)
- Wire all panels into gameLayout via mode/tab routing
- Add goddess passive income, disciple tick, quest timers, zone/quest
  unlock logic, and achievement checking to the tick engine
- Add goddess CSS variables, .goddess-mode overrides, 300ms fade
  transition, and full panel stylesheet coverage
- Add 13 Goddess expansion entries to the How to Play guide
- Add web-side data files for crafting recipes, exploration areas, materials
This commit is contained in:
2026-04-13 18:38:27 -07:00
committed by Naomi Carrigan
parent 96d6759661
commit 91c9f52daf
21 changed files with 7093 additions and 108 deletions
+333
View File
@@ -16,6 +16,8 @@ import {
type Achievement,
type Equipment,
type GameState,
type GoddessAchievement,
type GoddessState,
computeSetBonuses,
getActiveCompanionBonus,
} from "@elysium/types";
@@ -83,6 +85,62 @@ 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;
});
};
/**
* 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.
@@ -770,6 +828,269 @@ 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;
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;
}
const goldValue = capResource(state.resources.gold + goldGained + questGold);
const essenceValue = capResource(
state.resources.essence + essenceGained + questEssence,
@@ -784,8 +1105,17 @@ export const applyTick = (
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 +1137,9 @@ export const applyTick = (
}),
},
},
...updatedGoddess === undefined
? {}
: { goddess: updatedGoddess },
adventurers: updatedAdventurers,
bosses: updatedBosses,
equipment: updatedEquipmentReference,