Files
elysium/apps/web/src/components/game/BossPanel.tsx
T
hikari e780dc5f6c feat: sequential zone unlocking with dual boss+quest gate conditions
Zones now unlock in strict linear order. Each zone requires both the
final boss AND the final quest of the previous zone to be completed:

Verdant Vale → Shattered Ruins (forest_giant + ancient_ruins)
Shattered Ruins → Frozen Peaks (elder_dragon + dragon_lair)
Frozen Peaks → Shadow Marshes (void_titan + storm_citadel)
Shadow Marshes → Volcanic Depths (mud_kraken + plague_ruins)
Volcanic Depths → Astral Void (phoenix_lord + the_forge)

- Zone type gains `unlockQuestId` field alongside `unlockBossId`
- boss.ts checks both conditions before unlocking zone on boss defeat
- tick.ts checks both conditions and unlocks zones + first boss on
  quest completion if the boss condition is already met
- GameContext optimistic update also respects dual-condition logic
- BossPanel zone-gate hints now show both "⚔️ Defeat: X & 📜 Complete: Y"
- game.ts backfill syncs unlockQuestId and re-verifies zone status
  using both conditions, reverting incorrectly-unlocked saves
2026-03-06 16:04:52 -08:00

230 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Boss } from "@elysium/types";
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
import { LockToggle } from "../ui/LockToggle.js";
import { ZoneSelector } from "./ZoneSelector.js";
interface BossCardProps {
boss: Boss;
prestigeCount: number;
onChallenge: (bossId: string) => void;
isChallenging: boolean;
unlockHint?: string | undefined;
}
const BossCard = ({
boss,
prestigeCount,
onChallenge,
isChallenging,
unlockHint,
}: BossCardProps): React.JSX.Element => {
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
const canChallenge =
(boss.status === "available" || boss.status === "in_progress") && !isChallenging;
return (
<div className={`boss-card boss-${boss.status}`}>
<div className="boss-info">
<h3>{boss.name}</h3>
<p>{boss.description}</p>
{isPrestigeLocked && boss.status === "locked" && (
<p className="prestige-lock">
🔒 Requires Prestige {boss.prestigeRequirement}
</p>
)}
{!isPrestigeLocked && boss.status === "locked" && unlockHint && (
<p className="unlock-hint">{unlockHint}</p>
)}
</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>
)}
<div className="boss-meta">
<span className="boss-dps">💢 Boss DPS: {formatNumber(boss.damagePerSecond)}</span>
</div>
<div className="boss-rewards">
<span>🪙 {formatNumber(boss.goldReward)}</span>
{boss.essenceReward > 0 && (
<span> {formatNumber(boss.essenceReward)}</span>
)}
{boss.crystalReward > 0 && (
<span>💎 {formatNumber(boss.crystalReward)}</span>
)}
{(boss.equipmentRewards ?? []).length > 0 && (
<span>🗡 {boss.equipmentRewards.length} Equipment</span>
)}
</div>
{(boss.status === "available" || boss.status === "in_progress") && (
<button
className="attack-button"
disabled={!canChallenge}
onClick={() => {
onChallenge(boss.id);
}}
type="button"
>
{isChallenging ? "⚔️ Battling…" : "⚔️ Challenge"}
</button>
)}
{boss.status === "defeated" && (
<span className="boss-badge defeated"> Defeated</span>
)}
</div>
);
};
export const BossPanel = (): React.JSX.Element => {
const { state, challengeBoss } = useGame();
const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
const [showLocked, setShowLocked] = useState(true);
if (!state) return <section className="panel"><p>Loading...</p></section>;
// Calculate party combat stats including equipment multiplier
let globalMultiplier = 1;
for (const upgrade of state.upgrades) {
if (upgrade.purchased && upgrade.target === "global") {
globalMultiplier *= upgrade.multiplier;
}
}
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const equipmentCombatMultiplier = (state.equipment ?? [])
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
let partyDPS = 0;
let partyHP = 0;
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) continue;
let adventurerMultiplier = 1;
for (const upgrade of state.upgrades) {
if (
upgrade.purchased &&
upgrade.target === "adventurer" &&
upgrade.adventurerId === adventurer.id
) {
adventurerMultiplier *= upgrade.multiplier;
}
}
partyDPS +=
adventurer.combatPower *
adventurer.count *
adventurerMultiplier *
globalMultiplier *
prestigeMultiplier;
partyHP += adventurer.level * 50 * adventurer.count;
}
partyDPS *= equipmentCombatMultiplier;
const handleChallenge = async (bossId: string): Promise<void> => {
setChallengingBossId(bossId);
try {
await challengeBoss(bossId);
} finally {
setChallengingBossId(null);
}
};
const zones = state.zones ?? [];
const zoneBosses = state.bosses.filter((b) => b.zoneId === activeZoneId);
const lockedCount = zoneBosses.filter((b) => b.status === "locked").length;
const visibleBosses = showLocked
? zoneBosses
: zoneBosses.filter((b) => b.status !== "locked");
const bossUnlockHints = new Map<string, string>();
for (const zone of zones) {
const allZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
for (let i = 0; i < allZoneBosses.length; i++) {
const boss = allZoneBosses[i];
if (!boss || boss.status !== "locked") continue;
if (i === 0) {
const parts: string[] = [];
if (zone.unlockBossId) {
const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
if (gateBoss) parts.push(`⚔️ Defeat: ${gateBoss.name}`);
}
if (zone.unlockQuestId) {
const gateQuest = state.quests.find((q) => q.id === zone.unlockQuestId);
if (gateQuest) parts.push(`📜 Complete: ${gateQuest.name}`);
}
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} else {
const prevBoss = allZoneBosses[i - 1];
if (prevBoss) {
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`);
}
}
}
}
return (
<section className="panel boss-panel">
<div className="panel-header">
<h2>Boss Encounters</h2>
<LockToggle
lockedCount={lockedCount}
showLocked={showLocked}
onToggle={() => { setShowLocked((v) => !v); }}
/>
</div>
<ZoneSelector
activeZoneId={activeZoneId}
zones={zones}
onSelectZone={setActiveZoneId}
/>
<div className="party-combat-stats">
<div className="combat-stat">
<span className="stat-label"> Party DPS</span>
<span className="stat-value">{formatNumber(partyDPS)}</span>
</div>
<div className="combat-stat">
<span className="stat-label"> Party HP</span>
<span className="stat-value">{formatNumber(partyHP)}</span>
</div>
</div>
<div className="boss-list">
{visibleBosses.map((boss) => (
<BossCard
key={boss.id}
boss={boss}
isChallenging={challengingBossId === boss.id}
prestigeCount={state.prestige.count}
unlockHint={bossUnlockHints.get(boss.id)}
onChallenge={(id) => {
void handleChallenge(id);
}}
/>
))}
{visibleBosses.length === 0 && (
<p className="empty-zone">No bosses to show in this zone.</p>
)}
</div>
</section>
);
};