diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx
index 344b701..86b4f86 100644
--- a/apps/web/src/components/game/gameLayout.tsx
+++ b/apps/web/src/components/game/gameLayout.tsx
@@ -53,6 +53,9 @@ 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 { VampireQuestsPanel } from "./vampireQuestsPanel.js";
+import { VampireZonesPanel } from "./vampireZonesPanel.js";
type Mode = "mortal" | "goddess" | "vampire";
@@ -479,9 +482,7 @@ const GameLayout = (): JSX.Element => {
&& }
{activeMode === "vampire"
&& activeVampireTab === "vampire-zones"
- &&
-
{"πΊοΈ Vampire Zones coming soon..."}
-
+ &&
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-bosses"
@@ -491,9 +492,7 @@ const GameLayout = (): JSX.Element => {
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-quests"
- &&
-
{"π Vampire Quests coming soon..."}
-
+ &&
}
{activeMode === "vampire"
&& activeVampireTab === "thralls"
@@ -539,9 +538,7 @@ const GameLayout = (): JSX.Element => {
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-achievements"
- &&
-
{"π Vampire Achievements coming soon..."}
-
+ &&
}
diff --git a/apps/web/src/components/game/vampireAchievementsPanel.tsx b/apps/web/src/components/game/vampireAchievementsPanel.tsx
new file mode 100644
index 0000000..d644b8f
--- /dev/null
+++ b/apps/web/src/components/game/vampireAchievementsPanel.tsx
@@ -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 (
+
+
+ {achievement.icon}
+
+
+
{achievement.name}
+
{achievement.description}
+
+ {conditionDescription(achievement, formatNumber)}
+
+ {!isUnlocked
+ &&
+
+
+ {formatNumber(progressValue)}
+ {" / "}
+ {formatNumber(achievement.condition.amount)}
+
+
+ }
+ {achievement.reward !== undefined
+ &&
+ {achievement.reward.ichor !== undefined
+ &&
+ {"π§ +"}
+ {achievement.reward.ichor}
+ {" Ichor"}
+
+ }
+ {achievement.reward.soulShards !== undefined
+ &&
+ {"π +"}
+ {achievement.reward.soulShards}
+ {" Soul Shards"}
+
+ }
+
+ }
+
+
+ {isUnlocked
+ ? <>
+ {"β Unlocked"}
+ {achievement.unlockedAt !== null
+ &&
+ {new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ })}
+
+ }
+ >
+ : {"π"}
+ }
+
+
+ );
+};
+
+/**
+ * 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 (
+
+ );
+ }
+
+ const { vampire } = state;
+
+ if (vampire === undefined) {
+ return (
+
+ {"The Vampire expansion is not yet unlocked."}
+
+ );
+ }
+
+ 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 (
+
+
+
{"π©Έ Vampire Achievements"}
+
+
+
+ {unlocked.length}
+ {" / "}
+ {achievementList.length}
+ {" unlocked"}
+
+
+ {visible.map((achievement) => {
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export { VampireAchievementsPanel };
diff --git a/apps/web/src/components/game/vampireQuestsPanel.tsx b/apps/web/src/components/game/vampireQuestsPanel.tsx
new file mode 100644
index 0000000..91af4de
--- /dev/null
+++ b/apps/web/src/components/game/vampireQuestsPanel.tsx
@@ -0,0 +1,264 @@
+/**
+ * @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 (
+
+
+
{quest.name}
+
{quest.description}
+
+ {"β± "}
+ {formatDuration(quest.durationSeconds)}
+
+
+ {quest.rewards.map((reward, rewardIndex) => {
+ return
+ {getRewardLabel(reward, formatNumber)}
+ ;
+ })}
+
+
+
+ {quest.status === "locked" && !zoneIsOpen
+ &&
{"π Zone Locked"}
+ }
+ {quest.status === "locked" && zoneIsOpen
+ ? <>
+
{"π Locked"}
+ {unlockHint !== undefined
+ &&
+ {"π Complete: "}
+ {unlockHint}
+
+ }
+ >
+ : null
+ }
+ {quest.status === "available"
+ &&
{"π Available"}
+ }
+ {quest.status === "active"
+ &&
{"β³ In Progress"}
+ }
+ {quest.status === "completed"
+ &&
{"β
Completed"}
+ }
+
+
+ );
+};
+
+/**
+ * Renders the vampire quests panel with zone selection and quest list.
+ * @returns The JSX element.
+ */
+const VampireQuestsPanel = (): JSX.Element => {
+ const { state } = useGame();
+ const [ activeZoneId, setActiveZoneId ] = useState(() => {
+ return sessionStorage.getItem("elysium_vampire_quest_zone")
+ ?? "vampire_haunted_catacombs";
+ });
+
+ if (state === null) {
+ return (
+
+ );
+ }
+
+ const vampireState = state.vampire;
+ if (vampireState === undefined) {
+ return (
+
+ {"Vampire expansion not yet unlocked."}
+
+ );
+ }
+
+ const { zones, quests } = vampireState;
+
+ 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 (
+
+ {"Vampire Quests"}
+
+ {zones.map((zone: VampireZone) => {
+ function handleClick(): void {
+ handleZoneSelect(zone.id);
+ }
+ return ;
+ })}
+
+ {activeZone !== undefined
+ &&
+
{activeZone.description}
+
+ {String(completedCount)}
+ {" / "}
+ {String(zoneQuests.length)}
+ {" quests completed"}
+
+ {activeZone.status === "locked"
+ &&
+ {"π This zone is locked. Defeat the required vampire boss"}
+ {" to unlock it."}
+
+ }
+
+ }
+
+ {zoneQuests.length === 0
+ ?
{"No quests in this zone."}
+ : zoneQuests.map((quest: VampireQuest) => {
+ return
;
+ })
+ }
+
+
+ );
+};
+
+export { VampireQuestsPanel };
diff --git a/apps/web/src/components/game/vampireZonesPanel.tsx b/apps/web/src/components/game/vampireZonesPanel.tsx
new file mode 100644
index 0000000..e42826f
--- /dev/null
+++ b/apps/web/src/components/game/vampireZonesPanel.tsx
@@ -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 (
+
+
+ {zone.emoji}
+
{zone.name}
+ {isLocked
+ ? {"π"}
+ : null}
+
+
+
{zone.description}
+
+ {isLocked
+ && (unlockBossName !== undefined || unlockQuestName !== undefined)
+ ?
+
{"Unlock requirements:"}
+ {unlockBossName === undefined
+ ? null
+ :
+ {"π©Έ Defeat: "}
+ {unlockBossName}
+
+ }
+ {unlockQuestName === undefined
+ ? null
+ :
+ {"π Complete: "}
+ {unlockQuestName}
+
+ }
+
+ : null}
+
+ {isLocked
+ ? null
+ :
{"π©Έ Unlocked"}
+ }
+
+ );
+};
+
+/**
+ * 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 (
+
+ );
+ }
+
+ const { vampire } = state;
+
+ if (vampire === undefined) {
+ return (
+
+ {"The Vampire expansion is not yet unlocked."}
+
+ );
+ }
+
+ const { bosses: vampireBosses, quests: vampireQuests, zones } = vampire;
+
+ const defeatedBossIds = new Set(
+ vampireBosses.
+ filter((boss) => {
+ return boss.status === "defeated";
+ }).
+ map((boss) => {
+ return boss.id;
+ }),
+ );
+
+ return (
+
+
+
{"πΊοΈ Vampire Zones"}
+
+
+
+ {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 (
+
+ );
+ })}
+
+
+ );
+};
+
+export { VampireZonesPanel };