generated from nhcarrigan/template
feat: vampire crafting and exploration panels with dark materials data
This commit is contained in:
@@ -56,7 +56,9 @@ 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";
|
||||
@@ -520,15 +522,11 @@ const GameLayout = (): JSX.Element => {
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-crafting"
|
||||
&& <div className="vampire-placeholder">
|
||||
<p>{"⚗️ Vampire Crafting coming soon..."}</p>
|
||||
</div>
|
||||
&& <VampireCraftingPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-exploration"
|
||||
&& <div className="vampire-placeholder">
|
||||
<p>{"🌑 Vampire Exploration coming soon..."}</p>
|
||||
</div>
|
||||
&& <VampireExplorationPanel />
|
||||
}
|
||||
{activeMode === "vampire"
|
||||
&& activeVampireTab === "vampire-achievements"
|
||||
|
||||
@@ -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,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 };
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type AwakeningResponse,
|
||||
type SiringResponse,
|
||||
type VampireBossChallengeResponse,
|
||||
type VampireExploreCollectResponse,
|
||||
type LoginBonusResult,
|
||||
type NumberFormat,
|
||||
type Quest,
|
||||
@@ -59,9 +60,11 @@ import {
|
||||
challengeVampireBoss as challengeVampireBossApi,
|
||||
collectExploration as collectExplorationApi,
|
||||
collectGoddessExploration as collectGoddessExplorationApi,
|
||||
collectVampireExploration as collectVampireExplorationApi,
|
||||
consecrate as consecrateApi,
|
||||
craftGoddessRecipe as craftGoddessRecipeApi,
|
||||
craftRecipe as craftRecipeApi,
|
||||
craftVampireRecipe as craftVampireRecipeApi,
|
||||
debugHardReset as debugHardResetApi,
|
||||
enlighten as enlightenApi,
|
||||
forceUnlocks as forceUnlocksApi,
|
||||
@@ -73,6 +76,7 @@ import {
|
||||
sire as sireApi,
|
||||
startExploration as startExplorationApi,
|
||||
startGoddessExploration as startGoddessExplorationApi,
|
||||
startVampireExploration as startVampireExplorationApi,
|
||||
transcend as transcendApi,
|
||||
} from "../api/client.js";
|
||||
import { CODEX_ENTRIES } from "../data/codex.js";
|
||||
@@ -834,6 +838,23 @@ interface GameContextValue {
|
||||
* Purchase an awakening upgrade from the soul shards shop.
|
||||
*/
|
||||
buyAwakeningUpgrade: (upgradeId: string)=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Craft a vampire recipe using dark materials.
|
||||
*/
|
||||
craftVampireRecipe: (recipeId: string)=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Start a vampire exploration in the given area.
|
||||
*/
|
||||
startVampireExploration: (areaId: string)=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Collect results of a completed vampire exploration.
|
||||
*/
|
||||
collectVampireExploration: (
|
||||
areaId: string,
|
||||
)=> Promise<VampireExploreCollectResponse>;
|
||||
}
|
||||
|
||||
export interface BattleResult {
|
||||
@@ -2876,6 +2897,166 @@ export const GameProvider = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const craftVampireRecipe = useCallback(async(recipeId: string) => {
|
||||
try {
|
||||
const result = await craftVampireRecipeApi({ recipeId });
|
||||
signatureReference.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
setState((previous) => {
|
||||
if (previous?.vampire === undefined) {
|
||||
return previous;
|
||||
}
|
||||
let materials = [ ...previous.vampire.exploration.materials ];
|
||||
for (const cost of result.materials) {
|
||||
materials = materials.map((m) => {
|
||||
return m.materialId === cost.materialId
|
||||
? { ...m, quantity: m.quantity - cost.quantity }
|
||||
: m;
|
||||
});
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
vampire: {
|
||||
...previous.vampire,
|
||||
exploration: {
|
||||
...previous.vampire.exploration,
|
||||
craftedBloodMultiplier: result.craftedBloodMultiplier,
|
||||
craftedCombatMultiplier: result.craftedCombatMultiplier,
|
||||
craftedIchorMultiplier: result.craftedIchorMultiplier,
|
||||
craftedRecipeIds: [
|
||||
...previous.vampire.exploration.craftedRecipeIds,
|
||||
result.recipeId,
|
||||
],
|
||||
materials: materials,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error_: unknown) {
|
||||
logError("craft_vampire_recipe", error_);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startVampireExploration = useCallback(async(areaId: string) => {
|
||||
const response = await startVampireExplorationApi({ areaId });
|
||||
setState((previous) => {
|
||||
if (previous?.vampire === undefined) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
vampire: {
|
||||
...previous.vampire,
|
||||
exploration: {
|
||||
...previous.vampire.exploration,
|
||||
areas: previous.vampire.exploration.areas.map((a) => {
|
||||
return a.id === areaId
|
||||
? {
|
||||
...a,
|
||||
endsAt: response.endsAt,
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: a;
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const collectVampireExploration = useCallback(
|
||||
async(areaId: string): Promise<VampireExploreCollectResponse> => {
|
||||
isSyncingReference.current = true;
|
||||
const result = await collectVampireExplorationApi({ areaId });
|
||||
signatureReference.current = null;
|
||||
localStorage.removeItem("elysium_save_signature");
|
||||
lastSaveReference.current = Date.now();
|
||||
isSyncingReference.current = false;
|
||||
setState((previous) => {
|
||||
if (previous?.vampire === undefined) {
|
||||
return previous;
|
||||
}
|
||||
let materials = [ ...previous.vampire.exploration.materials ];
|
||||
for (const drop of result.materialsFound) {
|
||||
const existing = materials.find((m) => {
|
||||
return m.materialId === drop.materialId;
|
||||
});
|
||||
if (existing === undefined) {
|
||||
materials = [
|
||||
...materials,
|
||||
{ materialId: drop.materialId, quantity: drop.quantity },
|
||||
];
|
||||
} else {
|
||||
materials = materials.map((m) => {
|
||||
return m.materialId === drop.materialId
|
||||
? { ...m, quantity: m.quantity + drop.quantity }
|
||||
: m;
|
||||
});
|
||||
}
|
||||
}
|
||||
const materialGained = result.event?.materialGained;
|
||||
if (materialGained !== null && materialGained !== undefined) {
|
||||
const { materialId, quantity } = materialGained;
|
||||
const existing = materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing === undefined) {
|
||||
materials = [ ...materials, { materialId, quantity } ];
|
||||
} else {
|
||||
materials = materials.map((m) => {
|
||||
return m.materialId === materialId
|
||||
? { ...m, quantity: m.quantity + quantity }
|
||||
: m;
|
||||
});
|
||||
}
|
||||
}
|
||||
let thrallsToLose = result.event?.thrallLostCount ?? 0;
|
||||
const thralls = previous.vampire.thralls.map((thrall) => {
|
||||
if (thrallsToLose <= 0) {
|
||||
return thrall;
|
||||
}
|
||||
const lost = Math.min(thrall.count, thrallsToLose);
|
||||
thrallsToLose = thrallsToLose - lost;
|
||||
return { ...thrall, count: thrall.count - lost };
|
||||
});
|
||||
return {
|
||||
...previous,
|
||||
resources: {
|
||||
...previous.resources,
|
||||
blood: Math.max(
|
||||
0,
|
||||
(previous.resources.blood ?? 0)
|
||||
+ (result.event?.bloodChange ?? 0),
|
||||
),
|
||||
},
|
||||
vampire: {
|
||||
...previous.vampire,
|
||||
exploration: {
|
||||
...previous.vampire.exploration,
|
||||
areas: previous.vampire.exploration.areas.map((a) => {
|
||||
return a.id === areaId
|
||||
? { ...a, completedOnce: true, status: "available" as const }
|
||||
: a;
|
||||
}),
|
||||
materials: materials,
|
||||
},
|
||||
siring: {
|
||||
...previous.vampire.siring,
|
||||
ichor: Math.max(
|
||||
0,
|
||||
previous.vampire.siring.ichor
|
||||
+ (result.event?.ichorChange ?? 0),
|
||||
),
|
||||
},
|
||||
thralls: thralls,
|
||||
},
|
||||
};
|
||||
});
|
||||
return result;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const startExploration = useCallback(async(areaId: string) => {
|
||||
const response = await startExplorationApi({ areaId });
|
||||
setState((previous) => {
|
||||
@@ -3453,11 +3634,13 @@ export const GameProvider = ({
|
||||
challengeVampireBoss,
|
||||
collectExploration,
|
||||
collectGoddessExploration,
|
||||
collectVampireExploration,
|
||||
completeChapter,
|
||||
completedQuestToasts,
|
||||
consecrate,
|
||||
craftGoddessRecipe,
|
||||
craftRecipe,
|
||||
craftVampireRecipe,
|
||||
currentSchemaVersion,
|
||||
debugHardReset,
|
||||
dismissAchievement,
|
||||
@@ -3517,6 +3700,7 @@ export const GameProvider = ({
|
||||
startExploration,
|
||||
startGoddessExploration,
|
||||
startQuest,
|
||||
startVampireExploration,
|
||||
state,
|
||||
syncError,
|
||||
syncNewContent,
|
||||
@@ -3559,11 +3743,13 @@ export const GameProvider = ({
|
||||
challengeVampireBoss,
|
||||
collectExploration,
|
||||
collectGoddessExploration,
|
||||
collectVampireExploration,
|
||||
completeChapter,
|
||||
completedQuestToasts,
|
||||
consecrate,
|
||||
craftGoddessRecipe,
|
||||
craftRecipe,
|
||||
craftVampireRecipe,
|
||||
currentSchemaVersion,
|
||||
debugHardReset,
|
||||
dismissAchievement,
|
||||
@@ -3622,6 +3808,7 @@ export const GameProvider = ({
|
||||
startExploration,
|
||||
startGoddessExploration,
|
||||
startQuest,
|
||||
startVampireExploration,
|
||||
state,
|
||||
syncError,
|
||||
syncNewContent,
|
||||
|
||||
@@ -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,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",
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user