Release v0.1.4 2026-07-12

This commit is contained in:
Warren H
2026-07-12 13:49:46 -04:00
parent b48b3a4f8f
commit bef71d391a
803 changed files with 3800 additions and 358322 deletions
+1 -2
View File
@@ -6,7 +6,7 @@ import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store";
import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings, useGameLoop } from "./game/useGameLoop";
import { useActionBindings } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
@@ -25,7 +25,6 @@ function GameLoadingScreen() {
export default function App() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
useGameLoop();
const screen = useFrontendStore((state) => state.screen);
const hunter = useActiveHunter();
const settings = useFrontendStore((state) => state.settings);
+5
View File
@@ -0,0 +1,5 @@
# Runtime game assets
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
Binary file not shown.
Binary file not shown.
+350
View File
@@ -0,0 +1,350 @@
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
import { useGameStore } from "../game/store";
const ROOM_CENTER_Z = ARENA_CENTER[1];
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
type ArenaFixture = {
position: readonly [number, number, number];
rotationY: number;
scale: number;
scaleX?: number;
scaleZ?: number;
scaleY?: number;
};
const ARENA_WALL_SEGMENTS: readonly ArenaFixture[] = Array.from({ length: 16 }, (_, index) => {
const angle = (index / 16) * Math.PI * 2;
return {
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 0, ROOM_CENTER_Z + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
rotationY: angle,
scale: 1,
scaleX: ARENA_SIZE_MULTIPLIER,
};
});
const ARENA_COLUMNS: readonly ArenaFixture[] = Array.from({ length: 8 }, (_, index) => {
const angle = (index / 8) * Math.PI * 2 + Math.PI / 8;
return {
position: [Math.sin(angle) * 8.2 * ARENA_SIZE_MULTIPLIER, 0, ROOM_CENTER_Z + Math.cos(angle) * 8.2 * ARENA_SIZE_MULTIPLIER] as const,
rotationY: angle + Math.PI / 2,
scale: 0.9,
};
});
const ARENA_TORCH_COLORS = [
new THREE.Color("#ffb24d"),
new THREE.Color("#ff7048"),
new THREE.Color("#f7dd88"),
new THREE.Color("#72d6ce"),
] as const;
const ARENA_TORCHES: readonly ArenaFixture[] = ARENA_COLUMNS.map((column) => ({
position: [column.position[0], 3.45, column.position[2]] as const,
rotationY: column.rotationY,
scale: 0.9,
}));
const SCENERY_SLOTS = Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2 + Math.PI / 10;
return { angle, x: Math.sin(angle) * 8.45 * ARENA_SIZE_MULTIPLIER, z: ROOM_CENTER_Z + Math.cos(angle) * 8.45 * ARENA_SIZE_MULTIPLIER };
});
type MarkPattern = "rays" | "rings" | "cross" | "pools";
const ROOM_PATTERNS: Record<BossRoomFloor, MarkPattern> = {
cinder: "rays",
desert: "rings",
tide: "pools",
reliquary: "rings",
royal: "cross",
prism: "rays",
storm: "rays",
wilds: "rings",
void: "rays",
mire: "pools",
quarry: "rays",
junkyard: "cross",
web: "rings",
moon: "rings",
frost: "rays",
warcamp: "cross",
sky: "pools",
};
type SceneryKind = "crystal" | "obelisk" | "coral" | "grave" | "crown" | "shard" | "lightning" | "tree" | "rift" | "reed" | "boulder" | "scrap" | "web" | "moonstone" | "ice" | "banner" | "cloud";
const ROOM_SCENERY: Record<BossRoomFloor, SceneryKind> = {
cinder: "crystal",
desert: "obelisk",
tide: "coral",
reliquary: "grave",
royal: "crown",
prism: "shard",
storm: "lightning",
wilds: "tree",
void: "rift",
mire: "reed",
quarry: "boulder",
junkyard: "scrap",
web: "web",
moon: "moonstone",
frost: "ice",
warcamp: "banner",
sky: "cloud",
};
function firstMesh(scene: THREE.Object3D) {
let mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]> | undefined;
scene.traverse((child) => {
if (!mesh && child instanceof THREE.Mesh) mesh = child;
});
if (!mesh) throw new Error("Dungeon arena asset has no mesh.");
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
}
function DungeonAssetInstances({
url,
fixtures,
tint,
opacity = 1,
colors,
}: {
url: string;
fixtures: readonly ArenaFixture[];
tint: string;
opacity?: number;
colors?: readonly THREE.Color[];
}) {
const gltf = useGLTF(url, false, true);
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
const material = useMemo(() => {
const next = sourceMaterial.clone();
if ("color" in next) (next as THREE.MeshStandardMaterial).color.set(tint);
next.transparent = opacity < 1;
next.opacity = opacity;
next.depthWrite = opacity >= 1;
return next;
}, [opacity, sourceMaterial, tint]);
const instances = useRef<THREE.InstancedMesh>(null);
useEffect(() => () => material.dispose(), [material]);
useLayoutEffect(() => {
if (!instances.current) return;
const dummy = new THREE.Object3D();
fixtures.forEach((fixture, index) => {
dummy.position.fromArray(fixture.position);
dummy.rotation.set(0, fixture.rotationY, 0);
dummy.scale.set(
fixture.scale * (fixture.scaleX ?? 1),
fixture.scale * (fixture.scaleY ?? 1),
fixture.scale * (fixture.scaleZ ?? 1),
);
dummy.updateMatrix();
instances.current!.setMatrixAt(index, dummy.matrix);
if (colors) instances.current!.setColorAt(index, colors[index % colors.length]);
});
instances.current.instanceMatrix.needsUpdate = true;
if (instances.current.instanceColor) instances.current.instanceColor.needsUpdate = true;
instances.current.computeBoundingSphere();
}, [colors, fixtures]);
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
}
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
...fixture,
scaleY: room.wallHeight / 4,
})), [room.wallHeight]);
return (
<group>
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
</group>
);
}
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
const walls = useRef<THREE.Group>(null);
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
useFrame(({ camera }) => {
if (!walls.current) return;
const previous = previousCameraPosition.current;
if (previous && previous.distanceToSquared(camera.position) < 0.0001) return;
const cameraPosition = previous ?? new THREE.Vector3();
cameraPosition.copy(camera.position);
previousCameraPosition.current = cameraPosition;
for (const child of walls.current.children) {
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
const distance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
material.opacity = THREE.MathUtils.smoothstep(distance, 2.5, 7.5) * 0.48 + 0.05;
}
});
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
<mesh
key={index}
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
rotation={[0, fixture.rotationY, 0]}
receiveShadow
>
<boxGeometry args={[3.86 * ARENA_SIZE_MULTIPLIER, room.wallHeight, 0.22]} />
<meshStandardMaterial color={room.wallColor} roughness={0.9} transparent opacity={0.53} depthWrite={false} />
</mesh>
))}
</group>
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
return (
<Suspense fallback={<RoomWallFallback room={room} />}>
<ArenaArchitecture room={room} />
</Suspense>
);
}
function RoomMarks({ room }: { room: BossRoomDefinition }) {
const rays = useRef<THREE.InstancedMesh>(null);
const pattern = ROOM_PATTERNS[room.floor];
useLayoutEffect(() => {
if (!rays.current || pattern === "rings" || pattern === "pools") return;
const dummy = new THREE.Object3D();
const count = pattern === "cross" ? 4 : 8;
for (let index = 0; index < count; index += 1) {
const angle = (index / count) * Math.PI * 2 + (pattern === "cross" ? Math.PI / 4 : 0);
const radius = (pattern === "cross" ? 3 : 4.3) * ARENA_SIZE_MULTIPLIER;
dummy.position.set(Math.sin(angle) * radius, 0.025, ROOM_CENTER_Z + Math.cos(angle) * radius);
dummy.rotation.set(0, -angle, 0);
dummy.scale.set(
(pattern === "cross" ? 1.6 : 0.75) * ARENA_SIZE_MULTIPLIER,
1,
(pattern === "cross" ? 0.48 : 2.6) * ARENA_SIZE_MULTIPLIER,
);
dummy.updateMatrix();
rays.current.setMatrixAt(index, dummy.matrix);
}
rays.current.instanceMatrix.needsUpdate = true;
}, [pattern]);
if (pattern === "rings") {
return (
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.022, ROOM_CENTER_Z]}>
<mesh><ringGeometry args={[2.4 * ARENA_SIZE_MULTIPLIER, 2.5 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accent} transparent opacity={0.32} /></mesh>
<mesh><ringGeometry args={[5.2 * ARENA_SIZE_MULTIPLIER, 5.3 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accentSecondary} transparent opacity={0.22} /></mesh>
<mesh><ringGeometry args={[7.5 * ARENA_SIZE_MULTIPLIER, 7.58 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accent} transparent opacity={0.16} /></mesh>
</group>
);
}
if (pattern === "pools") {
return (
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.024, ROOM_CENTER_Z]}>
{[[0, 0, 2.5], [4, -2.4, 1.5], [-4.4, 2.8, 1.25]].map(([x, z, radius], index) => (
<mesh key={index} position={[x * ARENA_SIZE_MULTIPLIER, z * ARENA_SIZE_MULTIPLIER, 0]}>
<circleGeometry args={[radius * ARENA_SIZE_MULTIPLIER, 32]} />
<meshBasicMaterial color={index === 0 ? room.accent : room.accentSecondary} transparent opacity={0.2} depthWrite={false} />
</mesh>
))}
</group>
);
}
return (
<instancedMesh ref={rays} args={[undefined, undefined, 8]}>
<boxGeometry args={[0.15, 0.025, 1]} />
<meshBasicMaterial color={room.accent} transparent opacity={0.3} depthWrite={false} />
</instancedMesh>
);
}
function RoomScenery({ room }: { room: BossRoomDefinition }) {
const instances = useRef<THREE.InstancedMesh>(null);
const kind = ROOM_SCENERY[room.floor];
const height = kind === "tree" ? 2.1 : kind === "obelisk" || kind === "banner" ? 1.5 : kind === "ice" || kind === "lightning" ? 1.2 : 0.7;
useLayoutEffect(() => {
if (!instances.current) return;
const dummy = new THREE.Object3D();
SCENERY_SLOTS.forEach(({ angle, x, z }, index) => {
const scale = 0.78 + (index % 3) * 0.12;
dummy.position.set(x, height * scale, z);
dummy.rotation.set(0, -angle + (index % 2 ? 0.35 : 0), 0);
dummy.scale.setScalar(scale);
dummy.updateMatrix();
instances.current!.setMatrixAt(index, dummy.matrix);
instances.current!.setColorAt(index, new THREE.Color(index % 2 ? room.accent : room.accentSecondary));
});
instances.current.instanceMatrix.needsUpdate = true;
if (instances.current.instanceColor) instances.current.instanceColor.needsUpdate = true;
}, [height, kind, room.accent, room.accentSecondary]);
return (
<instancedMesh ref={instances} args={[undefined, undefined, SCENERY_SLOTS.length]} castShadow receiveShadow>
{kind === "crystal" || kind === "shard" || kind === "ice" || kind === "lightning" ? <coneGeometry args={[0.46, height * 2, kind === "ice" ? 4 : 5]} />
: kind === "obelisk" || kind === "grave" || kind === "moonstone" ? <boxGeometry args={[0.62, height * 2, 0.38]} />
: kind === "coral" || kind === "reed" ? <cylinderGeometry args={[0.12, 0.34, height * 2, 5]} />
: kind === "crown" ? <cylinderGeometry args={[0.52, 0.68, height * 2, 6]} />
: kind === "tree" ? <coneGeometry args={[0.8, height * 2, 7]} />
: kind === "rift" ? <tetrahedronGeometry args={[height]} />
: kind === "boulder" || kind === "cloud" ? <dodecahedronGeometry args={[height, 0]} />
: kind === "scrap" ? <boxGeometry args={[0.94, height, 0.48]} />
: kind === "web" ? <icosahedronGeometry args={[height, 1]} />
: <boxGeometry args={[0.16, height * 2, 0.95]} />}
<meshStandardMaterial vertexColors color={room.accent} emissive={room.accent} emissiveIntensity={kind === "web" || kind === "rift" ? 0.24 : 0.08} roughness={0.68} />
</instancedMesh>
);
}
function RoomFloor({ room }: { room: BossRoomDefinition }) {
return (
<group>
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
<cylinderGeometry args={[10.5 * ARENA_SIZE_MULTIPLIER, 11.2 * ARENA_SIZE_MULTIPLIER, 0.8, 48]} />
<meshStandardMaterial color={room.ground} roughness={0.94} />
</mesh>
<mesh position={[0, -0.02, ROOM_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<circleGeometry args={[9.35 * ARENA_SIZE_MULTIPLIER, 48]} />
<meshStandardMaterial color={room.floorColor} roughness={0.86} metalness={room.floor === "royal" || room.floor === "junkyard" ? 0.34 : 0.04} />
</mesh>
<mesh position={[0, -0.008, ROOM_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[8.62 * ARENA_SIZE_MULTIPLIER, 8.82 * ARENA_SIZE_MULTIPLIER, 48]} />
<meshBasicMaterial color={room.accentSecondary} transparent opacity={0.32} />
</mesh>
<RoomMarks room={room} />
<RoomScenery room={room} />
<RoomWalls room={room} />
</group>
);
}
/** Main-display room projection. Gameplay stays in the shared arena domain. */
export function BossRoom() {
const bossId = useGameStore((state) => state.boss.id);
const room = bossRoomFor(bossId);
return (
<group key={room.id}>
<color attach="background" args={[room.background]} />
<fog attach="fog" args={[room.fog, 15 * ARENA_SIZE_MULTIPLIER, 32 * ARENA_SIZE_MULTIPLIER]} />
<hemisphereLight args={[room.sky, room.ground, 1.18]} />
<directionalLight castShadow position={[5, 10, 8]} intensity={2.1} color={room.accentSecondary} shadow-mapSize={[512, 512]} />
<pointLight color={room.accent} intensity={2.35} distance={8} position={[-6, 2.8, ROOM_CENTER_Z]} />
<pointLight color={room.accentSecondary} intensity={1.75} distance={7} position={[6, 2.4, ROOM_CENTER_Z - 1]} />
<RoomFloor room={room} />
</group>
);
}
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
+1 -1
View File
@@ -9,7 +9,7 @@ import { useFrontendStore } from "../frontend/store";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
if (!rewards.length) return null;
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.coin.id}-${index}`}><b>{reward.coin.glyph}</b>{reward.coin.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.drop.id}-${index}`}><b>{reward.drop.glyph}</b>{reward.drop.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
}
function HealthBar({ member }: { member: PartyMember }) {
+53 -34
View File
@@ -5,7 +5,7 @@ import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
import { selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import {
@@ -20,7 +20,7 @@ import {
gearBonusText,
gearUpgradeCosts,
} from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossCoinDrop } from "../game/progression/loot";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
import {
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
PASSIVE_INFUSIONS,
@@ -388,7 +388,7 @@ function HomeScreen() {
</div>
<div className="home-secondary-actions">
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i></i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend boss coins</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend group drops</small></span><b></b></FocusButton>
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i></i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b></b></FocusButton>
</div>
<ControllerLegend back />
@@ -424,10 +424,10 @@ function ProfileScreen() {
const hunter = useActiveHunter();
const navigate = useFrontendStore((state) => state.navigate);
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const [bossId, setBossId] = useState(collections[0]?.bossId ?? "");
const collection = collections.find((boss) => boss.bossId === bossId) ?? collections[0];
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const actions = useMemo<MenuAction[]>(() => [
...collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })),
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
{ id: "back", run: () => navigate("home") },
], [collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
@@ -441,22 +441,22 @@ function ProfileScreen() {
top={
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="collection-heading"><span><small>Boss spoils</small><h2>{collection.bossName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}</p>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Every drop stays counted.</strong><small>Duplicates increase quantity instead of disappearing.</small></span></div>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>{collection.bosses.map((boss) => `${boss.bossName}: ${boss.kills} kills · ${boss.pet.count} pets`).join(" · ")}</small></span></div>
</FrontSurface>
}
bottom={
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and boss list">
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and mechanic group list">
<header className="context-header"><span>{hunter.hunterName} · {activeHealer.name} stats</span><b>LEVEL {activeProgress.level}</b></header>
<div className="profile-stats">
<span><small>Total boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
@@ -464,9 +464,9 @@ function ProfileScreen() {
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
</div>
<div className="boss-log"><span>Boss records</span>{collections.map((boss) => (
<FocusButton key={boss.bossId} id={boss.bossId} focusedId={controller.focusedId} focus={controller.focus} className={boss.bossId === collection.bossId ? "is-selected" : ""} onClick={() => setBossId(boss.bossId)}>
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossId] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
<i>{group.defeated ? group.groupLetter : "?"}</i><span><strong>Group {group.groupLetter} · {group.groupName}</strong><small>{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}</small></span><b>{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length}</b>
</FocusButton>
))}</div>
</FrontSurface>
@@ -558,7 +558,7 @@ function GearScreen() {
<DualDisplayFrame
top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Boss coin workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
@@ -576,7 +576,7 @@ function GearScreen() {
{workshopMode === "upgrade" ? <article className="gear-preview">
<span>Selected upgrade</span>
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
<p>{GEAR_STAT_LABELS[recipe.statId]} from {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.</p>
<p>{GEAR_STAT_LABELS[recipe.statId]} from Group {BOSS_GROUP_BY_ID[recipe.primaryGroupId].letter} and Group {BOSS_GROUP_BY_ID[recipe.secondaryGroupId].letter} drops.</p>
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i></i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
</article> : <article className="gear-preview gear-infusion-preview">
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
@@ -593,7 +593,7 @@ function GearScreen() {
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS</b></header>
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
@@ -601,7 +601,7 @@ function GearScreen() {
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend coins · autosave" : "Collect required boss coins"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend coins · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required boss coins"}</small></FocusButton>}
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
@@ -664,6 +664,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const bossPageSize = 12;
const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize)));
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
@@ -671,36 +673,46 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons";
const bossGridRows = Math.min(8, Math.ceil(BOSS_ORDER.length / 3));
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
const changeBossPage = (nextPage: number) => {
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
setBossPage(page);
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
};
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking connects here when game server is configured.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? BOSS_ORDER.map((bossId, index) => {
...(isDungeon ? visibleBossIds.map((bossId, index) => {
const column = Math.floor(index / bossGridRows);
const row = index % bossGridRows;
const neighborInColumn = (targetColumn: number) => {
const columnStart = targetColumn * bossGridRows;
if (columnStart >= BOSS_ORDER.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, BOSS_ORDER.length) - 1;
return `boss-${BOSS_ORDER[Math.min(columnStart + row, columnEnd)]}`;
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
};
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, BOSS_ORDER.length)
? `boss-${BOSS_ORDER[index + 1]}`
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
? `boss-${visibleBossIds[index + 1]}`
: `difficulty-${DIFFICULTIES[0].slug}`,
left: neighborInColumn(column - 1),
right: neighborInColumn(column + 1),
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined),
},
};
}) : []),
...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
@@ -712,8 +724,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug]);
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } },
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
@@ -741,10 +753,17 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<span>Choose encounter</span>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows } as React.CSSProperties}>
{BOSS_ORDER.map((bossId) => {
<div className="boss-picker-heading">
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
<div>
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}> Previous</FocusButton>
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next </FocusButton>
</div>
</div>
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows, "--boss-grid-columns": bossGridColumns } as React.CSSProperties}>
{visibleBossIds.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
const group = BOSS_GROUP_BY_ID[boss.groupId];
return (
<FocusButton
key={bossId}
@@ -756,7 +775,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>Group {group.letter} · {group.name} · {boss.mechanics[0]}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
@@ -778,7 +797,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
</FrontSurface>
}
/>
+300 -253
View File
@@ -1,53 +1,79 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, ARENA_WALL_RADIUS, clampToArena } from "../game/arena";
import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
import {
isActorAnimationOneShot,
shouldStartActorAnimation,
type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
import { useGameStore } from "../game/store";
import type { MemberId, PulseKind } from "../game/types";
import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const INSECT_QUEEN_URL = new URL("../../game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb", import.meta.url).href;
const BLUE_EYES_WHITE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb", import.meta.url).href;
const GATE_GUARDIAN_URL = new URL("../../game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb", import.meta.url).href;
const GANDORA_URL = new URL("../../game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb", import.meta.url).href;
const RED_EYES_BLACK_URL = new URL("../../game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb", import.meta.url).href;
const PUMPKING_URL = new URL("../../game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb", import.meta.url).href;
const BLUE_EYES_ULTIMATE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../../game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../../game_assets/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../../game_assets/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../../game_assets/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
| "bulldrome"
| "sandglass-scorpion"
| "cragclaw-crab"
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
>, string> = {
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
"cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href,
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
"mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href,
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
"warcaller-orc": new URL("../assets/game/models/claudecraft/creatures/orc.glb", import.meta.url).href,
"tuskmaw-orc": new URL("../assets/game/models/claudecraft/creatures/orcenemy.glb", import.meta.url).href,
"broodfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"silkfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"thorncrown-stag": new URL("../assets/game/models/claudecraft/creatures/stag.glb", import.meta.url).href,
"sky-totem": new URL("../assets/game/models/claudecraft/creatures/tribal.glb", import.meta.url).href,
"razorcrest-raptor": new URL("../assets/game/models/claudecraft/creatures/velociraptor.glb", import.meta.url).href,
"bristlequake-boar": new URL("../assets/game/models/claudecraft/creatures/wild_boar.glb", import.meta.url).href,
"moonfang-wolf": new URL("../assets/game/models/claudecraft/creatures/wolf.glb", import.meta.url).href,
"frostmaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yeti.glb", import.meta.url).href,
"rimeclaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yetialt.glb", import.meta.url).href,
};
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
nia: new URL("../../game_assets/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
orin: new URL("../../game_assets/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
vale: new URL("../../game_assets/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
};
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
aelia: { right: new URL("../../game_assets/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
brann: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
},
nia: { right: new URL("../../game_assets/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
orin: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
},
vale: {
right: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
left: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
},
};
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
@@ -58,19 +84,8 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
orin: "Spellcast_Shoot",
vale: "Dualwield_Melee_Attack_Chop",
};
const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2;
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
});
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
const ARENA_WALL_SEGMENTS = Array.from({ length: 16 }, (_, index) => {
const angle = (index / 16) * Math.PI * 2;
return {
angle,
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 1.15, ARENA_CENTER[1] + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
};
});
const PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia">[] = ["brann", "nia", "orin", "vale"];
const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) {
@@ -244,77 +259,89 @@ function PartyCharacterModel({
);
}
function Arena() {
const pillarInstances = useRef<THREE.InstancedMesh>(null);
const torchInstances = useRef<THREE.InstancedMesh>(null);
const GAMEPLAY_FRAME_INTERVAL_MS = 1000 / 60;
const BACKGROUND_FRAME_INTERVAL_MS = 1000 / 30;
const FRAME_INTERVAL_JITTER_MS = 1.5;
const SIMULATION_STEP_SECONDS = 0.1;
const MAX_SIMULATION_STEPS_PER_FRAME = 3;
const MIN_RENDER_DPR = 1;
const MAX_RENDER_DPR = 1.25;
/**
* Owns the only gameplay RAF. R3F stays manually advanced, while simulation remains
* in the store and is advanced at its existing fixed 10 Hz cadence.
*/
function SceneFrameScheduler({ dpr, onDprChange }: { dpr: number; onDprChange: (next: number) => void }) {
const { advance } = useThree();
const gameplayActive = useGameStore((state) => state.phase === "combat" && !state.paused);
const frameId = useRef<number | null>(null);
const lastRenderedAt = useRef<number | null>(null);
const simulationAccumulator = useRef(0);
const slowFrameMs = useRef(0);
const stableFrameMs = useRef(0);
const dprRef = useRef(dpr);
dprRef.current = dpr;
useEffect(() => {
const pillars = pillarInstances.current;
const torches = torchInstances.current;
if (!pillars || !torches) return;
const matrix = new THREE.Matrix4();
ARENA_COLUMNS.forEach(([x, z], index) => {
matrix.makeTranslation(x, 1.1, z - 1);
pillars.setMatrixAt(index, matrix);
matrix.makeTranslation(x, 2.6, z - 1);
torches.setMatrixAt(index, matrix);
torches.setColorAt(index, ARENA_TORCH_COLORS[index % ARENA_TORCH_COLORS.length]);
});
pillars.instanceMatrix.needsUpdate = true;
torches.instanceMatrix.needsUpdate = true;
if (torches.instanceColor) torches.instanceColor.needsUpdate = true;
}, []);
const schedule = (now: number) => {
const interval = gameplayActive ? GAMEPLAY_FRAME_INTERVAL_MS : BACKGROUND_FRAME_INTERVAL_MS;
const previous = lastRenderedAt.current;
if (previous === null) {
lastRenderedAt.current = now;
advance(now / 1000, true);
} else {
const elapsedMs = now - previous;
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= interval) {
lastRenderedAt.current = now;
const elapsedSeconds = Math.min(elapsedMs / 1000, 0.25);
return (
<group>
<mesh position={[0, -0.45, -1]} receiveShadow>
<cylinderGeometry args={[10.5, 11.2, 0.8, 48]} />
<meshStandardMaterial color="#182420" roughness={0.92} />
</mesh>
<mesh position={[0, -0.015, -1]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<ringGeometry args={[4.8, 5.05, 64]} />
<meshBasicMaterial color="#765c32" transparent opacity={0.55} />
</mesh>
<mesh position={[0, -0.01, -1]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[2.1, 48]} />
<meshStandardMaterial color="#27342d" roughness={1} />
</mesh>
<instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
<meshStandardMaterial color="#26342f" roughness={0.8} />
</instancedMesh>
<instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
<octahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial />
</instancedMesh>
<ArenaWalls />
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
</group>
);
}
if (gameplayActive) {
simulationAccumulator.current += elapsedSeconds;
let steps = 0;
while (simulationAccumulator.current >= SIMULATION_STEP_SECONDS && steps < MAX_SIMULATION_STEPS_PER_FRAME) {
const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
simulationAccumulator.current -= SIMULATION_STEP_SECONDS;
steps += 1;
}
function ArenaWalls() {
const walls = useRef<THREE.Group>(null);
useFrame(({ camera }) => {
if (!walls.current) return;
for (const child of walls.current.children) {
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
const cameraDistance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
material.opacity = THREE.MathUtils.smoothstep(cameraDistance, 2.5, 7.5) * 0.52 + 0.06;
}
});
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => (
<mesh key={index} position={position} rotation={[0, angle, 0]} receiveShadow>
<boxGeometry args={[3.86, 2.3, 0.18]} />
<meshStandardMaterial color="#263a34" roughness={0.9} transparent opacity={0.58} depthWrite={false} />
</mesh>
))}
</group>
);
if (elapsedMs > 20) {
slowFrameMs.current += elapsedMs;
stableFrameMs.current = 0;
if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
slowFrameMs.current = 0;
}
} else {
slowFrameMs.current = 0;
stableFrameMs.current += elapsedMs;
if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
stableFrameMs.current = 0;
}
}
} else {
simulationAccumulator.current = 0;
slowFrameMs.current = 0;
stableFrameMs.current = 0;
}
advance(now / 1000, true);
}
}
frameId.current = requestAnimationFrame(schedule);
};
frameId.current = requestAnimationFrame(schedule);
return () => {
if (frameId.current !== null) cancelAnimationFrame(frameId.current);
frameId.current = null;
lastRenderedAt.current = null;
simulationAccumulator.current = 0;
};
}, [advance, gameplayActive, onDprChange]);
return null;
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
@@ -517,25 +544,35 @@ function PlayerCharacter() {
function Party() {
const selected = useGameStore((state) => state.selectedMemberId);
const [loadSupportModels, setLoadSupportModels] = useState(false);
useEffect(() => {
const timer = window.setTimeout(() => setLoadSupportModels(true), 750);
return () => window.clearTimeout(timer);
}, []);
return (
<>
<PlayerCharacter />
{PARTY_MEMBER_IDS.map((memberId) => (
<Character
key={memberId}
memberId={memberId}
selected={selected === memberId}
/>
))}
<Suspense fallback={<PartyFallback memberIds={CRITICAL_PARTY_MEMBER_IDS} />}>
<PlayerCharacter />
<Character memberId="brann" selected={selected === "brann"} />
</Suspense>
{loadSupportModels ? (
<Suspense fallback={<PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}>
{SUPPORT_PARTY_MEMBER_IDS.map((memberId) => (
<Character key={memberId} memberId={memberId} selected={selected === memberId} />
))}
</Suspense>
) : <PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}
</>
);
}
function PartyFallback() {
function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
const positions = useGameStore((state) => state.partyPositions);
return (
<>
{(Object.keys(positions) as MemberId[]).map((memberId) => (
{memberIds.map((memberId) => (
<mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
<capsuleGeometry args={[0.3, 0.75, 4, 8]} />
<meshStandardMaterial color="#79998c" roughness={0.8} />
@@ -554,7 +591,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "sandglass-scorpion" ? "#b78b32" : bossId === "ember-mantis-duelist" || bossId === "cinderback-ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
<meshStandardMaterial color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
);
}
@@ -625,8 +662,8 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
} else if (motion.mode === "returning") {
facingX = state.partyPositions.brann[0] + motion.formationOffsetX - motion.position[0];
facingZ = state.partyPositions.brann[1] - 4.25 - motion.position[1];
facingX = ARENA_CENTER[0] + motion.formationOffsetX - motion.position[0];
facingZ = ARENA_CENTER[1] - motion.position[1];
}
if (Math.hypot(facingX, facingZ) > 0.01) {
const targetAngle = Math.atan2(facingX, facingZ);
@@ -645,67 +682,21 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
const ALTERNATE_BOSS_CONFIG = {
vexa: {
url: INSECT_QUEEN_URL,
scale: 9,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#bb67ff",
rotationOffset: 0,
prototype: true,
},
cindermaw: {
url: BLUE_EYES_WHITE_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8742",
rotationOffset: 0,
prototype: true,
},
"ember-mantis-duelist": {
url: GATE_GUARDIAN_URL,
scale: 4.3,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff5a24",
rotationOffset: 0,
prototype: true,
},
"obsidian-ram-golem": {
url: GANDORA_URL,
scale: 6.2,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff7438",
rotationOffset: 0,
prototype: true,
},
"cinderback-ricochet": {
url: RED_EYES_BLACK_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8b3d",
rotationOffset: 0,
prototype: true,
},
interface AlternateBossConfig {
url: string;
scale: number;
idle: string;
move: string;
attack: string;
special: string;
death: string;
light: string;
rotationOffset: number;
prototype?: boolean;
floating?: boolean;
}
const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
"sandglass-scorpion": {
url: SANDGLASS_URL,
scale: 0.7,
@@ -728,30 +719,6 @@ const ALTERNATE_BOSS_CONFIG = {
light: "#49d5df",
rotationOffset: 0,
},
"pumpking-king-of-ghosts": {
url: PUMPKING_URL,
scale: 1.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#d87842",
rotationOffset: 0,
prototype: true,
},
"blue-eyes-ultimate-dragon": {
url: BLUE_EYES_ULTIMATE_URL,
scale: 4.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#8fc8ff",
rotationOffset: 0,
prototype: true,
},
"mournveil-ghost": {
url: MOURNVEIL_URL,
scale: 1.1,
@@ -762,6 +729,7 @@ const ALTERNATE_BOSS_CONFIG = {
death: "Death",
light: "#9d72ff",
rotationOffset: 0,
floating: true,
},
"crownshard-golem": {
url: CROWNSHARD_URL,
@@ -773,14 +741,94 @@ const ALTERNATE_BOSS_CONFIG = {
death: "Death",
light: "#e0bd45",
rotationOffset: 0,
floating: true,
},
} as const;
"crystal-bat-matriarch": {
url: CRYSTAL_BAT_MATRIARCH_URL,
scale: 1.04,
idle: "Idle",
move: "Swoop",
attack: "SonicPulse",
special: "MirrorShatter",
death: "Death",
light: "#8eeaff",
rotationOffset: 0,
floating: true,
},
"stormwool-alpaca": {
url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0, prototype: true,
},
"cluckhorn-colossus": {
url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0, prototype: true,
},
"ashwing-demon": {
url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, prototype: true, floating: true,
},
"riftclaw-demon": {
url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0, prototype: true,
},
"tempestscale-dragon": {
url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, prototype: true, floating: true,
},
emberfox: {
url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0, prototype: true,
},
"mirelord-frog": {
url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0, prototype: true,
},
"stonebreaker-giant": {
url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0, prototype: true,
},
"glub-sovereign": {
url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, prototype: true, floating: true,
},
"scrapking-goblin": {
url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0, prototype: true,
},
"warcaller-orc": {
url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0, prototype: true,
},
"tuskmaw-orc": {
url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0, prototype: true,
},
"broodfang-spider": {
url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0, prototype: true,
},
"silkfang-spider": {
url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0, prototype: true,
},
"thorncrown-stag": {
url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0, prototype: true,
},
"sky-totem": {
url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, prototype: true, floating: true,
},
"razorcrest-raptor": {
url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0, prototype: true,
},
"bristlequake-boar": {
url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0, prototype: true,
},
"moonfang-wolf": {
url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0, prototype: true,
},
"frostmaw-yeti": {
url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0, prototype: true,
},
"rimeclaw-yeti": {
url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0, prototype: true,
},
};
const PROTOTYPE_MOVE_MODES = [
"skyfall",
"mantis_sidestep",
"ram_charging",
"cinderback_ricochet",
"charging",
"returning",
"sandglass_burrowing",
"crab_scuttling",
] as const;
const PROTOTYPE_ATTACK_MODES = [
@@ -800,33 +848,23 @@ const PROTOTYPE_ATTACK_MODES = [
"ghost_haunting",
"golem_shockwave",
"golem_crownfall",
"telegraph",
"stacking",
"pouncing",
"sandglass_burrow_telegraph",
"sandglass_eruption",
"sandglass_hourglass",
"crab_scuttle_telegraph",
"crab_tidal_burst",
] as const;
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
const config = ALTERNATE_BOSS_CONFIG[kind];
if ("prototype" in config && config.prototype) {
if (config.prototype) {
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
return config.idle;
}
if (kind === "ember-mantis-duelist") {
if (motionMode === "mantis_sidestep") return config.move;
if (motionMode === "mantis_line_telegraph") return config.attack;
if (motionMode === "mantis_cross_telegraph") return config.special;
if (motionMode === "mantis_recover") return "Recover";
}
if (kind === "obsidian-ram-golem") {
if (motionMode === "ram_charge_telegraph" || motionMode === "ram_charging") return config.attack;
if (motionMode === "ram_quake") return config.special;
if (motionMode === "ram_shatter") return "ArmorShatter";
if (motionMode === "ram_recover") return "Stagger";
}
if (kind === "cinderback-ricochet") {
if (motionMode === "cinderback_curl") return config.attack;
if (motionMode === "cinderback_ricochet") return config.move;
if (motionMode === "cinderback_slam") return config.special;
if (motionMode === "cinderback_recover") return "Recover";
}
if (kind === "sandglass-scorpion") {
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
if (motionMode === "sandglass_eruption") return config.attack;
@@ -846,16 +884,18 @@ function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeo
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
}
if (kind === "cindermaw") {
if (motionMode === "skyfall") return config.move;
if (motionMode === "breath_telegraph" || motionMode === "breath_sweeping") return config.special;
if (kind === "crystal-bat-matriarch") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
if (motionMode === "golem_recover") return "Stagger";
}
if (kind === "vexa" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
if (BOSS_ARCHETYPE_BY_ID[kind] === "web-caster" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
return config.idle;
}
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind];
const archetype = BOSS_ARCHETYPE_BY_ID[kind];
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
@@ -881,13 +921,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.16);
const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
const timeScale = archetype === "duelist" && motionMode === "mantis_line_telegraph"
? 0.55
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
: archetype === "duelist" && motionMode === "mantis_cross_telegraph"
? 0.6
: 1;
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
const authoredOneShot = ![config.idle, config.move].includes(clipName);
if (phase === "victory" || defeated || authoredOneShot) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
@@ -895,7 +935,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.16); };
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
}, [actions, archetype, clipName, config.idle, config.move, defeated, motionMode, phase]);
useFrame((_, delta) => {
if (!group.current) return;
@@ -903,9 +943,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const current = encounterBossAt(state, bossIndex);
if (!current) return;
const motion = current.motion;
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 0.2 : 0.03;
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
const burrowed = archetype === "burrower" && motion.mode === "sandglass_burrowing";
const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
@@ -913,9 +953,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
state.partyPositions.brann[0] - motion.position[0],
state.partyPositions.brann[1] - motion.position[1],
);
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
} else if (kind === "ember-mantis-duelist" && (
} else if (archetype === "duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
@@ -1181,6 +1221,8 @@ function PerformanceProbe() {
}
document.documentElement.dataset.gamePerf = JSON.stringify({
frame: {
targetFps: 60,
budgetMs: 16.67,
averageMs: total / samples.length,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
@@ -1192,6 +1234,9 @@ function PerformanceProbe() {
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
pixelRatio: gl.getPixelRatio(),
renderWidth: gl.domElement.width,
renderHeight: gl.domElement.height,
},
simulation: simulationTickSnapshot(),
memory: memory.memory ? {
@@ -1237,24 +1282,25 @@ function CombatFx() {
}
export function GameScene() {
const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1)));
const setRenderDpr = useCallback((next: number) => {
setDpr((current) => Math.abs(current - next) < 0.001 ? current : next);
}, []);
return (
<Canvas
shadows
dpr={[1, 1.5]}
frameloop="never"
shadows="basic"
dpr={dpr}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
gl={{ antialias: true, powerPreference: "high-performance" }}
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
>
<color attach="background" args={["#07110f"]} />
<fog attach="fog" args={["#07110f", 17, 32]} />
<hemisphereLight args={["#8ac4b5", "#15100b", 1.25]} />
<directionalLight castShadow position={[5, 10, 8]} intensity={2.2} color="#ffe2a9" shadow-mapSize={[1024, 1024]} />
<Arena />
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
<BossRoom />
<BossMechanicIndicators />
<BarrierField />
<TankAuraField />
<Suspense fallback={<PartyFallback />}>
<Party />
</Suspense>
<Party />
<BossActor />
<RangedProjectiles />
<CombatFx />
@@ -1263,8 +1309,9 @@ export function GameScene() {
);
}
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
const loadout = PARTY_WEAPON_URLS[memberId];
useGLTF.preload(loadout.right, false, true);
if (loadout.left) useGLTF.preload(loadout.left, false, true);
}
+10 -4
View File
@@ -1,10 +1,13 @@
import { lazy, Suspense } from "react";
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GameScene } from "./GameScene";
import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
function CompactParty() {
const party = useGameStore((state) => state.party);
const time = useGameStore((state) => state.time);
@@ -110,15 +113,16 @@ function PhaseOverlay() {
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
if (phase === "combat") return null;
const title = phase === "briefing"
? definitions.map((boss) => boss.title).join(" & ")
? room.name
: phase === "victory"
? `${bossNames} Broken`
: "Party Broken";
const eyebrow = phase === "briefing"
? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial)
? `${bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial} · ${room.biome}`
: phase === "victory"
? "Encounter Complete"
: "Encounter Failed";
@@ -182,7 +186,9 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
<GameScene />
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
<GameScene />
</Suspense>
<div className="top-vignette" />
<div className="top-hud">
<CompactParty />
+289 -5
View File
@@ -2,17 +2,31 @@ import { useFrame } from "@react-three/fiber";
import { useRef, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
import { CINDER_BREATH } from "../../game/bosses/cindermaw";
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
import { useGameStore } from "../../game/store";
import type { MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
const EMPTY_POOL_TELEGRAPHS: never[] = [];
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
const DANGER_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
const SOAK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const WARD_ARROW_DIRECTIONS = [0, Math.PI / 2, Math.PI, Math.PI * 1.5] as const;
const INWARD_ARROW_SHAPE = new THREE.Shape()
.moveTo(-0.13, -0.32)
.lineTo(0.13, -0.32)
.lineTo(0.13, 0.04)
.lineTo(0.29, 0.04)
.lineTo(0, 0.38)
.lineTo(-0.29, 0.04)
.lineTo(-0.13, 0.04)
.lineTo(-0.13, -0.32);
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
@@ -212,12 +226,12 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - CINDER_BREATH.halfAngle]}>
<circleGeometry args={[CINDER_BREATH.range, 64, 0, CINDER_BREATH.halfAngle * 2]} />
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, CINDER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[CINDER_BREATH.range * 0.47, CINDER_BREATH.range * 0.49, 48, 1, -CINDER_BREATH.halfAngle, CINDER_BREATH.halfAngle * 2]} />
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
@@ -276,6 +290,274 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
}
/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
const wardGroup = useRef<THREE.Group>(null);
const ghostGroup = useRef<THREE.Group>(null);
const outerColumn = useRef<THREE.MeshBasicMaterial>(null);
const innerColumn = useRef<THREE.MeshBasicMaterial>(null);
const groundRing = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
const current = motionAt(useGameStore.getState(), bossIndex);
const siphon = current?.poolTelegraphs.find((telegraph) => telegraph.kind === "soul-siphon")?.soulSiphon;
const active = useGameStore.getState().phase === "combat" && siphon;
if (!active || !siphon) return;
const pulse = (Math.sin(clock.elapsedTime * 5.5) + 1) * 0.5;
if (wardGroup.current) {
wardGroup.current.position.set(siphon.wardPosition[0], 0, siphon.wardPosition[1]);
wardGroup.current.rotation.y += 0.003;
}
if (ghostGroup.current) {
ghostGroup.current.position.set(siphon.ghostPosition[0], 0.85 + Math.sin(clock.elapsedTime * 4.2) * 0.12, siphon.ghostPosition[1]);
ghostGroup.current.rotation.y += 0.018;
}
if (outerColumn.current) outerColumn.current.opacity = 0.15 + pulse * 0.09;
if (innerColumn.current) innerColumn.current.opacity = 0.2 + pulse * 0.16;
if (groundRing.current) groundRing.current.opacity = 0.62 + pulse * 0.28;
});
const siphon = motion?.poolTelegraphs.find((telegraph) => telegraph.kind === "soul-siphon")?.soulSiphon;
if (!siphon || phase !== "combat") return null;
return (
<>
<group ref={wardGroup} position={[siphon.wardPosition[0], 0, siphon.wardPosition[1]]}>
<mesh position={[0, 2.45, 0]}>
<cylinderGeometry args={[0.78, 1.05, 4.9, 28, 1, true]} />
<meshBasicMaterial ref={outerColumn} color="#f8c94e" transparent opacity={0.2} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 2.45, 0]}>
<cylinderGeometry args={[0.38, 0.58, 4.9, 24, 1, true]} />
<meshBasicMaterial ref={innerColumn} color="#fff0a3" transparent opacity={0.32} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 0.045, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[siphon.wardRadius - 0.13, siphon.wardRadius, 40]} />
<meshBasicMaterial ref={groundRing} color="#ffe47d" transparent opacity={0.9} depthWrite={false} />
</mesh>
{WARD_ARROW_DIRECTIONS.map((angle) => (
<group
key={angle}
position={[Math.sin(angle) * 1.58, 0.062, Math.cos(angle) * 1.58]}
rotation={[0, angle, 0]}
>
<mesh rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial color="#54ff90" transparent opacity={1} depthWrite={false} depthTest={false} />
</mesh>
</group>
))}
</group>
<group ref={ghostGroup} position={[siphon.ghostPosition[0], 0.85, siphon.ghostPosition[1]]}>
<mesh>
<sphereGeometry args={[0.34, 16, 12]} />
<meshBasicMaterial color="#b48aff" transparent opacity={0.68} depthWrite={false} />
</mesh>
<mesh position={[0, -0.52, 0]} scale={[0.82, 1.45, 0.82]}>
<coneGeometry args={[0.34, 0.78, 12, 1, true]} />
<meshBasicMaterial color="#8d62d2" transparent opacity={0.36} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.48, 0.026, 8, 24]} />
<meshBasicMaterial color="#e1c4ff" transparent opacity={0.72} depthWrite={false} />
</mesh>
</group>
</>
);
}
function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySymbolId; size?: number; opacity?: number }) {
const color = MEMORY_SYMBOLS[symbol].color;
if (symbol === "cross") {
return (
<group position={[0, 0.012, 0]}>
<mesh>
<boxGeometry args={[size * 0.22, 0.035, size]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
<mesh>
<boxGeometry args={[size, 0.035, size * 0.22]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
</group>
);
}
return (
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, symbol === "square" ? Math.PI / 4 : -Math.PI / 2]}>
<ringGeometry args={[size * 0.34, size * 0.54, symbol === "circle" ? 36 : symbol === "triangle" ? 3 : 4]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
);
}
function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) {
const color = MEMORY_SYMBOLS[tile.symbol].color;
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
const gridOffsets = [-0.48, 0, 0.48] as const;
return (
<group position={[tile.center[0], 0.075, tile.center[1]]}>
<mesh>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<group key={side}>
<mesh position={[side * halfSize, 0.03, 0]}>
<boxGeometry args={[0.08, 0.06, MEMORY_SEQUENCE.tileSize + 0.08]} />
<meshBasicMaterial color={color} transparent opacity={0.96} depthWrite={false} />
</mesh>
<mesh position={[0, 0.03, side * halfSize]}>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize + 0.08, 0.06, 0.08]} />
<meshBasicMaterial color={color} transparent opacity={0.96} depthWrite={false} />
</mesh>
</group>
))}
{gridOffsets.map((offset) => (
<group key={offset}>
<mesh position={[0, 0.026, offset * MEMORY_SEQUENCE.tileSize]}>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize * 0.84, 0.018, 0.026]} />
<meshBasicMaterial color="#fff6df" transparent opacity={0.38} depthWrite={false} />
</mesh>
<mesh position={[offset * MEMORY_SEQUENCE.tileSize, 0.027, 0]}>
<boxGeometry args={[0.026, 0.018, MEMORY_SEQUENCE.tileSize * 0.84]} />
<meshBasicMaterial color="#fff6df" transparent opacity={0.38} depthWrite={false} />
</mesh>
</group>
))}
<MemorySymbolMark symbol={tile.symbol} size={0.78} opacity={1} />
</group>
);
}
function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph: PoolTelegraph; bossPosition?: [number, number]; time: number }) {
if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined) return null;
const showingSequence = time < telegraph.inputStartsAt;
const flashIndex = Math.min(
telegraph.sequence.length - 1,
Math.max(0, Math.floor((time - telegraph.activatesAt) / MEMORY_SEQUENCE.flashDuration)),
);
const flashSymbol = telegraph.sequence[flashIndex];
const source = bossPosition ?? telegraph.center;
return (
<>
{telegraph.tiles.map((tile) => <MemoryTileIndicator key={tile.symbol} tile={tile} inputActive={!showingSequence} />)}
{showingSequence && (
<group position={[source[0], 2.5, source[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.88, 32]} />
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
</mesh>
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.8, 0.9, 32]} />
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
</mesh>
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
</group>
)}
</>
);
}
function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: string; bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const time = useGameStore((state) => state.time);
const telegraph = useGameStore((state) => motionAt(state, bossIndex)?.poolTelegraphs.find((entry) => entry.id === telegraphId));
const bossPosition = useGameStore((state) => motionAt(state, bossIndex)?.position);
const fillMaterial = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!fillMaterial.current) return;
const current = motionAt(useGameStore.getState(), bossIndex)?.poolTelegraphs.find((entry) => entry.id === telegraphId);
if (!current) return;
const active = useGameStore.getState().time >= current.activatesAt;
fillMaterial.current.opacity = active ? 0.46 : 0.14 + (Math.sin(clock.elapsedTime * 10) + 1) * 0.08;
});
if (!telegraph || phase !== "combat") return null;
if (telegraph.kind === "memory") return <MemorySequenceIndicator telegraph={telegraph} bossPosition={bossPosition} time={time} />;
if (telegraph.kind === "soul-siphon") return null;
const active = time >= telegraph.activatesAt;
const color = telegraph.kind === "soak" ? "#57e8ff"
: telegraph.kind === "spread" ? "#e869ff"
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
const dx = telegraph.end[0] - telegraph.start[0];
const dz = telegraph.end[1] - telegraph.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
return (
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
);
}
const innerRadius = telegraph.innerRadius ?? 0;
return (
<group position={[telegraph.center[0], 0.07, telegraph.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
{telegraph.kind === "donut"
? <ringGeometry args={[innerRadius, telegraph.radius, 56]} />
: <circleGeometry args={[telegraph.radius, 48]} />}
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[telegraph.radius - 0.1, telegraph.radius + 0.04, 48]} />
<meshBasicMaterial color={color} transparent opacity={0.94} depthWrite={false} />
</mesh>
{telegraph.kind === "donut" && (
<mesh position={[0, 0.021, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[innerRadius - 0.05, innerRadius + 0.05, 40]} />
<meshBasicMaterial color="#f9efcf" transparent opacity={0.8} depthWrite={false} />
</mesh>
)}
{telegraph.kind === "spread" && (
<>
<mesh position={[0, 0.028, 0]}>
<boxGeometry args={[telegraph.radius * 1.35, 0.032, 0.07]} />
<meshBasicMaterial color="#ffd9ff" transparent opacity={0.9} />
</mesh>
<mesh position={[0, 0.03, 0]}>
<boxGeometry args={[0.07, 0.032, telegraph.radius * 1.35]} />
<meshBasicMaterial color="#ffd9ff" transparent opacity={0.9} />
</mesh>
</>
)}
{telegraph.kind === "soak" && SOAK_DIRECTIONS.map((angle, index) => (
<group key={index} rotation={[0, angle, 0]} position={[0, 0.035, 0]}>
<mesh position={[0, 0, -telegraph.radius + 0.32]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.16, 0.34, 3]} />
<meshBasicMaterial color="#c9fbff" transparent opacity={0.96} />
</mesh>
</group>
))}
</group>
);
}
export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
const telegraphs = useGameStore((state) => motionAt(state, bossIndex)?.poolTelegraphs ?? EMPTY_POOL_TELEGRAPHS);
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
}
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
ChargeLaneIndicator,
SlashLaneIndicators,
@@ -283,6 +565,8 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[]
BindingWebIndicator,
BreathConeIndicator,
CircleHazardIndicators,
SoulSiphonIndicator,
PooledMechanicIndicators,
];
export function BossMechanicIndicators() {
+9 -8
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
import { selectRandomBossPair } from "../game/roguelike";
import { BOSS_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
import { BOSS_ORDER } from "../game/bossCatalog";
import { GROUP_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => {
@@ -11,24 +11,25 @@ describe("game mode configuration", () => {
});
it("selects a boss across the full encounter pool", () => {
for (let index = 0; index < BOSS_ORDER.length; index += 1) {
expect(selectRandomBoss(() => (index + 0.5) / BOSS_ORDER.length)).toBe(BOSS_ORDER[index]);
for (let index = 0; index < AVAILABLE_BOSS_IDS.length; index += 1) {
expect(selectRandomBoss(() => (index + 0.5) / AVAILABLE_BOSS_IDS.length)).toBe(AVAILABLE_BOSS_IDS[index]);
}
});
it("selects two distinct bosses for PVE", () => {
const values = [0, 0];
const pair = selectRandomBossPair([], () => values.shift() ?? 0);
expect(pair).toEqual(["bulldrome", "vexa"]);
expect(pair).toEqual(["bulldrome", "sandglass-scorpion"]);
expect(new Set(pair)).toHaveLength(2);
});
it("derives collection entries from canonical boss drop tables", () => {
it("derives group collection entries from canonical group drop tables", () => {
const collections = buildCollections(createEmptyCollectionLog(), {});
expect(collections.map((boss) => boss.bossId)).toEqual(BOSS_ORDER);
expect(collections.map((group) => group.groupId)).toEqual(BOSS_GROUPS.map((group) => group.id));
expect(collections.flatMap((group) => group.bosses.map((boss) => boss.bossId)).sort()).toEqual([...AVAILABLE_BOSS_IDS].sort());
for (const collection of collections) {
expect(collection.drops.map((drop) => drop.id)).toEqual(
BOSS_DROP_TABLES[collection.bossId as keyof typeof BOSS_DROP_TABLES].entries.map((drop) => drop.id),
GROUP_DROP_TABLES[collection.groupId].entries.map((drop) => drop.id),
);
}
});
+36 -24
View File
@@ -1,15 +1,9 @@
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import type { BossGroupCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "../game/bossCatalog";
import { createClassInventory } from "../game/healers";
import type { BossId } from "../game/types";
import { createDefaultGearProgress } from "../game/progression/gear";
import {
BOSS_DROP_TABLES,
createEmptyCollectionLog,
type CollectionLog,
type LootRarity,
type MaterialStack,
} from "../game/progression/loot";
import { BOSS_PET_DROPS, GROUP_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type LootRarity, type MaterialStack } from "../game/progression/loot";
export const DEFAULT_SETTINGS: GameSettings = {
masterVolume: 80,
@@ -18,7 +12,7 @@ export const DEFAULT_SETTINGS: GameSettings = {
largeText: false,
};
const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"]> = {
const RARITY_LABELS: Record<LootRarity, BossGroupCollection["drops"][number]["rarity"]> = {
common: "Common",
uncommon: "Uncommon",
rare: "Rare",
@@ -26,31 +20,49 @@ const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"
legendary: "Legendary",
};
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossCollection[] {
return BOSS_ORDER.map((bossId) => {
const table = BOSS_DROP_TABLES[bossId];
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossGroupCollection[] {
return BOSS_GROUPS.map((group) => {
const table = GROUP_DROP_TABLES[group.id];
const bosses = group.bossIds.map((bossId) => {
const pet = BOSS_PET_DROPS[bossId];
return {
bossId,
bossName: BOSS_DEFINITIONS[bossId].name,
kills: bossKills[bossId] ?? 0,
pet: {
id: pet.id,
name: pet.name,
icon: pet.glyph,
rarity: RARITY_LABELS[pet.rarity],
count: collectionLog.petsFound[pet.id] ?? 0,
chance: pet.chanceLabel,
kind: pet.kind,
},
};
});
return {
bossId,
bossName: BOSS_DEFINITIONS[bossId].name,
defeated: (bossKills[bossId] ?? 0) > 0,
groupId: group.id,
groupLetter: group.letter,
groupName: group.name,
coreMechanic: group.coreMechanic,
defeated: bosses.some((boss) => boss.kills > 0),
drops: table.entries.map((drop) => ({
id: drop.id,
name: drop.name,
icon: drop.glyph,
rarity: RARITY_LABELS[drop.rarity],
count: drop.kind === "coin"
? collectionLog.dropsFound[drop.id] ?? 0
: collectionLog.petsFound[drop.id] ?? 0,
count: collectionLog.dropsFound[drop.id] ?? 0,
chance: drop.chanceLabel,
itemLevel: drop.kind === "coin" ? drop.itemLevel : undefined,
itemLevel: drop.itemLevel,
kind: drop.kind,
})),
bosses,
};
});
}
export const DEFAULT_COLLECTION_LOG: CollectionLog = createEmptyCollectionLog();
export const DEFAULT_COLLECTIONS: BossCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
export const DEFAULT_COLLECTIONS: BossGroupCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
"roguelike-pve": {
@@ -64,7 +76,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
eyebrow: "14 hunters · chosen encounter",
title: "Dungeons",
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
detail: "Ten prototype guardians available",
detail: `${AVAILABLE_BOSS_IDS.length} animated guardians available`,
status: "Playable now",
},
"roguelike-pvp": {
@@ -84,7 +96,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
};
export function selectRandomBoss(random: () => number = Math.random): BossId {
return BOSS_ORDER[Math.floor(random() * BOSS_ORDER.length)] ?? BOSS_ORDER[0];
return AVAILABLE_BOSS_IDS[Math.floor(random() * AVAILABLE_BOSS_IDS.length)] ?? AVAILABLE_BOSS_IDS[0];
}
export const MAX_HUNTER_NAME_LENGTH = 20;
@@ -101,7 +113,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
const normalizedName = normalizeHunterName(hunterName);
if (!normalizedName) throw new Error("Hunter name is required.");
return {
schemaVersion: 4,
schemaVersion: 5,
slotId,
hunterName: normalizedName,
activeClassId: "priest",
+60 -19
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { SaveRepository, type StorageAdapter } from "./saveRepository";
import { buildCollections, DEFAULT_COLLECTIONS } from "./data";
import { groupDrop } from "../game/progression/loot";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
@@ -91,39 +91,80 @@ describe("SaveRepository", () => {
expect(save.healers.priest.inventory).toHaveLength(4);
});
it("migrates schema v1 saves into Priest progress without losing the hunter name", () => {
it("resets every legacy save into fresh v5 progression while preserving identity and timestamp", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Legacy");
const legacy = { ...created, schemaVersion: 1, level: 27 } as Record<string, unknown>;
delete legacy.activeClassId;
delete legacy.healers;
const legacy = {
...created,
schemaVersion: 4,
activeClassId: "druid",
playSeconds: 999,
healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])),
stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 } },
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 99, rarity: "common", itemLevel: 1, glyph: "R" }],
collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } },
gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, {
...owner,
slots: Object.fromEntries(Object.entries(owner.slots).map(([slotId]) => [slotId, { level: 10 }])),
infusionAbilityId: "priest-sanctuary",
}])),
} as Record<string, unknown>;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.list(null)[0].local!;
expect(migrated.schemaVersion).toBe(4);
expect(migrated.schemaVersion).toBe(5);
expect(migrated.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest");
expect(migrated.healers.priest.level).toBe(27);
expect(migrated.healers.druid.level).toBe(1);
expect(migrated.healers.shaman.inventory.length).toBeGreaterThan(0);
expect(migrated.playSeconds).toBe(0);
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {} });
expect(migrated.materials).toEqual([]);
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5);
});
it("derives newly shipped bosses from drop tables after migrating schema v2 collections", () => {
it("resets and persists legacy cloud saves when they are listed", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Cloud Legacy");
const legacy = {
...created,
schemaVersion: 4,
stats: { ...created.stats, totalBossKills: 8, bossKills: { bulldrome: 8 } },
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 8, rarity: "common", itemLevel: 1, glyph: "R" }],
};
const cloudKey = "i-want-to-heal:saves:cloud:v1:cloud@example.com";
storage.setItem(cloudKey, JSON.stringify({ 1: legacy }));
const online = repository.list("cloud@example.com")[0].online!;
expect(online.schemaVersion).toBe(5);
expect(online.stats.totalBossKills).toBe(0);
expect(online.materials).toEqual([]);
expect(JSON.parse(storage.getItem(cloudKey) ?? "{}")["1"].schemaVersion).toBe(5);
});
it("preserves valid v5 progression and group-drop inventory", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Veteran");
const legacy = { ...created, schemaVersion: 2, collections: DEFAULT_COLLECTIONS.filter((boss) => boss.bossId !== "vexa") } as Record<string, unknown>;
delete legacy.collectionLog;
delete legacy.materials;
delete legacy.gearProgress;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const drop = groupDrop("charge", "veteran");
created.healers.priest.level = 8;
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
const migrated = repository.list(null)[0].local!;
expect(buildCollections(migrated.collectionLog, migrated.stats.bossKills).some((boss) => boss.bossId === "vexa")).toBe(true);
expect(migrated.schemaVersion).toBe(5);
expect(migrated.healers.priest.level).toBe(8);
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
expect(migrated.collectionLog).toEqual(created.collectionLog);
});
it("migrates valid infusion choices and discards stale ids", () => {
it("normalizes valid v5 infusion choices and discards stale ids", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Infused");
@@ -131,10 +172,10 @@ describe("SaveRepository", () => {
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
created.gearProgress.brann.passiveInfusionId = "deep-wells";
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 3 } }));
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
const migrated = repository.list(null)[0].local!;
expect(migrated.schemaVersion).toBe(4);
expect(migrated.schemaVersion).toBe(5);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
+26 -26
View File
@@ -1,11 +1,11 @@
import { createHunterSave } from "./data";
import { createClassInventory } from "../game/healers";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "../game/bossCatalog";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear";
import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions";
import { BOSS_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type MaterialStack } from "../game/progression/loot";
import { GROUP_DROP_TABLES, type CollectionLog, type MaterialStack } from "../game/progression/loot";
import type { BossId, HealerClassId } from "../game/types";
import type { BossCollection, HunterSave, SaveSlotId, SaveSlotState } from "./types";
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
export interface StorageAdapter {
getItem(key: string): string | null;
@@ -44,7 +44,6 @@ interface LegacyHunterSave {
playSeconds?: number;
updatedAt?: string;
stats?: HunterSave["stats"];
collections?: BossCollection[];
materials?: MaterialStack[];
collectionLog?: CollectionLog;
gearProgress?: GearProgress;
@@ -61,26 +60,16 @@ function positiveCounts(value: unknown): Record<string, number> {
}
function normalizeCollectionLog(candidate: LegacyHunterSave): CollectionLog {
if (candidate.collectionLog) {
return {
dropsFound: positiveCounts(candidate.collectionLog.dropsFound),
petsFound: positiveCounts(candidate.collectionLog.petsFound),
};
}
const result = createEmptyCollectionLog();
for (const legacyBoss of candidate.collections ?? []) {
if (!BOSS_ORDER.includes(legacyBoss.bossId as BossId)) continue;
const bossId = legacyBoss.bossId as BossId;
const quantity = legacyBoss.drops.reduce((sum, drop) => sum + Math.max(0, Math.floor(drop.count || 0)), 0);
if (quantity > 0) result.dropsFound[BOSS_DROP_TABLES[bossId].coins.initiate.id] = quantity;
}
return result;
return {
dropsFound: positiveCounts(candidate.collectionLog?.dropsFound),
petsFound: positiveCounts(candidate.collectionLog?.petsFound),
};
}
function knownMaterial(id: string) {
for (const bossId of BOSS_ORDER) {
const coin = Object.values(BOSS_DROP_TABLES[bossId].coins).find((candidate) => candidate.id === id);
if (coin) return coin;
for (const table of Object.values(GROUP_DROP_TABLES)) {
const drop = Object.values(table.drops).find((candidate) => candidate.id === id);
if (drop) return drop;
}
return undefined;
}
@@ -99,8 +88,8 @@ function normalizeMaterials(value: unknown, collectionLog: CollectionLog): Mater
for (const [id, quantity] of Object.entries(collectionLog.dropsFound)) quantities.set(id, quantity);
}
return [...quantities].flatMap(([id, quantity]) => {
const coin = knownMaterial(id);
return coin ? [{ id, quantity, name: coin.name, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }] : [];
const drop = knownMaterial(id);
return drop ? [{ id, quantity, name: drop.name, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }] : [];
});
}
@@ -123,7 +112,7 @@ function normalizeBossKills(value: unknown): Record<string, number> {
const source = positiveCounts(value);
const result: Record<string, number> = {};
for (const [key, quantity] of Object.entries(source)) {
const bossId = BOSS_ORDER.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
const bossId = AVAILABLE_BOSS_IDS.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
result[bossId ?? key] = (result[bossId ?? key] ?? 0) + quantity;
}
return result;
@@ -133,11 +122,18 @@ function normalizeSave(value: unknown): HunterSave | null {
if (!value || typeof value !== "object") return null;
const candidate = value as LegacyHunterSave;
if (!candidate.slotId || !candidate.hunterName) return null;
if (candidate.schemaVersion !== 5) {
try {
return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName);
} catch {
return null;
}
}
const collectionLog = normalizeCollectionLog(candidate);
const bossKills = normalizeBossKills(candidate.stats?.bossKills);
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
return {
schemaVersion: 4,
schemaVersion: 5,
slotId: candidate.slotId,
hunterName: candidate.hunterName,
activeClassId,
@@ -253,7 +249,11 @@ export class SaveRepository {
}
private read(key: string): SaveMap {
return parseSaveMap(this.storage.getItem(key));
const raw = this.storage.getItem(key);
const saves = parseSaveMap(raw);
const normalized = JSON.stringify(saves);
if (raw !== normalized) this.storage.setItem(key, normalized);
return saves;
}
private write(key: string, saves: SaveMap): void {
+1 -1
View File
@@ -298,7 +298,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
set((state) => ({
slots: repository.list(accountId),
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
notice: awarded ? `${awarded.coin.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
}));
return awarded;
},
+16 -5
View File
@@ -1,4 +1,5 @@
import type { HealerClassId, InventoryItem } from "../game/types";
import type { BossGroupId } from "../game/bossCatalog";
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
import type { GearProgress } from "../game/progression/gear";
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
@@ -14,14 +15,24 @@ export interface CollectionDrop {
count: number;
chance: string;
itemLevel?: number;
kind: "coin" | "pet";
kind: "group-drop" | "pet";
}
export interface BossCollection {
bossId: string;
export interface GroupBossCollection {
bossId: BossId;
bossName: string;
kills: number;
pet: CollectionDrop;
}
export interface BossGroupCollection {
groupId: BossGroupId;
groupLetter: string;
groupName: string;
coreMechanic: string;
defeated: boolean;
drops: CollectionDrop[];
bosses: GroupBossCollection[];
}
export interface HunterStats {
@@ -38,7 +49,7 @@ export interface HealerProgress {
}
export interface HunterSave {
schemaVersion: 4;
schemaVersion: 5;
slotId: SaveSlotId;
hunterName: string;
activeClassId: HealerClassId;
+4 -2
View File
@@ -1,8 +1,10 @@
import type { BossMotionState, WorldPosition } from "./types";
export const ARENA_CENTER: WorldPosition = [0, -1];
export const ARENA_RADIUS = 8.35;
export const ARENA_WALL_RADIUS = 9.55;
/** Keeps simulation limits and the room renderer in lockstep. */
export const ARENA_SIZE_MULTIPLIER = 1.3;
export const ARENA_RADIUS = 8.35 * ARENA_SIZE_MULTIPLIER;
export const ARENA_WALL_RADIUS = 9.55 * ARENA_SIZE_MULTIPLIER;
export function clampToArena(position: WorldPosition, padding = 0): WorldPosition {
const radius = Math.max(0, ARENA_RADIUS - padding);
+5 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { BOSS_ORDER } from "./bossCatalog";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { useGameStore } from "./store";
import type { BossId } from "./types";
@@ -26,15 +26,15 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
}
describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
);
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
const result = simulateControlledBattle([first, second]);
expect(result.phase).toBe("victory");
expect(result.time).toBeGreaterThan(70);
expect(result.time).toBeLessThan(110);
expect(result.time).toBeGreaterThanOrEqual(50);
expect(result.time).toBeLessThanOrEqual(100);
for (const memberId of ["brann", "nia", "orin", "vale"] as const) expect(result.damageBySource[memberId].damageDone).toBeGreaterThan(0);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
import { createBossMotionState, createBossState } from "./bossMechanics";
describe("boss catalog", () => {
it("derives the available roster from every catalog definition", () => {
expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS));
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(27);
});
it("assigns every boss to one compatible mechanic group", () => {
const groupedBosses = BOSS_GROUPS.flatMap((group) => group.bossIds);
expect(new Set(groupedBosses)).toHaveLength(AVAILABLE_BOSS_IDS.length);
expect([...groupedBosses].sort()).toEqual([...AVAILABLE_BOSS_IDS].sort());
expect(BOSS_GROUPS.find((group) => group.letter === "A")?.bossIds).toEqual([
"bulldrome", "stormwool-alpaca", "thorncrown-stag", "bristlequake-boar",
]);
for (const group of BOSS_GROUPS) {
for (const bossId of group.bossIds) expect(group.archetypes).toContain(BOSS_DEFINITIONS[bossId].archetype);
}
});
it("removes the retired licensed bosses from the playable roster", () => {
expect(AVAILABLE_BOSS_IDS).not.toContain("vexa");
expect(AVAILABLE_BOSS_IDS).not.toContain("ember-mantis-duelist");
expect(AVAILABLE_BOSS_IDS).not.toContain("obsidian-ram-golem");
expect(Object.values(BOSS_DEFINITIONS).map((boss) => boss.name)).not.toEqual(expect.arrayContaining([
"Insect Queen",
"Gate Guardian",
"Gandora the Dragon of Destruction",
"Blue-Eyes White Dragon",
"Red-Eyes Black Dragon",
"Pumpking the King of Ghosts",
"Blue-Eyes Ultimate Dragon",
]));
});
it.each(AVAILABLE_BOSS_IDS)("creates typed state and modular motion for %s", (bossId) => {
const state = createBossState(bossId);
const motion = createBossMotionState(bossId);
expect(state).toMatchObject({ id: bossId, name: BOSS_DEFINITIONS[bossId].name, maxHp: BOSS_DEFINITIONS[bossId].maxHp });
expect(state.hp).toBe(state.maxHp);
expect(motion.bossId).toBe(bossId);
});
});
+229 -186
View File
@@ -1,5 +1,39 @@
import type { BossId } from "./types";
export type BossArchetype =
| "bull"
| "web-caster"
| "sky-sweeper"
| "duelist"
| "ram"
| "ricochet"
| "burrower"
| "crab"
| "ghost"
| "golem";
export type BossGroupId =
| "charge"
| "bind-venom"
| "breath-skyfall"
| "slash-cross"
| "ricochet"
| "burrow-eruption"
| "scuttle-burst"
| "rift-lanes"
| "shockwave-fall";
export type BossGroupLetter = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I";
export interface BossGroupDefinition {
id: BossGroupId;
letter: BossGroupLetter;
name: string;
coreMechanic: string;
bossIds: readonly BossId[];
archetypes: readonly BossArchetype[];
}
export interface BossDefinition {
id: BossId;
name: string;
@@ -12,204 +46,213 @@ export interface BossDefinition {
failure: string;
mapTitle: string;
mapCopy: string;
mechanics: readonly [string, string];
mechanics: readonly [string, string, ...string[]];
maxHp: number;
archetype: BossArchetype;
groupId: BossGroupId;
}
export const BOSS_ORDER: readonly BossId[] = [
"bulldrome",
"vexa",
"cindermaw",
"ember-mantis-duelist",
"obsidian-ram-golem",
"cinderback-ricochet",
"sandglass-scorpion",
"cragclaw-crab",
"pumpking-king-of-ghosts",
"blue-eyes-ultimate-dragon",
"mournveil-ghost",
"crownshard-golem",
];
interface BossSeed {
name: string;
title: string;
icon: string;
accent: string;
summary: string;
mechanics: readonly [string, string, ...string[]];
maxHp: number;
archetype: BossArchetype;
}
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
function boss(id: BossId, index: number, seed: BossSeed): BossDefinition {
const [first, second] = seed.mechanics;
return {
id,
...seed,
groupId: BOSS_GROUP_BY_BOSS_ID[id],
trial: `Trial ${String(index + 1).padStart(2, "0")} · ${seed.title}`,
briefing: `Read ${first}, then preserve open ground for ${second}.`,
failure: `Do not overlap ${first}. Re-form only after ${second} resolves.`,
mapTitle: seed.title,
mapCopy: `${seed.summary} Keep escape lanes open and move before each warning completes.`,
};
}
const BOSS_SEEDS: Record<BossId, BossSeed> = {
bulldrome: {
id: "bulldrome",
name: "Bulldrome",
title: "The Cinder Bull",
trial: "Trial I · Healer Initiation",
icon: "♜",
accent: "#e2744e",
summary: "Charges marked lanes and crushes grouped targets.",
briefing: "Keep formation alive. Sidestep the charge lane, then stack tightly for the Bull's pounce.",
failure: "Protect Brann and the healer. Purify Ember Brand before it burns through formation.",
mapTitle: "Hall of the Bull",
mapCopy: "Keep Brann between formation and Bull. Move clear when the charge lane turns red.",
mechanics: ["Bull Charge", "Crushing Pounce"],
maxHp: 500,
},
vexa: {
id: "vexa",
name: "Insect Queen",
title: "The Hive Sovereign",
trial: "Trial II · Royal Brood",
icon: "✣",
accent: "#b56cff",
summary: "Pins prey with silk before flooding safe ground with venom.",
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its brood pool poisons formation.",
failure: "Break royal tethers quickly. Cleanse venom only after its target reaches open ground.",
mapTitle: "The Royal Hive",
mapCopy: "Spread tethered allies toward opposite edges. Keep venom brood pools away from center.",
mechanics: ["Binding Web", "Venom Brood"],
maxHp: 535,
},
cindermaw: {
id: "cindermaw",
name: "Blue-Eyes White Dragon",
title: "The White Lightning",
trial: "Trial III · Burststream Orbit",
icon: "◆",
accent: "#ff9b45",
summary: "Sweeps the arena with white lightning and dives through targeted ground.",
briefing: "Rotate behind Burst Stream. During White Skyfall, leave each numbered impact before it becomes charged ground.",
failure: "Follow the safe side of the breath cone. Keep moving as White Skyfall removes sections of arena.",
mapTitle: "The Ivory Crown",
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between skyfall impacts.",
mechanics: ["Burst Stream", "White Skyfall"],
maxHp: 410,
},
"ember-mantis-duelist": {
id: "ember-mantis-duelist",
name: "Gate Guardian",
title: "The Tri-Element Sentinel",
trial: "Trial IV · Elements in Motion",
icon: "⚔",
accent: "#ff6a2a",
summary: "Repositions its stacked body before firing single and crossed elemental lanes.",
briefing: "Track each sidestep. Clear Elemental Beam, then find a safe quadrant when three powers form Guardian Cross.",
failure: "Do not chase the guardian through a telegraph. Preserve space and move perpendicular to each beam lane.",
mapTitle: "The Sealed Gate",
mapCopy: "Follow the guardian laterally, but cross glowing lanes only after its arms finish firing.",
mechanics: ["Elemental Beam", "Guardian Cross"],
maxHp: 520,
},
"obsidian-ram-golem": {
id: "obsidian-ram-golem",
name: "Gandora the Dragon of Destruction",
title: "The Ruin Orb",
trial: "Trial V · Destruction March",
icon: "♞",
accent: "#ff7a38",
summary: "Breaks formation with armored rushes, ruin quakes, and radial destruction beams.",
briefing: "Clear Destruction Rush, leave Ruin Quake, then step between Gandora's radial destruction lines.",
failure: "Do not remain in front of the armored dragon. Treat every glowing orb as an active strike lane.",
mapTitle: "The Ruined Causeway",
mapCopy: "Hold open flanks for Destruction Rush. Spread between radial fractures when its armor vents.",
mechanics: ["Destruction Rush", "Ruin Quake"],
maxHp: 540,
},
"cinderback-ricochet": {
id: "cinderback-ricochet",
name: "Red-Eyes Black Dragon",
title: "The Black Flare",
trial: "Trial VI · Inferno Rebound",
icon: "⬢",
accent: "#ff9345",
summary: "Rebounds through marked flight lanes and leaves black-flame impact pools.",
briefing: "Clear both Inferno Rush lanes. Meteor Slam blooms into black-flame pools around its landing zone.",
failure: "Watch the second rebound before returning to formation. Preserve a clean route around black flame.",
mapTitle: "The Inferno Circuit",
mapCopy: "Bait the dragon along arena edges. Never cross a marked flight lane before second impact.",
mechanics: ["Inferno Rush", "Meteor Slam"],
maxHp: 505,
name: "Bulldrome", title: "Hall of the Cinder Bull", icon: "♜", accent: "#e2744e",
summary: "Charges marked lanes and crushes grouped targets.", mechanics: ["Bull Charge", "Crushing Pounce"], maxHp: 500, archetype: "bull",
},
"sandglass-scorpion": {
id: "sandglass-scorpion",
name: "Sandglass Scorpion",
title: "The Dune Chronarch",
trial: "Trial VII · Hour of Venom",
icon: "⌛",
accent: "#e9b94f",
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.",
briefing: "Cross the Burrow Rush lane before it dives. Leave Stinger Eruptions, then outrun the active Hourglass zone.",
failure: "Move before each timer completes. Sand warnings become damaging ground the instant they fill.",
mapTitle: "The Sunken Hour",
mapCopy: "Keep the center open. Burrow paths split formation while hourglass zones close escape routes.",
mechanics: ["Burrow Rush", "Hourglass Eruption"],
maxHp: 515,
name: "Sandglass Scorpion", title: "The Sunken Hour", icon: "⌛", accent: "#e9b94f",
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.", mechanics: ["Burrow Rush", "Hourglass Eruption"], maxHp: 515, archetype: "burrower",
},
"cragclaw-crab": {
id: "cragclaw-crab",
name: "Cragclaw",
title: "The Breakwater Tyrant",
trial: "Trial VIII · Tide in the Claws",
icon: "♋",
accent: "#49c7d4",
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.",
briefing: "Clear Sidewinder Rush, then leave every Crushing Tide circle before the claws close.",
failure: "Cross the scuttle lane only after Cragclaw passes. Spread targeted circles away from formation.",
mapTitle: "The Drowned Breakwater",
mapCopy: "Keep open water between party lanes. Tidal marks punish overlapping escape routes.",
mechanics: ["Sidewinder Rush", "Crushing Tide"],
maxHp: 505,
},
"pumpking-king-of-ghosts": {
id: "pumpking-king-of-ghosts",
name: "Pumpking the King of Ghosts",
title: "The Haunted Harvest",
trial: "Trial XI · Vines Unbound",
icon: "♚",
accent: "#d87842",
summary: "Whips the arena twice with spectral vines and grows hungry rifts beneath allies.",
briefing: "Dodge both Vine Scissor patterns. Carry Haunting Rifts away before they sprout.",
failure: "The second vine cross rotates. Do not return to the first safe quadrant early.",
mapTitle: "The Haunted Patch",
mapCopy: "Read both crossing vine patterns, then preserve clear ground for persistent ghost rifts.",
mechanics: ["Vine Scissors", "Haunting Rifts"],
maxHp: 505,
},
"blue-eyes-ultimate-dragon": {
id: "blue-eyes-ultimate-dragon",
name: "Blue-Eyes Ultimate Dragon",
title: "The Three-Headed Tyrant",
trial: "Trial XII · Ultimate Evolution",
icon: "♕",
accent: "#8fc8ff",
summary: "Three heads fire expanding burst rings before marking allies for ultimate skyfall.",
briefing: "Move through each Tri-Burst ring, then spread targeted Ultimate Skyfall circles.",
failure: "Three shockwaves expand in sequence. Commit to each safe band before the next head fires.",
mapTitle: "The Ultimate Aerie",
mapCopy: "Follow expanding safe bands. Spread triple-head skyfall marks toward separate arena edges.",
mechanics: ["Tri-Burst Rings", "Ultimate Skyfall"],
maxHp: 500,
name: "Cragclaw", title: "The Drowned Breakwater", icon: "♋", accent: "#49c7d4",
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.", mechanics: ["Sidewinder Rush", "Crushing Tide"], maxHp: 505, archetype: "crab",
},
"mournveil-ghost": {
id: "mournveil-ghost",
name: "Mournveil",
title: "The Hollow Choir",
trial: "Trial IX · Echoes Unbound",
icon: "◉",
accent: "#9d72ff",
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.",
briefing: "Dodge both Soul Scissor patterns. Carry Haunting Rifts away before they open.",
failure: "The second spectral cross rotates. Do not return to the first safe quadrant early.",
mapTitle: "The Silent Reliquary",
mapCopy: "Read both crossing patterns, then preserve clear ground for persistent soul rifts.",
mechanics: ["Soul Scissors", "Haunting Rifts"],
maxHp: 505,
name: "Mournveil", title: "The Silent Reliquary", icon: "◉", accent: "#9d72ff",
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.", mechanics: ["Soul Scissors", "Haunting Rifts"], maxHp: 505, archetype: "ghost",
},
"crownshard-golem": {
id: "crownshard-golem",
name: "Crownshard Golem",
title: "The Fallen Idol",
trial: "Trial X · Edict of Stone",
icon: "♛",
accent: "#e0bd45",
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.",
briefing: "Move through each Royal Shockwave ring, then clear targeted Crownfall circles.",
failure: "Shockwaves expand in three steps. Commit to each safe band before the next ring fires.",
mapTitle: "The Broken Coronation",
mapCopy: "Follow the expanding safe bands. Spread Crownfall marks toward separate arena edges.",
mechanics: ["Royal Shockwave", "Crownfall"],
maxHp: 500,
name: "Crownshard Golem", title: "The Broken Coronation", icon: "♛", accent: "#e0bd45",
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.", mechanics: ["Royal Shockwave", "Crownfall"], maxHp: 500, archetype: "golem",
},
"crystal-bat-matriarch": {
name: "Crystal Bat Matriarch", title: "The Prism Echo", icon: "◈", accent: "#8eeaff",
summary: "Sonic rings force precise spacing while orbiting mirror shards fracture safe ground.", mechanics: ["Sonic Ring", "Mirror Shards"], maxHp: 480, archetype: "golem",
},
"stormwool-alpaca": {
name: "Stormwool", title: "The Thunder Fleece", icon: "ϟ", accent: "#8fc7ff",
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanics: ["Storm Charge", "Cloudburst Pounce"], maxHp: 480, archetype: "bull",
},
"cluckhorn-colossus": {
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanics: ["Roost Rush", "Shellburst"], maxHp: 475, archetype: "crab",
},
"ashwing-demon": {
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
summary: "Carves crossing fire lanes and opens persistent ember rifts.", mechanics: ["Ash Scissors", "Cinder Rifts"], maxHp: 515, archetype: "ghost",
},
"riftclaw-demon": {
name: "Riftclaw", title: "The Broken Duel", icon: "⚔", accent: "#d45cff",
summary: "Sidesteps around the tank before cutting single and crossed void lanes.", mechanics: ["Rift Blade", "Abyss Cross"], maxHp: 495, archetype: "duelist",
},
"tempestscale-dragon": {
name: "Tempestscale", title: "The Living Storm", icon: "☈", accent: "#5fc8e8",
summary: "Sweeps the arena with storm breath before marking allies for sky strikes.", mechanics: ["Tempest Breath", "Stormfall"], maxHp: 500, archetype: "sky-sweeper",
},
emberfox: {
name: "Emberfox", title: "The Burning Trail", icon: "✦", accent: "#ff7b45",
summary: "Ricochets across the arena and leaves fire at every landing.", mechanics: ["Foxfire Rush", "Ember Pounce"], maxHp: 465, archetype: "ricochet",
},
"mirelord-frog": {
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
summary: "Dives below the mire before erupting through timed bog zones.", mechanics: ["Mire Dive", "Bogglass Eruption"], maxHp: 490, archetype: "burrower",
},
"stonebreaker-giant": {
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
summary: "Sends quake bands across the floor and rains boulders on spread allies.", mechanics: ["Crag Shockwave", "Boulderfall"], maxHp: 500, archetype: "golem",
},
"glub-sovereign": {
name: "Glub Sovereign", title: "The Binding Ooze", icon: "◌", accent: "#6ce0b8",
summary: "Links two allies with living slime before seeding toxic pools.", mechanics: ["Ooze Tether", "Caustic Brood"], maxHp: 495, archetype: "web-caster",
},
"scrapking-goblin": {
name: "Scrapking", title: "The Jagged Throne", icon: "⚒", accent: "#d7a34b",
summary: "Repositions between attacks and fires improvised blade lanes.", mechanics: ["Scrap Blade", "Junkyard Cross"], maxHp: 485, archetype: "duelist",
},
"warcaller-orc": {
name: "Warcaller", title: "The Red Standard", icon: "⚑", accent: "#e4533f",
summary: "Tracks the party flank before cleaving single and crossed war lanes.", mechanics: ["Warpath Cleave", "Banner Cross"], maxHp: 500, archetype: "duelist",
},
"tuskmaw-orc": {
name: "Tuskmaw", title: "The Breaker Below", icon: "◈", accent: "#9eb25d",
summary: "Rushes laterally and crushes three marked allies beneath tusk bursts.", mechanics: ["Tusk Rush", "Groundbreaker"], maxHp: 525, archetype: "crab",
},
"broodfang-spider": {
name: "Broodfang", title: "The Silk Tyrant", icon: "✣", accent: "#b56cff",
summary: "Binds paired prey with silk before flooding safe ground with venom.", mechanics: ["Binding Web", "Venom Brood"], maxHp: 535, archetype: "web-caster",
},
"silkfang-spider": {
name: "Silkfang", title: "The Gloom Weaver", icon: "✤", accent: "#9d68d8",
summary: "Snares paired allies before seeding the arena with toxic nests.", mechanics: ["Silk Snare", "Venom Nest"], maxHp: 505, archetype: "web-caster",
},
"thorncrown-stag": {
name: "Thorncrown", title: "The Briar Hart", icon: "♧", accent: "#7fc46b",
summary: "Charges through thorn lanes and leaps onto grouped prey.", mechanics: ["Briar Charge", "Crown Pounce"], maxHp: 510, archetype: "bull",
},
"sky-totem": {
name: "Sky Totem", title: "The Hollow Idol", icon: "☼", accent: "#69d4d1",
summary: "Cuts the floor with spirit lanes and anchors hungry wind rifts.", mechanics: ["Spirit Scissors", "Wind Rifts"], maxHp: 505, archetype: "ghost",
},
"razorcrest-raptor": {
name: "Razorcrest", title: "The Hunting Circuit", icon: "➳", accent: "#d9c45a",
summary: "Rebounds through hunting lanes and tears open impact pools.", mechanics: ["Raptor Rush", "Talon Slam"], maxHp: 500, archetype: "ricochet",
},
"bristlequake-boar": {
name: "Bristlequake", title: "The Iron Tusk", icon: "♞", accent: "#d47b45",
summary: "Breaks formation with armored rushes, quakes, and radial fault lines.", mechanics: ["Tusk Charge", "Bristle Quake"], maxHp: 545, archetype: "ram",
},
"moonfang-wolf": {
name: "Moonfang", title: "The Silver Pursuit", icon: "☾", accent: "#9db9e5",
summary: "Ricochets between marked lanes and leaves moonfire at each strike.", mechanics: ["Lunar Rush", "Moonfall"], maxHp: 495, archetype: "ricochet",
},
"frostmaw-yeti": {
name: "Frostmaw", title: "The White Avalanche", icon: "❄", accent: "#8ed8ef",
summary: "Scuttles through ice lanes and buries spread allies beneath frost bursts.", mechanics: ["Avalanche Rush", "Frost Crush"], maxHp: 550, archetype: "crab",
},
"rimeclaw-yeti": {
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanics: ["Rime Blade", "Glacier Cross"], maxHp: 500, archetype: "duelist",
},
};
/**
* Loot and encounter organization. Archetypes still select existing combat
* scripts; groups describe the shared core mechanic and reward material.
*/
export const BOSS_GROUPS: readonly BossGroupDefinition[] = [
{
id: "charge", letter: "A", name: "Charge", coreMechanic: "Charge",
bossIds: ["bulldrome", "stormwool-alpaca", "thorncrown-stag", "bristlequake-boar"], archetypes: ["bull", "ram"],
},
{
id: "bind-venom", letter: "B", name: "Bind / Venom", coreMechanic: "Bind and Venom",
bossIds: ["glub-sovereign", "broodfang-spider", "silkfang-spider"], archetypes: ["web-caster"],
},
{
id: "breath-skyfall", letter: "C", name: "Breath / Skyfall", coreMechanic: "Breath and Skyfall",
bossIds: ["tempestscale-dragon"], archetypes: ["sky-sweeper"],
},
{
id: "slash-cross", letter: "D", name: "Slash / Cross", coreMechanic: "Slash and Cross",
bossIds: ["riftclaw-demon", "scrapking-goblin", "warcaller-orc", "rimeclaw-yeti"], archetypes: ["duelist"],
},
{
id: "ricochet", letter: "E", name: "Ricochet", coreMechanic: "Ricochet",
bossIds: ["emberfox", "razorcrest-raptor", "moonfang-wolf"], archetypes: ["ricochet"],
},
{
id: "burrow-eruption", letter: "F", name: "Burrow / Eruption", coreMechanic: "Burrow and Eruption",
bossIds: ["sandglass-scorpion", "mirelord-frog"], archetypes: ["burrower"],
},
{
id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst",
bossIds: ["cragclaw-crab", "cluckhorn-colossus", "tuskmaw-orc", "frostmaw-yeti"], archetypes: ["crab"],
},
{
id: "rift-lanes", letter: "H", name: "Rift / Lanes", coreMechanic: "Rifts and Lanes",
bossIds: ["mournveil-ghost", "ashwing-demon", "sky-totem"], archetypes: ["ghost"],
},
{
id: "shockwave-fall", letter: "I", name: "Shockwave / Fall", coreMechanic: "Shockwave and Fall",
bossIds: ["crownshard-golem", "crystal-bat-matriarch", "stonebreaker-giant"], archetypes: ["golem"],
},
] as const;
export const BOSS_GROUP_BY_ID = Object.fromEntries(
BOSS_GROUPS.map((group) => [group.id, group]),
) as Record<BossGroupId, BossGroupDefinition>;
export const BOSS_GROUP_BY_BOSS_ID = Object.fromEntries(
BOSS_GROUPS.flatMap((group) => group.bossIds.map((bossId) => [bossId, group.id])),
) as Record<BossId, BossGroupId>;
export function bossGroupFor(bossId: BossId): BossGroupDefinition {
return BOSS_GROUP_BY_ID[BOSS_GROUP_BY_BOSS_ID[bossId]];
}
/**
* Canonical roster. Adding a boss definition makes it eligible for every
* encounter surface, including the dungeon selector and roguelike pool.
*/
export const AVAILABLE_BOSS_IDS = Object.freeze(Object.keys(BOSS_SEEDS) as BossId[]);
export const BOSS_DEFINITIONS = Object.fromEntries(
AVAILABLE_BOSS_IDS.map((id, index) => [id, boss(id, index, BOSS_SEEDS[id])]),
) as Record<BossId, BossDefinition>;
export const BOSS_ARCHETYPE_BY_ID = Object.fromEntries(
AVAILABLE_BOSS_IDS.map((id) => [id, BOSS_DEFINITIONS[id].archetype]),
) as Record<BossId, BossArchetype>;
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { ARENA_CENTER } from "./arena";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { advanceBossMechanics, createBossMotionState, createBossState } from "./bossMechanics";
import { freshParty } from "./data";
import type { WorldPosition } from "./types";
const PARTY_POSITIONS = {
aelia: [0, 4.5],
brann: [7, 5],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
} satisfies Record<"aelia" | "brann" | "nia" | "orin" | "vale", WorldPosition>;
describe("boss home positioning", () => {
it.each(AVAILABLE_BOSS_IDS)("moves %s back toward its center slot while idle", (bossId) => {
const motion = createBossMotionState(bossId);
motion.position = [7, 5];
motion.nextChargeAt = Number.POSITIVE_INFINITY;
motion.nextMechanicAt = Number.POSITIVE_INFINITY;
motion.nextPoolMechanicAt = Number.POSITIVE_INFINITY;
const before = Math.hypot(
motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX),
motion.position[1] - ARENA_CENTER[1],
);
const result = advanceBossMechanics({
boss: createBossState(bossId),
motion,
party: freshParty(),
partyPositions: structuredClone(PARTY_POSITIONS),
time: 0,
delta: 0.5,
damageMember: (member) => member,
});
const after = Math.hypot(
result.motion.position[0] - (ARENA_CENTER[0] + result.motion.formationOffsetX),
result.motion.position[1] - ARENA_CENTER[1],
);
expect(after).toBeLessThan(before);
});
});
+68 -75
View File
@@ -1,14 +1,15 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_ARCHETYPE_BY_ID, BOSS_DEFINITIONS, type BossArchetype } from "./bossCatalog";
import { clampToArena } from "./arena";
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/cinderbackRicochet";
import { advanceSkySweeperMechanics, createSkySweeperMotion, createSkySweeperState, upcomingSkySweeperMechanic } from "./bosses/skySweeper";
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/ricochet";
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
import { createBaseMotion } from "./bosses/shared";
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
import { advancePooledBossMechanics, upcomingPooledMechanic } from "./bosses/mechanicPool";
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
import { distance, moveToward, pointToSegmentDistance } from "./geometry";
@@ -45,54 +46,46 @@ const CHARGE_TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "aelia"
const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
export function createBossState(bossId: BossId = "bulldrome"): BossState {
if (bossId === "vexa") return createVexaState();
if (bossId === "cindermaw") return createCindermawState();
if (bossId === "ember-mantis-duelist") return createEmberMantisState();
if (bossId === "obsidian-ram-golem") return createObsidianRamState();
if (bossId === "cinderback-ricochet") return createCinderbackState();
if (bossId === "sandglass-scorpion") return createSandglassState();
if (bossId === "cragclaw-crab") return createCragclawState();
if (bossId === "mournveil-ghost") return createMournveilState();
if (bossId === "crownshard-golem") return createCrownshardState();
if (bossId === "pumpking-king-of-ghosts" || bossId === "blue-eyes-ultimate-dragon") {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: definition.id,
const definition = BOSS_DEFINITIONS[bossId];
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
let state: BossState;
if (archetype === "web-caster") state = createVexaState(bossId);
else if (archetype === "sky-sweeper") state = createSkySweeperState(bossId);
else if (archetype === "duelist") state = createEmberMantisState(bossId);
else if (archetype === "ram") state = createObsidianRamState(bossId);
else if (archetype === "ricochet") state = createCinderbackState(bossId);
else if (archetype === "burrower") state = createSandglassState();
else if (archetype === "crab") state = createCragclawState();
else if (archetype === "ghost") state = createMournveilState();
else if (archetype === "golem") state = createCrownshardState();
else {
state = {
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: bossId === "pumpking-king-of-ghosts" ? 2.35 : 2.4,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
brandCount: 0,
};
}
const definition = BOSS_DEFINITIONS.bulldrome;
return {
id: "bulldrome",
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: BOSS_PERIODIC_MECHANICS.melee.firstAt,
nextNovaAt: BOSS_PERIODIC_MECHANICS.nova.firstAt,
nextBrandAt: BOSS_PERIODIC_MECHANICS.brand.firstAt,
brandCount: 0,
};
return { ...state, id: bossId, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp };
}
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
if (bossId === "vexa") return createVexaMotion();
if (bossId === "cindermaw") return createCindermawMotion();
if (bossId === "ember-mantis-duelist") return createEmberMantisMotion();
if (bossId === "obsidian-ram-golem") return createObsidianRamMotion();
if (bossId === "cinderback-ricochet") return createCinderbackMotion();
if (bossId === "sandglass-scorpion") return createSandglassMotion();
if (bossId === "cragclaw-crab") return createCragclawMotion();
if (bossId === "mournveil-ghost") return createMournveilMotion();
if (bossId === "crownshard-golem") return createCrownshardMotion();
if (bossId === "pumpking-king-of-ghosts") return { ...createMournveilMotion(), bossId };
if (bossId === "blue-eyes-ultimate-dragon") return { ...createCrownshardMotion(), bossId };
return {
const archetype = BOSS_ARCHETYPE_BY_ID[bossId];
let motion: BossMotionState;
if (archetype === "web-caster") motion = createVexaMotion(bossId);
else if (archetype === "sky-sweeper") motion = createSkySweeperMotion(bossId);
else if (archetype === "duelist") motion = createEmberMantisMotion(bossId);
else if (archetype === "ram") motion = createObsidianRamMotion(bossId);
else if (archetype === "ricochet") motion = createCinderbackMotion(bossId);
else if (archetype === "burrower") motion = createSandglassMotion();
else if (archetype === "crab") motion = createCragclawMotion();
else if (archetype === "ghost") motion = createMournveilMotion();
else if (archetype === "golem") motion = createCrownshardMotion();
else motion = {
...createBaseMotion("bulldrome"),
mode: "holding",
position: [0, -8.2],
@@ -108,6 +101,11 @@ export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionS
pounceCenter: [0, 4.5],
pounceCount: 0,
};
return { ...motion, bossId };
}
function mechanicArchetype(bossId: BossId): BossArchetype {
return BOSS_ARCHETYPE_BY_ID[bossId];
}
function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
@@ -152,8 +150,7 @@ function advanceMotionMechanics(
let updatedParty = party;
if (motion.mode === "holding") {
const tank = partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * delta);
returnBossToArenaCenter(motion, delta, 1.8);
if (time >= motion.nextChargeAt) {
const targetId = chooseTarget(updatedParty, CHARGE_TARGET_ORDER, motion.chargeCount);
const target = partyPositions[targetId];
@@ -207,16 +204,12 @@ function advanceMotionMechanics(
motion = { ...motion, position: [motion.chargeEnd[0], motion.chargeEnd[1]], mode: "returning", phaseEndsAt: 0 };
}
} else if (motion.mode === "returning") {
const tank = partyPositions.brann;
const returnPoint: WorldPosition = [tank[0] + motion.formationOffsetX, tank[1] - 4.25];
motion.position = moveToward(motion.position, returnPoint, 4.4 * delta);
if (distance(motion.position, returnPoint) < 0.12) {
if (returnBossToArenaCenter(motion, delta, 4.4)) {
if (motion.chargesSincePounce >= BULL_POUNCE.afterCharges) {
const targetId = chooseTarget(updatedParty, POUNCE_TARGET_ORDER, motion.pounceCount);
const targetName = updatedParty.find((member) => member.id === targetId)?.name ?? targetId;
motion = {
...motion,
position: returnPoint,
mode: "stacking",
phaseEndsAt: time + BULL_POUNCE.stackDuration,
nextChargeAt: Number.POSITIVE_INFINITY,
@@ -235,7 +228,6 @@ function advanceMotionMechanics(
} else {
motion = {
...motion,
position: returnPoint,
mode: "holding",
nextChargeAt: time + BULL_CHARGE.repeatDelay,
};
@@ -383,18 +375,18 @@ function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicRe
}
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
if (context.boss.id === "ember-mantis-duelist") return advanceEmberMantisMechanics(context);
if (context.boss.id === "obsidian-ram-golem") return advanceObsidianRamMechanics(context);
if (context.boss.id === "cinderback-ricochet") return advanceCinderbackMechanics(context);
if (context.boss.id === "sandglass-scorpion") return advanceSandglassMechanics(context);
if (context.boss.id === "cragclaw-crab") return advanceCragclawMechanics(context);
if (context.boss.id === "mournveil-ghost") return advanceMournveilMechanics(context);
if (context.boss.id === "crownshard-golem") return advanceCrownshardMechanics(context);
if (context.boss.id === "pumpking-king-of-ghosts") return advanceMournveilMechanics(context);
if (context.boss.id === "blue-eyes-ultimate-dragon") return advanceCrownshardMechanics(context);
return advanceBulldromeMechanics(context);
const archetype = mechanicArchetype(context.boss.id);
const result = archetype === "web-caster" ? advanceVexaMechanics(context)
: archetype === "sky-sweeper" ? advanceSkySweeperMechanics(context)
: archetype === "duelist" ? advanceEmberMantisMechanics(context)
: archetype === "ram" ? advanceObsidianRamMechanics(context)
: archetype === "ricochet" ? advanceCinderbackMechanics(context)
: archetype === "burrower" ? advanceSandglassMechanics(context)
: archetype === "crab" ? advanceCragclawMechanics(context)
: archetype === "ghost" ? advanceMournveilMechanics(context)
: archetype === "golem" ? advanceCrownshardMechanics(context)
: advanceBulldromeMechanics(context);
return advancePooledBossMechanics(context, result);
}
export function handleBossDispel(
@@ -405,7 +397,7 @@ export function handleBossDispel(
time: number,
debuffNames: readonly string[],
) {
if (bossId === "vexa" && debuffNames.includes("Widow Venom")) {
if (mechanicArchetype(bossId) === "web-caster" && debuffNames.includes("Widow Venom")) {
return {
motion: dropVexaVenomPool(motion, memberId, [position[0], position[1]], time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
@@ -415,17 +407,18 @@ export function handleBossDispel(
}
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time);
if (boss.id === "ember-mantis-duelist") return upcomingEmberMantisMechanic(boss, motion, time);
if (boss.id === "obsidian-ram-golem") return upcomingObsidianRamMechanic(boss, motion, time);
if (boss.id === "cinderback-ricochet") return upcomingCinderbackMechanic(boss, motion, time);
if (boss.id === "sandglass-scorpion") return upcomingSandglassMechanic(boss, motion, time);
if (boss.id === "cragclaw-crab") return upcomingCragclawMechanic(boss, motion, time);
if (boss.id === "mournveil-ghost") return upcomingMournveilMechanic(boss, motion, time);
if (boss.id === "crownshard-golem") return upcomingCrownshardMechanic(boss, motion, time);
if (boss.id === "pumpking-king-of-ghosts") return upcomingMournveilMechanic(boss, motion, time);
if (boss.id === "blue-eyes-ultimate-dragon") return upcomingCrownshardMechanic(boss, motion, time);
const pooled = upcomingPooledMechanic(motion, time);
if (pooled) return pooled;
const archetype = mechanicArchetype(boss.id);
if (archetype === "web-caster") return upcomingVexaMechanic(boss, motion, time);
if (archetype === "sky-sweeper") return upcomingSkySweeperMechanic(boss, motion, time);
if (archetype === "duelist") return upcomingEmberMantisMechanic(boss, motion, time);
if (archetype === "ram") return upcomingObsidianRamMechanic(boss, motion, time);
if (archetype === "ricochet") return upcomingCinderbackMechanic(boss, motion, time);
if (archetype === "burrower") return upcomingSandglassMechanic(boss, motion, time);
if (archetype === "crab") return upcomingCragclawMechanic(boss, motion, time);
if (archetype === "ghost") return upcomingMournveilMechanic(boss, motion, time);
if (archetype === "golem") return upcomingCrownshardMechanic(boss, motion, time);
if (motion.mode === "telegraph") {
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { ARENA_RADIUS, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "./arena";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { BOSS_ROOMS, bossRoomFor } from "./bossRooms";
describe("boss rooms", () => {
it("scales gameplay space and visible walls together by thirty percent", () => {
expect(ARENA_SIZE_MULTIPLIER).toBe(1.3);
expect(ARENA_RADIUS).toBeCloseTo(8.35 * 1.3);
expect(ARENA_WALL_RADIUS).toBeCloseTo(9.55 * 1.3);
});
it("gives every selectable boss its own room definition", () => {
expect(Object.keys(BOSS_ROOMS)).toEqual(AVAILABLE_BOSS_IDS);
expect(new Set(AVAILABLE_BOSS_IDS.map((bossId) => bossRoomFor(bossId).id))).toHaveLength(AVAILABLE_BOSS_IDS.length);
});
it("spans multiple readable biomes instead of recoloring one arena", () => {
expect(new Set(Object.values(BOSS_ROOMS).map((room) => room.floor)).size).toBeGreaterThanOrEqual(12);
});
});
+140
View File
@@ -0,0 +1,140 @@
import type { BossId } from "./types";
export type BossRoomFloor =
| "cinder"
| "desert"
| "tide"
| "reliquary"
| "royal"
| "prism"
| "storm"
| "wilds"
| "void"
| "mire"
| "quarry"
| "junkyard"
| "web"
| "moon"
| "frost"
| "warcamp"
| "sky";
export interface BossRoomDefinition {
/** Stable room key. Keep this independent from a boss's combat archetype. */
id: string;
name: string;
biome: string;
floor: BossRoomFloor;
background: string;
fog: string;
sky: string;
ground: string;
floorColor: string;
wallColor: string;
accent: string;
accentSecondary: string;
wallHeight: number;
}
function room(
id: string,
name: string,
biome: string,
floor: BossRoomFloor,
colors: Omit<BossRoomDefinition, "id" | "name" | "biome" | "floor">,
): BossRoomDefinition {
return { id, name, biome, floor, ...colors };
}
/**
* Every selectable guardian owns a room identity. Rendering composes this small
* data record into low-cost geometry, so encounter mechanics remain independent
* from the presentation of the room around them.
*/
export const BOSS_ROOMS = {
bulldrome: room("cinderstock-coliseum", "Cinderstock Coliseum", "Volcanic fortress", "cinder", {
background: "#190603", fog: "#351006", sky: "#e36b36", ground: "#120704", floorColor: "#382018", wallColor: "#3d1c15", accent: "#ff7138", accentSecondary: "#ffbd63", wallHeight: 3.8,
}),
"sandglass-scorpion": room("sunken-hour", "The Sunken Hour", "Buried sun temple", "desert", {
background: "#201607", fog: "#51401b", sky: "#e8c56d", ground: "#160f04", floorColor: "#5d4821", wallColor: "#6e5126", accent: "#f6c850", accentSecondary: "#9e672c", wallHeight: 3.2,
}),
"cragclaw-crab": room("drowned-breakwater", "The Drowned Breakwater", "Storm-tossed coast", "tide", {
background: "#041621", fog: "#0b4050", sky: "#7ad9e0", ground: "#031015", floorColor: "#0c3b49", wallColor: "#125264", accent: "#4cd9e7", accentSecondary: "#8ce9c6", wallHeight: 3.4,
}),
"mournveil-ghost": room("silent-reliquary", "The Silent Reliquary", "Haunted mausoleum", "reliquary", {
background: "#11091d", fog: "#332249", sky: "#b89cff", ground: "#0d0714", floorColor: "#2b1c3d", wallColor: "#3e2b52", accent: "#b985ff", accentSecondary: "#e1c1ff", wallHeight: 4.2,
}),
"crownshard-golem": room("broken-coronation", "The Broken Coronation", "Shattered royal hall", "royal", {
background: "#171005", fog: "#463518", sky: "#f0d88b", ground: "#100a03", floorColor: "#493617", wallColor: "#5d441c", accent: "#f1c951", accentSecondary: "#fff0a6", wallHeight: 4.4,
}),
"crystal-bat-matriarch": room("prism-echo", "The Prism Echo", "Crystal echo cavern", "prism", {
background: "#051522", fog: "#174d67", sky: "#92edff", ground: "#041019", floorColor: "#123a52", wallColor: "#1d6078", accent: "#82eaff", accentSecondary: "#d0a5ff", wallHeight: 4.8,
}),
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8,
}),
"cluckhorn-colossus": room("roostbreaker-yard", "The Roostbreaker Yard", "Ruinous farmstead", "wilds", {
background: "#171308", fog: "#49542a", sky: "#d8c675", ground: "#0b1207", floorColor: "#37421e", wallColor: "#554626", accent: "#f1b95c", accentSecondary: "#b8d065", wallHeight: 2.6,
}),
"ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", {
background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2,
}),
"riftclaw-demon": room("broken-duel", "The Broken Duel", "Fractured void arena", "void", {
background: "#080315", fog: "#251040", sky: "#c773ff", ground: "#05020e", floorColor: "#1c0f30", wallColor: "#311850", accent: "#d65cff", accentSecondary: "#7458ff", wallHeight: 3.7,
}),
"tempestscale-dragon": room("living-storm", "The Living Storm", "Eye of the storm", "sky", {
background: "#041b2b", fog: "#1d5a73", sky: "#7be4ef", ground: "#041018", floorColor: "#15465d", wallColor: "#24637a", accent: "#57d8ee", accentSecondary: "#d3ffff", wallHeight: 2.1,
}),
emberfox: room("burning-trail", "The Burning Trail", "Charred twilight grove", "cinder", {
background: "#1a0804", fog: "#4a1b0a", sky: "#ff9562", ground: "#0f0603", floorColor: "#412015", wallColor: "#5a2b16", accent: "#ff7d45", accentSecondary: "#ffc56a", wallHeight: 2.9,
}),
"mirelord-frog": room("drowned-bell", "The Drowned Bell", "Luminous blackwater mire", "mire", {
background: "#06170d", fog: "#235c36", sky: "#91dc84", ground: "#030d07", floorColor: "#1c4b2c", wallColor: "#30653b", accent: "#74d66b", accentSecondary: "#bcf08a", wallHeight: 2.5,
}),
"stonebreaker-giant": room("walking-crag", "The Walking Crag", "Deep mountain quarry", "quarry", {
background: "#16100b", fog: "#4a3528", sky: "#d8af82", ground: "#0b0806", floorColor: "#473127", wallColor: "#5b4234", accent: "#d39a62", accentSecondary: "#f0cb9b", wallHeight: 5.5,
}),
"glub-sovereign": room("binding-ooze", "The Binding Ooze", "Caustic slime caverns", "mire", {
background: "#06170f", fog: "#1d5c47", sky: "#76e5bc", ground: "#030c09", floorColor: "#174c3b", wallColor: "#216752", accent: "#66e3b3", accentSecondary: "#d0ffd3", wallHeight: 3.6,
}),
"scrapking-goblin": room("jagged-throne", "The Jagged Throne", "Scrap-metal forge", "junkyard", {
background: "#17100a", fog: "#513b25", sky: "#e1ac62", ground: "#0d0905", floorColor: "#3e3022", wallColor: "#5a462d", accent: "#e0a64c", accentSecondary: "#c9e58d", wallHeight: 3.3,
}),
"warcaller-orc": room("red-standard", "The Red Standard", "Siege camp", "warcamp", {
background: "#1d0807", fog: "#59221c", sky: "#e56b54", ground: "#100505", floorColor: "#48201b", wallColor: "#612a20", accent: "#e8543f", accentSecondary: "#e1b15c", wallHeight: 3.4,
}),
"tuskmaw-orc": room("breaker-below", "The Breaker Below", "Bone-strewn badlands", "quarry", {
background: "#16160b", fog: "#536036", sky: "#b7c67a", ground: "#0a0c05", floorColor: "#3e4525", wallColor: "#596037", accent: "#b6cf6a", accentSecondary: "#e7d08e", wallHeight: 3.5,
}),
"broodfang-spider": room("silk-tyrant", "The Silk Tyrant", "Venom web caverns", "web", {
background: "#140615", fog: "#49234b", sky: "#ce7fff", ground: "#0a030b", floorColor: "#351638", wallColor: "#532052", accent: "#c66cff", accentSecondary: "#88e66f", wallHeight: 4.3,
}),
"silkfang-spider": room("gloom-weaver", "The Gloom Weaver", "Moonlit silk grove", "web", {
background: "#10091b", fog: "#352653", sky: "#b39bff", ground: "#080510", floorColor: "#271b42", wallColor: "#3c2d62", accent: "#a979e7", accentSecondary: "#d1c1ff", wallHeight: 3.8,
}),
"thorncrown-stag": room("briar-hart", "The Briar Hart", "Ancient thornwood", "wilds", {
background: "#071509", fog: "#254e2a", sky: "#9cdb7b", ground: "#040b05", floorColor: "#234326", wallColor: "#355c31", accent: "#7fc96a", accentSecondary: "#d9b965", wallHeight: 4.1,
}),
"sky-totem": room("hollow-idol", "The Hollow Idol", "Cloudbound spirit plateau", "sky", {
background: "#051620", fog: "#1b5960", sky: "#76ded4", ground: "#041014", floorColor: "#154247", wallColor: "#236165", accent: "#66d9cf", accentSecondary: "#e0ffff", wallHeight: 2.3,
}),
"razorcrest-raptor": room("hunting-circuit", "The Hunting Circuit", "Overgrown predator ruins", "wilds", {
background: "#111407", fog: "#4d5522", sky: "#d8d36d", ground: "#080a04", floorColor: "#383d1b", wallColor: "#565a29", accent: "#d8c95c", accentSecondary: "#96d26b", wallHeight: 3.1,
}),
"bristlequake-boar": room("iron-tusk", "The Iron Tusk", "Ironwood quarry", "junkyard", {
background: "#15100a", fog: "#473c2b", sky: "#d89a63", ground: "#090705", floorColor: "#393125", wallColor: "#504636", accent: "#d67d45", accentSecondary: "#d0ba6d", wallHeight: 4.6,
}),
"moonfang-wolf": room("silver-pursuit", "The Silver Pursuit", "Moonlit ruins", "moon", {
background: "#07101d", fog: "#273a61", sky: "#b9d0ff", ground: "#040914", floorColor: "#1f3152", wallColor: "#34476e", accent: "#b3c8ff", accentSecondary: "#ece9ff", wallHeight: 3.9,
}),
"frostmaw-yeti": room("white-avalanche", "The White Avalanche", "Glacial ravine", "frost", {
background: "#061722", fog: "#306779", sky: "#9ceeff", ground: "#031017", floorColor: "#1b5466", wallColor: "#397588", accent: "#8ce5f5", accentSecondary: "#e9ffff", wallHeight: 4.7,
}),
"rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", {
background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9,
}),
} as const satisfies Record<BossId, BossRoomDefinition>;
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
return BOSS_ROOMS[bossId];
}
+2 -3
View File
@@ -2,7 +2,7 @@ import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards } from "./shared";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CRAGCLAW = {
@@ -103,8 +103,7 @@ export function advanceCragclawMechanics(context: BossMechanicContext): BossMech
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.05 * context.delta);
returnBossToArenaCenter(motion, context.delta, 2.05);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "crab_scuttle_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
+2 -4
View File
@@ -1,7 +1,6 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { moveToward } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CROWNSHARD = {
@@ -96,8 +95,7 @@ export function advanceCrownshardMechanics(context: BossMechanicContext): BossMe
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.55 * context.delta);
returnBossToArenaCenter(motion, context.delta, 1.55);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if ((motion.mode === "golem_shockwave" || motion.mode === "golem_crownfall") && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "golem_recover", phaseEndsAt: context.time + CROWNSHARD.recoverDuration };
+1 -1
View File
@@ -36,7 +36,7 @@ function context(
};
}
describe("Gate Guardian mechanics", () => {
describe("Warcaller mechanics", () => {
it("sidesteps, telegraphs Elemental Beam, then damages targets left in the lane", () => {
const boss = createEmberMantisState();
const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1));
+9 -15
View File
@@ -1,7 +1,7 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName } from "./shared";
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const EMBER_MANTIS_SLASH = {
@@ -27,8 +27,8 @@ const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "bran
const MIN_BOSS_X = -5.8;
const MAX_BOSS_X = 5.8;
export function createEmberMantisState(): BossState {
const definition = BOSS_DEFINITIONS["ember-mantis-duelist"];
export function createEmberMantisState(bossId: BossId = "warcaller-orc"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: definition.id,
name: definition.name,
@@ -41,9 +41,9 @@ export function createEmberMantisState(): BossState {
};
}
export function createEmberMantisMotion(): BossMotionState {
export function createEmberMantisMotion(bossId: BossId = "warcaller-orc"): BossMotionState {
return {
...createBaseMotion("ember-mantis-duelist"),
...createBaseMotion(bossId),
position: [0, -6.6],
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
};
@@ -161,17 +161,12 @@ export function advanceEmberMantisMechanics(context: BossMechanicContext): BossM
const events: BossMechanicResult["events"] = [];
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(
motion.position,
[tank[0] + motion.formationOffsetX, tank[1] - 4.25],
2.5 * context.delta,
);
returnBossToArenaCenter(motion, context.delta, 2.5);
if (context.time >= motion.nextMechanicAt) {
motion = beginSidestep(motion, party, context.partyPositions, context.time);
events.push({
at: context.time,
message: `Gate Guardian shifts toward ${memberName(party, motion.chargeTargetId)}. Track its arms.`,
message: `${boss.name} shifts toward ${memberName(party, motion.chargeTargetId)}. Track its attack.`,
tone: "danger",
pulseKind: "slash",
targetId: motion.chargeTargetId,
@@ -223,7 +218,6 @@ export function upcomingEmberMantisMechanic(
motion: BossMotionState,
time: number,
): UpcomingMechanic {
void boss;
if (motion.mode === "mantis_sidestep") {
return { name: "Guardian repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
}
@@ -234,7 +228,7 @@ export function upcomingEmberMantisMechanic(
return { name: "Guardian Cross — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_recover") {
return { name: "Gate Guardian exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
}
const nextIsCross = motion.mechanicCount % 2 === 1;
const remaining = Math.max(0, motion.nextMechanicAt - time);
+2 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./cinderbackRicochet";
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./ricochet";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, OBSIDIAN_RAM } from "./obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, SANDGLASS } from "./sandglassScorpion";
import type { BossMechanicContext } from "./types";
@@ -34,7 +34,7 @@ function context(
}
describe("IWT2 boss trio mechanics", () => {
it("telegraphs and resolves Obsidian Ram Plate Charge", () => {
it("telegraphs and resolves Bristlequake Tusk Charge", () => {
const start = advanceObsidianRamMechanics(context(createObsidianRamState(), createObsidianRamMotion(), OBSIDIAN_RAM.firstAt));
expect(start.motion.mode).toBe("ram_charge_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
+248
View File
@@ -0,0 +1,248 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import { createBaseMotion } from "./shared";
import { advancePooledBossMechanics, POOLED_MECHANIC_TIMING, SOUL_SIPHON } from "./mechanicPool";
import type { BossMechanicContext, BossMechanicResult } from "./types";
import type { BossState, PoolTelegraph, WorldPosition } from "../types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function state(): BossState {
return {
id: "bulldrome",
name: "Bulldrome",
maxHp: 500,
hp: 500,
nextMeleeAt: Number.POSITIVE_INFINITY,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
function context(time: number, positions = POSITIONS): BossMechanicContext {
return {
boss: state(),
motion: createBaseMotion("bulldrome"),
party: freshParty(),
partyPositions: structuredClone(positions),
time,
delta: 0.1,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
function result(contextValue: BossMechanicContext): BossMechanicResult {
return { boss: contextValue.boss, motion: contextValue.motion, party: contextValue.party, events: [] };
}
function memoryTelegraph(): PoolTelegraph {
return {
id: "test-memory",
kind: "memory",
name: "Memory Sequence",
center: [0, -8],
radius: 0,
activatesAt: 0,
inputStartsAt: 0,
expiresAt: 10,
damage: 15,
targetId: "aelia",
sequence: ["triangle", "cross", "circle", "square"],
tiles: [
{ symbol: "triangle", center: [-3, 2] },
{ symbol: "cross", center: [3, 2] },
{ symbol: "circle", center: [-3, -2] },
{ symbol: "square", center: [3, -2] },
],
inputIndex: 0,
resolved: false,
hitIds: [],
};
}
function advanceMemory(
time: number,
telegraph: PoolTelegraph,
party = freshParty(),
aeliaPosition: WorldPosition = [0, 4.5],
) {
const positions = structuredClone(POSITIONS);
positions.aelia = aeliaPosition;
const source = context(time, positions);
source.party = party;
source.motion = {
...source.motion,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: [telegraph],
};
return advancePooledBossMechanics(source, result(source));
}
function soulSiphonTelegraph(): PoolTelegraph {
return {
id: "test-soul-siphon",
kind: "soul-siphon",
name: "Soul Siphon",
center: [0, -7],
radius: 0,
activatesAt: 0,
expiresAt: Number.POSITIVE_INFINITY,
damage: 0,
targetId: "aelia",
soulSiphon: {
targetId: "aelia",
ghostPosition: [0, 3.6],
wardPosition: [0, -7],
wardRadius: SOUL_SIPHON.wardRadius,
nextDamageAt: SOUL_SIPHON.tickInterval,
tickCount: 0,
},
resolved: false,
hitIds: [],
};
}
function advanceSoulSiphon(
time: number,
telegraph: PoolTelegraph,
party = freshParty(),
aeliaPosition: WorldPosition = [0, 4.5],
) {
const positions = structuredClone(POSITIONS);
positions.aelia = aeliaPosition;
const source = context(time, positions);
source.party = party;
source.motion = {
...source.motion,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: [telegraph],
};
return advancePooledBossMechanics(source, result(source));
}
describe("shared boss mechanic pool", () => {
it("schedules a telegraphed pool mechanic and keeps its warning state independent of boss mode", () => {
const source = context(POOLED_MECHANIC_TIMING.firstAt);
const started = advancePooledBossMechanics(source, result(source));
expect(started.motion.mode).toBe("holding");
expect(started.motion.poolTelegraphs).not.toHaveLength(0);
expect(started.events[0].message).toContain(":");
expect(started.motion.nextPoolMechanicAt).toBe(Number.POSITIVE_INFINITY);
});
it("splits soak damage across allies inside its indicator", () => {
const source = context(0);
const activatesAt = 1.5;
const soak = {
id: "test-soak",
kind: "soak" as const,
name: "Aetheric Soak",
center: [0, 0] as WorldPosition,
radius: 2.5,
activatesAt,
expiresAt: activatesAt + 0.5,
damage: 0,
totalDamage: 90,
minimumParticipants: 3,
targetId: "brann" as const,
resolved: false,
hitIds: [],
};
const motion = { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [soak] };
const positions = structuredClone(POSITIONS);
positions.aelia = [0.2, 0];
positions.brann = [0, 0];
positions.nia = [-0.2, 0];
positions.vale = [0, -4];
const atImpact = { ...source, motion, partyPositions: positions, time: activatesAt };
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp - 30);
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 30);
expect(resolved.party.find((member) => member.id === "nia")!.hp).toBe(source.party[2].hp - 30);
expect(resolved.events[0].message).toContain("splits 90 damage across 3 allies");
});
it("resolves a donut only in its outer ring", () => {
const source = context(0);
const activatesAt = 1.5;
const donut = {
id: "test-donut",
kind: "donut" as const,
name: "Hollow Collapse",
center: [0, 0] as WorldPosition,
radius: 5,
innerRadius: 2,
activatesAt,
expiresAt: activatesAt + 0.5,
damage: 25,
resolved: false,
hitIds: [],
};
const positions = structuredClone(POSITIONS);
positions.aelia = [0, 0];
positions.brann = [3, 0];
const atImpact = {
...source,
motion: { ...source.motion, nextPoolMechanicAt: Number.POSITIVE_INFINITY, poolTelegraphs: [donut] },
partyPositions: positions,
time: activatesAt,
};
const resolved = advancePooledBossMechanics(atImpact, result(atImpact));
expect(resolved.party.find((member) => member.id === "aelia")!.hp).toBe(source.party[0].hp);
expect(resolved.party.find((member) => member.id === "brann")!.hp).toBe(source.party[1].hp - 25);
});
it("requires only the healer to cross four tiles in shown order", () => {
let current = advanceMemory(1, memoryTelegraph(), freshParty(), [-3, 2]);
expect(current.motion.poolTelegraphs[0].inputIndex).toBe(1);
current = advanceMemory(1.1, current.motion.poolTelegraphs[0], current.party, [0, 4.5]);
current = advanceMemory(1.2, current.motion.poolTelegraphs[0], current.party, [3, 2]);
expect(current.motion.poolTelegraphs[0].inputIndex).toBe(2);
current = advanceMemory(1.3, current.motion.poolTelegraphs[0], current.party, [0, 4.5]);
current = advanceMemory(1.4, current.motion.poolTelegraphs[0], current.party, [-3, -2]);
current = advanceMemory(1.5, current.motion.poolTelegraphs[0], current.party, [0, 4.5]);
current = advanceMemory(1.6, current.motion.poolTelegraphs[0], current.party, [3, -2]);
expect(current.motion.poolTelegraphs).toHaveLength(0);
expect(current.events[0].message).toBe("Memory Sequence cleared by healer.");
expect(current.party).toEqual(freshParty());
});
it("ignores NPC positions but deals 15 raidwide damage when healer selects a wrong tile", () => {
const waiting = advanceMemory(1, memoryTelegraph(), freshParty(), [0, 4.5]);
expect(waiting.events).toEqual([]);
expect(waiting.motion.poolTelegraphs[0].inputIndex).toBe(0);
const failed = advanceMemory(1, memoryTelegraph(), freshParty(), [3, 2]);
for (const member of failed.party) {
expect(member.hp).toBe(member.maxHp - 15);
}
expect(failed.events[0].message).toContain("15 raidwide damage");
});
it("drains the healer until they enter the opposite cleansing ward", () => {
const started = soulSiphonTelegraph();
const firstTick = advanceSoulSiphon(SOUL_SIPHON.tickInterval + 0.01, started);
expect(firstTick.party.find((member) => member.id === "aelia")!.hp).toBe(
freshParty()[0].hp - SOUL_SIPHON.tickDamage,
);
expect(firstTick.motion.poolTelegraphs[0].soulSiphon?.ghostPosition).not.toEqual(started.soulSiphon?.ghostPosition);
const wardPosition = firstTick.motion.poolTelegraphs[0].soulSiphon!.wardPosition;
const cleansed = advanceSoulSiphon(1, firstTick.motion.poolTelegraphs[0], firstTick.party, wardPosition);
expect(cleansed.motion.poolTelegraphs).toHaveLength(0);
expect(cleansed.events[0].message).toContain("cleansing ward");
});
});
+584
View File
@@ -0,0 +1,584 @@
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const BOSS_MECHANIC_POOL = [
{
id: "meteor-spread",
name: "Meteor Spread",
instruction: "Break formation before the marked circles land.",
},
{
id: "hollow-collapse",
name: "Hollow Collapse",
instruction: "Move inside the inner safe circle.",
},
{
id: "aetheric-soak",
name: "Aetheric Soak",
instruction: "Stack in the marked circle to split the hit.",
},
{
id: "prism-beam",
name: "Prism Beam",
instruction: "Clear the marked beam lane.",
},
{
id: "memory-sequence",
name: "Memory Sequence",
instruction: "Healer only: watch four symbols, then cross matching tiles in order.",
},
{
id: "soul-siphon",
name: "Soul Siphon",
instruction: "Healer only: run through the gold cleansing ward before the shade drains you.",
},
] as const;
export type PoolMechanicId = (typeof BOSS_MECHANIC_POOL)[number]["id"];
export const POOLED_MECHANIC_TIMING = {
firstAt: 15,
repeatDelay: 26,
activeDuration: 0.42,
warningDuration: 1.4,
} as const;
export const MEMORY_SEQUENCE = {
sequenceLength: 4,
flashDuration: 0.68,
inputDuration: 7,
tileSize: 2.15,
raidwideDamage: 15,
} as const;
export const SOUL_SIPHON = {
wardRadius: 1.3,
wardDistance: ARENA_RADIUS - 1.05,
ghostSpeed: 2.6,
tickInterval: 0.7,
tickDamage: 6,
tickRamp: 2,
} as const;
export const MEMORY_SYMBOLS: Record<MemorySymbolId, { label: string; color: string }> = {
triangle: { label: "Triangle", color: "#ff4d55" },
cross: { label: "Cross", color: "#4fa8ff" },
circle: { label: "Circle", color: "#65d67a" },
square: { label: "Square", color: "#ffd34d" },
};
const MEMORY_TILE_LAYOUT: readonly { symbol: MemorySymbolId; center: WorldPosition }[] = [
{ symbol: "triangle", center: [-2.35, 1.35] },
{ symbol: "cross", center: [2.35, 1.35] },
{ symbol: "circle", center: [-2.35, -3.15] },
{ symbol: "square", center: [2.35, -3.15] },
];
const MEMORY_SEQUENCES: readonly (readonly MemorySymbolId[])[] = [
["triangle", "cross", "circle", "square"],
["circle", "triangle", "square", "cross"],
["square", "circle", "cross", "triangle"],
["cross", "square", "triangle", "circle"],
];
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "vale", "brann", "aelia"];
function bossPoolOffset(bossId: string) {
let value = 0;
for (let index = 0; index < bossId.length; index += 1) value = (value + bossId.charCodeAt(index)) % BOSS_MECHANIC_POOL.length;
return value;
}
function liveTarget(party: PartyMember[], targetId: MemberId) {
return party.some((member) => member.id === targetId && member.hp > 0)
? targetId
: party.find((member) => member.hp > 0)?.id ?? targetId;
}
function circleTelegraph({
id,
kind,
name,
center,
radius,
innerRadius,
activatesAt,
damage,
targetId,
}: {
id: string;
kind: "spread" | "donut" | "soak";
name: string;
center: WorldPosition;
radius: number;
innerRadius?: number;
activatesAt: number;
damage: number;
targetId?: MemberId;
}): PoolTelegraph {
return {
id,
kind,
name,
center: [center[0], center[1]],
radius,
innerRadius,
activatesAt,
expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
damage,
targetId,
resolved: false,
hitIds: [],
};
}
function beamTelegraph(id: string, center: WorldPosition, target: WorldPosition, activatesAt: number): PoolTelegraph {
const angle = angleTo(center, target);
const halfLength = 9.2;
const dx = Math.sin(angle) * halfLength;
const dz = Math.cos(angle) * halfLength;
return {
id,
kind: "beam",
name: "Prism Beam",
center: [center[0], center[1]],
radius: 0,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: 1.45,
activatesAt,
expiresAt: activatesAt + POOLED_MECHANIC_TIMING.activeDuration,
damage: 29,
resolved: false,
hitIds: [],
};
}
function memorySequenceTelegraph(id: string, bossPosition: WorldPosition, count: number, inputStartsAt: number): PoolTelegraph {
const sequence = MEMORY_SEQUENCES[count % MEMORY_SEQUENCES.length];
return {
id,
kind: "memory",
name: "Memory Sequence",
center: [bossPosition[0], bossPosition[1]],
radius: 0,
activatesAt: inputStartsAt,
inputStartsAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration,
expiresAt: inputStartsAt + sequence.length * MEMORY_SEQUENCE.flashDuration + MEMORY_SEQUENCE.inputDuration,
damage: MEMORY_SEQUENCE.raidwideDamage,
targetId: "aelia",
sequence: [...sequence],
tiles: MEMORY_TILE_LAYOUT.map((tile) => ({ symbol: tile.symbol, center: [tile.center[0], tile.center[1]] })),
inputIndex: 0,
resolved: false,
hitIds: [],
};
}
function oppositeWardPosition(healerPosition: WorldPosition, count: number): WorldPosition {
const offsetX = healerPosition[0] - ARENA_CENTER[0];
const offsetZ = healerPosition[1] - ARENA_CENTER[1];
const offsetLength = Math.hypot(offsetX, offsetZ);
const fallbackAngle = count % 2 === 0 ? 0 : Math.PI / 2;
const directionX = offsetLength > 0.05 ? -offsetX / offsetLength : Math.sin(fallbackAngle);
const directionZ = offsetLength > 0.05 ? -offsetZ / offsetLength : Math.cos(fallbackAngle);
return clampToArena([
ARENA_CENTER[0] + directionX * SOUL_SIPHON.wardDistance,
ARENA_CENTER[1] + directionZ * SOUL_SIPHON.wardDistance,
], 0.25);
}
function soulSiphonTelegraph(id: string, healerPosition: WorldPosition, count: number, time: number): PoolTelegraph {
const wardPosition = oppositeWardPosition(healerPosition, count);
return {
id,
kind: "soul-siphon",
name: "Soul Siphon",
center: [wardPosition[0], wardPosition[1]],
radius: 0,
activatesAt: time,
expiresAt: Number.POSITIVE_INFINITY,
damage: 0,
targetId: "aelia",
soulSiphon: {
targetId: "aelia",
ghostPosition: clampToArena([healerPosition[0] - 0.85, healerPosition[1] + 0.85], 0.2),
wardPosition,
wardRadius: SOUL_SIPHON.wardRadius,
nextDamageAt: time + SOUL_SIPHON.tickInterval,
tickCount: 0,
},
resolved: false,
hitIds: [],
};
}
function beginPoolMechanic(
motion: BossMotionState,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
time: number,
) {
const count = motion.poolMechanicCount + 1;
const entry = BOSS_MECHANIC_POOL[(bossPoolOffset(motion.bossId) + motion.poolMechanicCount) % BOSS_MECHANIC_POOL.length];
const activatesAt = time + POOLED_MECHANIC_TIMING.warningDuration;
let telegraphs: PoolTelegraph[];
let targetId: MemberId | undefined;
if (entry.id === "meteor-spread") {
const targets = [0, 1, 2].map((offset) => liveTarget(party, TARGET_ORDER[(count + offset) % TARGET_ORDER.length]));
telegraphs = targets.map((id, index) => circleTelegraph({
id: `pool-spread-${count}-${index}`,
kind: "spread",
name: "Meteor Spread",
center: positions[id],
radius: 1.75,
activatesAt: activatesAt + index * 0.42,
damage: 24,
targetId: id,
}));
targetId = targets[0];
} else if (entry.id === "hollow-collapse") {
telegraphs = [circleTelegraph({
id: `pool-donut-${count}`,
kind: "donut",
name: "Hollow Collapse",
center: motion.position,
radius: 7.25,
innerRadius: 2.15,
activatesAt,
damage: 27,
})];
} else if (entry.id === "aetheric-soak") {
targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
const soak = circleTelegraph({
id: `pool-soak-${count}`,
kind: "soak",
name: "Aetheric Soak",
center: positions[targetId],
radius: 2.25,
activatesAt: time + 1.75,
damage: 0,
targetId,
});
soak.totalDamage = 78;
soak.minimumParticipants = 3;
telegraphs = [soak];
} else if (entry.id === "memory-sequence") {
targetId = "aelia";
telegraphs = [memorySequenceTelegraph(`pool-memory-${count}`, motion.position, count, time)];
} else if (entry.id === "soul-siphon") {
targetId = "aelia";
telegraphs = [soulSiphonTelegraph(`pool-soul-siphon-${count}`, positions.aelia, count, time)];
} else {
targetId = liveTarget(party, TARGET_ORDER[count % TARGET_ORDER.length]);
telegraphs = [beamTelegraph(`pool-beam-${count}`, motion.position, positions[targetId], activatesAt)];
}
return {
motion: {
...motion,
poolMechanicCount: count,
nextPoolMechanicAt: Number.POSITIVE_INFINITY,
poolTelegraphs: telegraphs,
},
event: {
at: time,
message: `${entry.name}: ${entry.instruction}`,
tone: "danger" as const,
pulseKind: "boss" as const,
targetId,
},
};
}
function memoryTileAt(telegraph: PoolTelegraph, position: WorldPosition) {
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
return telegraph.tiles?.find((tile) =>
Math.abs(position[0] - tile.center[0]) <= halfSize && Math.abs(position[1] - tile.center[1]) <= halfSize,
);
}
function failMemorySequence(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: `${telegraph.name} fails — every party member takes ${telegraph.damage} raidwide damage.`,
tone: "danger",
pulseKind: "boss",
targetId: "aelia",
});
return party.map((member) => member.hp > 0
? context.damageMember(member, telegraph.damage, positions[member.id], context.time)
: member);
}
function resolveMemorySequence(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined || telegraph.inputIndex === undefined) {
return failMemorySequence(telegraph, party, positions, context, events);
}
if (context.time < telegraph.inputStartsAt) return party;
if (context.time >= telegraph.expiresAt) return failMemorySequence(telegraph, party, positions, context, events);
const selectedTile = memoryTileAt(telegraph, positions.aelia);
if (!selectedTile) {
telegraph.lastHealerTileId = undefined;
} else if (selectedTile.symbol !== telegraph.lastHealerTileId) {
telegraph.lastHealerTileId = selectedTile.symbol;
if (selectedTile.symbol !== telegraph.sequence[telegraph.inputIndex]) {
return failMemorySequence(telegraph, party, positions, context, events);
}
telegraph.inputIndex += 1;
if (telegraph.inputIndex === telegraph.sequence.length) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: "Memory Sequence cleared by healer.",
tone: "neutral",
pulseKind: "boss",
targetId: "aelia",
});
return party;
}
}
return party;
}
function resolveSoulSiphon(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const siphon = telegraph.soulSiphon;
if (!siphon) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
return party;
}
const healerPosition = positions[siphon.targetId];
if (distance(healerPosition, siphon.wardPosition) <= siphon.wardRadius) {
telegraph.resolved = true;
telegraph.expiresAt = context.time;
events.push({
at: context.time,
message: "Aelia reaches the cleansing ward. Soul Siphon collapses.",
tone: "neutral",
pulseKind: "purify",
targetId: "aelia",
});
return party;
}
const nextSiphon = {
...siphon,
ghostPosition: moveToward(siphon.ghostPosition, healerPosition, SOUL_SIPHON.ghostSpeed * context.delta),
};
let nextParty = party;
const healerIndex = nextParty.findIndex((member) => member.id === siphon.targetId);
if (healerIndex >= 0 && nextParty[healerIndex].hp > 0) {
let nextDamageAt = nextSiphon.nextDamageAt;
let tickCount = nextSiphon.tickCount;
let healer = nextParty[healerIndex];
while (nextDamageAt <= context.time + 0.001) {
healer = context.damageMember(
healer,
SOUL_SIPHON.tickDamage + Math.min(tickCount, 4) * SOUL_SIPHON.tickRamp,
healerPosition,
nextDamageAt,
);
nextDamageAt += SOUL_SIPHON.tickInterval;
tickCount += 1;
}
nextSiphon.nextDamageAt = nextDamageAt;
nextSiphon.tickCount = tickCount;
if (healer !== nextParty[healerIndex]) {
nextParty = [...nextParty];
nextParty[healerIndex] = healer;
}
}
telegraph.soulSiphon = nextSiphon;
return nextParty;
}
function isHit(telegraph: PoolTelegraph, position: WorldPosition) {
if (telegraph.kind === "beam") {
return !!telegraph.start && !!telegraph.end
&& pointToSegmentDistance(position, telegraph.start, telegraph.end) <= (telegraph.width ?? 0) * 0.5;
}
const distance = Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]);
return distance <= telegraph.radius && distance >= (telegraph.innerRadius ?? 0);
}
function resolveSoak(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const participants = party.filter((member) => member.hp > 0 && isHit(telegraph, positions[member.id]));
const minimum = telegraph.minimumParticipants ?? 1;
const failed = participants.length < minimum;
const totalDamage = (telegraph.totalDamage ?? 0) * (failed ? 1.5 : 1);
if (!participants.length) {
const marked = party.find((member) => member.id === telegraph.targetId && member.hp > 0);
if (!marked) return party;
events.push({
at: context.time,
message: `${telegraph.name} fails — ${marked.name} takes ${Math.round(totalDamage)} damage alone.`,
tone: "danger",
pulseKind: "boss",
targetId: marked.id,
});
return party.map((member) => member.id === marked.id
? context.damageMember(member, totalDamage, positions[member.id], context.time)
: member);
}
const splitDamage = totalDamage / participants.length;
events.push({
at: context.time,
message: failed
? `${telegraph.name} is under-soaked by ${participants.length}. ${Math.round(splitDamage)} damage each.`
: `${telegraph.name} splits ${Math.round(totalDamage)} damage across ${participants.length} allies.`,
tone: "danger",
pulseKind: "boss",
targetId: telegraph.targetId,
});
const participantIds = new Set(participants.map((member) => member.id));
return party.map((member) => participantIds.has(member.id)
? context.damageMember(member, splitDamage, positions[member.id], context.time)
: member);
}
function resolveTelegraph(
telegraph: PoolTelegraph,
party: PartyMember[],
positions: BossMechanicContext["partyPositions"],
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
if (telegraph.kind === "soak") return resolveSoak(telegraph, party, positions, context, events);
const hitIds: MemberId[] = [];
const next = party.map((member) => {
if (member.hp <= 0 || !isHit(telegraph, positions[member.id])) return member;
hitIds.push(member.id);
return context.damageMember(member, telegraph.damage, positions[member.id], context.time, "hazard");
});
telegraph.hitIds = hitIds;
events.push({
at: context.time,
message: hitIds.length
? `${telegraph.name} catches ${hitIds.length} ${hitIds.length === 1 ? "ally" : "allies"}.`
: `${telegraph.name} misses the party.`,
tone: "danger",
pulseKind: "boss",
targetId: telegraph.targetId,
});
return next;
}
/** Composes the shared mechanic pool after a boss's signature mechanic update. */
export function advancePooledBossMechanics(
context: BossMechanicContext,
result: BossMechanicResult,
): BossMechanicResult {
if (context.allowPooledMechanics === false) return result;
const source = result.motion;
if (!source.poolTelegraphs.length && context.time < source.nextPoolMechanicAt) return result;
let motion: BossMotionState = {
...source,
poolTelegraphs: source.poolTelegraphs.map((telegraph) => ({
...telegraph,
center: [telegraph.center[0], telegraph.center[1]],
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
soulSiphon: telegraph.soulSiphon && {
...telegraph.soulSiphon,
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
},
hitIds: [...telegraph.hitIds],
})),
};
let party = result.party;
const events = [...result.events];
for (const telegraph of motion.poolTelegraphs) {
if (telegraph.kind === "memory") {
if (!telegraph.resolved) party = resolveMemorySequence(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.kind === "soul-siphon") {
if (!telegraph.resolved) party = resolveSoulSiphon(telegraph, party, context.partyPositions, context, events);
continue;
}
if (telegraph.resolved || context.time < telegraph.activatesAt) continue;
party = resolveTelegraph(telegraph, party, context.partyPositions, context, events);
telegraph.resolved = true;
}
motion.poolTelegraphs = motion.poolTelegraphs.filter((telegraph) => telegraph.expiresAt > context.time);
if (!motion.poolTelegraphs.length && context.time >= motion.nextPoolMechanicAt) {
const started = beginPoolMechanic(motion, party, context.partyPositions, context.time);
motion = started.motion;
events.push(started.event);
}
if (!motion.poolTelegraphs.length && !Number.isFinite(motion.nextPoolMechanicAt)) {
motion.nextPoolMechanicAt = context.time + POOLED_MECHANIC_TIMING.repeatDelay;
}
return { ...result, motion, party, events };
}
export function upcomingPooledMechanic(motion: BossMotionState, time: number): UpcomingMechanic | null {
const telegraphs = motion.poolTelegraphs.filter((telegraph) => !telegraph.resolved && telegraph.expiresAt > time);
if (!telegraphs.length) return null;
const next = telegraphs.reduce((earliest, telegraph) => telegraph.activatesAt < earliest.activatesAt ? telegraph : earliest);
if (next.kind === "memory") {
const inputStartsAt = next.inputStartsAt ?? next.activatesAt;
const showingSequence = time < inputStartsAt;
return {
name: showingSequence ? "Memory Sequence — watch boss" : "Memory Sequence — match tiles",
remaining: Math.max(0, (showingSequence ? inputStartsAt : next.expiresAt) - time),
cycle: showingSequence ? Math.max(0.01, inputStartsAt - next.activatesAt) : MEMORY_SEQUENCE.inputDuration,
urgent: true,
};
}
if (next.kind === "soul-siphon" && next.soulSiphon) {
return {
name: "Soul Siphon — reach cleansing ward",
remaining: Math.max(0, next.soulSiphon.nextDamageAt - time),
cycle: SOUL_SIPHON.tickInterval,
urgent: true,
};
}
return {
name: next.kind === "soak" ? `${next.name} — stack` : next.kind === "donut" ? `${next.name} — move in` : next.kind === "spread" ? `${next.name} — spread` : `${next.name} — clear lane`,
remaining: Math.max(0, next.activatesAt - time),
cycle: Math.max(0.01, next.activatesAt - (next.activatesAt - POOLED_MECHANIC_TIMING.warningDuration)),
urgent: true,
};
}
+5 -6
View File
@@ -1,7 +1,7 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
import { angleTo, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const MOURNVEIL = {
@@ -113,8 +113,7 @@ export function advanceMournveilMechanics(context: BossMechanicContext): BossMec
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.7 * context.delta);
returnBossToArenaCenter(motion, context.delta, 1.7);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ghost_soul_cross" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
@@ -145,11 +144,11 @@ export function advanceMournveilMechanics(context: BossMechanicContext): BossMec
return { boss, motion, party, events };
}
export function upcomingMournveilMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
export function upcomingMournveilMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ghost_soul_cross") return { name: "Vine Scissors — first cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.crossWarning, urgent: true };
if (motion.mode === "ghost_soul_cross_followup") return { name: "Vine Scissors — rotated cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.followupWarning, urgent: true };
if (motion.mode === "ghost_haunting") return { name: "Haunting Rifts — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.riftWarning, urgent: true };
if (motion.mode === "ghost_recover") return { name: "Pumpking exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
if (motion.mode === "ghost_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Vine Scissors" : "Haunting Rifts", remaining, cycle: MOURNVEIL.repeatDelay + MOURNVEIL.crossWarning, urgent: remaining < 2.5 };
}
+9 -10
View File
@@ -1,8 +1,8 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossId, BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const OBSIDIAN_RAM = {
@@ -24,8 +24,8 @@ export const OBSIDIAN_RAM = {
const TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
export function createObsidianRamState(): BossState {
const definition = BOSS_DEFINITIONS["obsidian-ram-golem"];
export function createObsidianRamState(bossId: BossId = "bristlequake-boar"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: definition.id,
name: definition.name,
@@ -38,8 +38,8 @@ export function createObsidianRamState(): BossState {
};
}
export function createObsidianRamMotion(): BossMotionState {
return { ...createBaseMotion("obsidian-ram-golem"), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
export function createObsidianRamMotion(bossId: BossId = "bristlequake-boar"): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
}
function laneAt(center: WorldPosition, angle: number, id: string): SlashLane {
@@ -142,8 +142,7 @@ export function advanceObsidianRamMechanics(context: BossMechanicContext): BossM
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * context.delta);
returnBossToArenaCenter(motion, context.delta, 1.8);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ram_charge_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_charging", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / OBSIDIAN_RAM.chargeSpeed };
@@ -172,11 +171,11 @@ export function advanceObsidianRamMechanics(context: BossMechanicContext): BossM
return { boss, motion, party, events };
}
export function upcomingObsidianRamMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
export function upcomingObsidianRamMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ram_charge_telegraph" || motion.mode === "ram_charging") return { name: "Destruction Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.chargeWarning, urgent: true };
if (motion.mode === "ram_quake") return { name: "Ruin Quake — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.quakeWarning, urgent: true };
if (motion.mode === "ram_shatter") return { name: "Destruction Pulse — find gap", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.shatterWarning, urgent: true };
if (motion.mode === "ram_recover") return { name: "Gandora exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
if (motion.mode === "ram_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
const names = ["Destruction Rush", "Ruin Quake", "Destruction Pulse"];
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: names[motion.mechanicCount % 3], remaining, cycle: OBSIDIAN_RAM.repeatDelay + OBSIDIAN_RAM.chargeWarning, urgent: remaining < 2.5 };
@@ -1,8 +1,8 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossId, BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CINDERBACK = {
@@ -24,13 +24,13 @@ export const CINDERBACK = {
const TARGETS: readonly MemberId[] = ["orin", "nia", "aelia", "vale", "brann"];
export function createCinderbackState(): BossState {
const definition = BOSS_DEFINITIONS["cinderback-ricochet"];
export function createCinderbackState(bossId: BossId = "emberfox"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.4, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createCinderbackMotion(): BossMotionState {
return { ...createBaseMotion("cinderback-ricochet"), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
export function createCinderbackMotion(bossId: BossId = "emberfox"): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
}
function rushEnd(start: WorldPosition, target: WorldPosition) {
@@ -53,15 +53,14 @@ export function advanceCinderbackMechanics(context: BossMechanicContext): BossMe
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2 * context.delta);
returnBossToArenaCenter(motion, context.delta, 2);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = rushEnd(motion.position, context.partyPositions[targetId]);
motion = { ...motion, mode: "cinderback_curl", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], chargeCount: 0, phaseStartedAt: context.time, phaseEndsAt: context.time + CINDERBACK.curlWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [rushLane(`ricochet-${count}-0`, motion.position, end)] };
events.push({ at: context.time, message: `Red-Eyes dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
events.push({ at: context.time, message: `${boss.name} dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + CINDERBACK.slamWarning;
const pools = [0, 1, 2].map((index) => {
@@ -69,7 +68,7 @@ export function advanceCinderbackMechanics(context: BossMechanicContext): BossMe
return lavaPool(`slam-lava-${count}-${index}`, clampToArena([motion.position[0] + Math.sin(angle) * 3.7, motion.position[1] + Math.cos(angle) * 3.7]), activatesAt);
});
motion = { ...motion, mode: "cinderback_slam", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.25, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, { id: `armor-slam-${count}`, kind: "quake", center: [...motion.position], radius: CINDERBACK.slamRadius, activatesAt, expiresAt: activatesAt + 0.3, damage: CINDERBACK.slamDamage, nextDamageAt: {}, resolved: false, hitIds: [] }, ...pools] };
events.push({ at: context.time, message: "Meteor Slam! Leave Red-Eyes and spreading black flame.", tone: "danger", pulseKind: "boss" });
events.push({ at: context.time, message: "Meteor slam! Clear the spreading flame.", tone: "danger", pulseKind: "boss" });
}
}
} else if (motion.mode === "cinderback_curl" && context.time >= motion.phaseEndsAt) {
@@ -89,7 +88,7 @@ export function advanceCinderbackMechanics(context: BossMechanicContext): BossMe
const start = [...motion.chargeEnd] as WorldPosition;
const end = rushEnd(start, context.partyPositions[targetId]);
motion = { ...motion, position: start, chargeStart: start, chargeEnd: end, chargeTargetId: targetId, chargeHitIds: [], chargeCount: 1, phaseEndsAt: context.time + distance(start, end) / CINDERBACK.speed, slashLanes: [rushLane(`ricochet-${motion.mechanicCount}-1`, start, end)] };
events.push({ at: context.time, message: `Inferno Rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
events.push({ at: context.time, message: `Ricochet rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
} else {
motion = { ...motion, position: [...motion.chargeEnd], mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
}
@@ -105,11 +104,11 @@ export function advanceCinderbackMechanics(context: BossMechanicContext): BossMe
return { boss, motion, party, events };
}
export function upcomingCinderbackMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "cinderback_curl") return { name: "Inferno Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
export function upcomingCinderbackMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "cinderback_curl") return { name: "Ricochet Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
if (motion.mode === "cinderback_ricochet") return { name: motion.chargeCount === 0 ? "First rebound" : "Second rebound", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
if (motion.mode === "cinderback_slam") return { name: "Meteor Slam — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.slamWarning, urgent: true };
if (motion.mode === "cinderback_recover") return { name: "Red-Eyes exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
if (motion.mode === "cinderback_recover") return { name: `${boss.name} exposed`, remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Inferno Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
return { name: motion.mechanicCount % 2 === 0 ? "Ricochet Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
}
+2 -3
View File
@@ -2,7 +2,7 @@ import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const SANDGLASS = {
@@ -50,8 +50,7 @@ export function advanceSandglassMechanics(context: BossMechanicContext): BossMec
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
returnBossToArenaCenter(motion, context.delta, 2.1);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
+40 -1
View File
@@ -1,5 +1,6 @@
import { distance } from "../geometry";
import type { BossId, BossMotionState, BossState, CircleHazard, CircleHazardKind, MemberId, PartyMember, WorldPosition } from "../types";
import { ARENA_CENTER } from "../arena";
import type { BossId, BossMotionState, BossState, CircleHazard, CircleHazardKind, MemberId, PartyMember, PoolTelegraph, WorldPosition } from "../types";
import type { BossMechanicContext, BossMechanicEvent } from "./types";
const PERSISTENT_HAZARD_KINDS = new Set<CircleHazardKind>(["venom_pool", "lava_pool", "hourglass", "soul_rift"]);
@@ -16,6 +17,28 @@ const HAZARD_LABELS = {
royal_shockwave: "Royal Shockwave",
} as const;
/**
* Standard idle location after a mechanic. Dual encounters keep distinct
* center-adjacent slots through formationOffsetX instead of drifting to a
* displaced tank at the edge of the room.
*/
export function returnBossToArenaCenter(motion: BossMotionState, delta: number, speed: number) {
const targetX = ARENA_CENTER[0] + motion.formationOffsetX;
const targetZ = ARENA_CENTER[1];
const dx = targetX - motion.position[0];
const dz = targetZ - motion.position[1];
const remaining = Math.hypot(dx, dz);
const step = speed * delta;
if (remaining < 0.0001 || remaining <= step) {
motion.position[0] = targetX;
motion.position[1] = targetZ;
return true;
}
motion.position[0] += (dx / remaining) * step;
motion.position[1] += (dz / remaining) * step;
return false;
}
export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState {
return {
id: bossId,
@@ -95,6 +118,9 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
breathEndAngle: 0,
hazards: [],
slashLanes: [],
nextPoolMechanicAt: 15,
poolMechanicCount: 0,
poolTelegraphs: [],
};
}
@@ -120,6 +146,19 @@ export function cloneMotion(source: BossMotionState): BossMotionState {
start: [lane.start[0], lane.start[1]],
end: [lane.end[0], lane.end[1]],
})),
poolTelegraphs: source.poolTelegraphs.map((telegraph): PoolTelegraph => ({
...telegraph,
center: [telegraph.center[0], telegraph.center[1]],
start: telegraph.start && [telegraph.start[0], telegraph.start[1]],
end: telegraph.end && [telegraph.end[0], telegraph.end[1]],
tiles: telegraph.tiles?.map((tile) => ({ ...tile, center: [tile.center[0], tile.center[1]] })),
soulSiphon: telegraph.soulSiphon && {
...telegraph.soulSiphon,
ghostPosition: [telegraph.soulSiphon.ghostPosition[0], telegraph.soulSiphon.ghostPosition[1]],
wardPosition: [telegraph.soulSiphon.wardPosition[0], telegraph.soulSiphon.wardPosition[1]],
},
hitIds: [...telegraph.hitIds],
})),
};
}
@@ -1,10 +1,10 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointInCone } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards } from "./shared";
import { angleTo, pointInCone } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CINDER_BREATH = {
export const SKY_SWEEPER_BREATH = {
firstAt: 6,
telegraphDuration: 2,
sweepDuration: 3.2,
@@ -15,7 +15,7 @@ export const CINDER_BREATH = {
tickInterval: 0.45,
} as const;
export const CINDER_SKYFALL = {
export const SKY_SWEEPER_SKYFALL = {
warning: 2,
stagger: 0.9,
radius: 1.8,
@@ -29,10 +29,10 @@ const SKYFALL_TARGETS: readonly MemberId[][] = [
["vale", "nia", "aelia"],
];
export function createCindermawState(): BossState {
const definition = BOSS_DEFINITIONS.cindermaw;
export function createSkySweeperState(bossId: BossId): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: "cindermaw",
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
@@ -43,11 +43,11 @@ export function createCindermawState(): BossState {
};
}
export function createCindermawMotion(): BossMotionState {
return { ...createBaseMotion("cindermaw"), position: [0, -2.8], nextMechanicAt: CINDER_BREATH.firstAt };
export function createSkySweeperMotion(bossId: BossId): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -2.8], nextMechanicAt: SKY_SWEEPER_BREATH.firstAt };
}
export function advanceCindermawMechanics(context: BossMechanicContext): BossMechanicResult {
export function advanceSkySweeperMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
@@ -56,41 +56,40 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.9 * context.delta);
returnBossToArenaCenter(motion, context.delta, 1.9);
}
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
if (motion.mechanicCount % 2 === 0) {
const aimedAngle = angleTo(motion.position, context.partyPositions.brann);
const direction = motion.mechanicCount % 4 === 0 ? 1 : -1;
const startAngle = aimedAngle - direction * CINDER_BREATH.sweepArc * 0.5;
const startAngle = aimedAngle - direction * SKY_SWEEPER_BREATH.sweepArc * 0.5;
motion = {
...motion,
mode: "breath_telegraph",
phaseStartedAt: context.time,
phaseEndsAt: context.time + CINDER_BREATH.telegraphDuration,
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.telegraphDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
breathAngle: startAngle,
breathStartAngle: startAngle,
breathEndAngle: startAngle + direction * CINDER_BREATH.sweepArc,
breathEndAngle: startAngle + direction * SKY_SWEEPER_BREATH.sweepArc,
mechanicHitIds: [],
mechanicNextDamageAt: {},
};
events.push({ at: context.time, message: "Blue-Eyes draws a sweeping Burst Stream. Rotate behind it!", tone: "danger", pulseKind: "breath" });
events.push({ at: context.time, message: `${boss.name} gathers a sweeping storm breath. Rotate behind it!`, tone: "danger", pulseKind: "breath" });
} else {
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
const hazards = set.map((memberId, index) => {
const activatesAt = context.time + CINDER_SKYFALL.warning + index * CINDER_SKYFALL.stagger;
const activatesAt = context.time + SKY_SWEEPER_SKYFALL.warning + index * SKY_SWEEPER_SKYFALL.stagger;
return {
id: `skyfall-${motion.mechanicCount}-${index}`,
kind: "skyfall" as const,
center: [context.partyPositions[memberId][0], context.partyPositions[memberId][1]] as [number, number],
radius: CINDER_SKYFALL.radius,
radius: SKY_SWEEPER_SKYFALL.radius,
activatesAt,
expiresAt: activatesAt + CINDER_SKYFALL.fireDuration,
damage: CINDER_SKYFALL.damage,
expiresAt: activatesAt + SKY_SWEEPER_SKYFALL.fireDuration,
damage: SKY_SWEEPER_SKYFALL.damage,
nextDamageAt: {},
resolved: false,
hitIds: [],
@@ -105,23 +104,23 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
mechanicCount: motion.mechanicCount + 1,
hazards: [...motion.hazards, ...hazards],
};
events.push({ at: context.time, message: "Blue-Eyes takes flight. Three White Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
events.push({ at: context.time, message: `${boss.name} takes flight. Three skyfalls incoming!`, tone: "danger", pulseKind: "skyfall", targetId: set[0] });
}
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "breath_sweeping",
phaseStartedAt: context.time,
phaseEndsAt: context.time + CINDER_BREATH.sweepDuration,
phaseEndsAt: context.time + SKY_SWEEPER_BREATH.sweepDuration,
breathAngle: motion.breathStartAngle,
};
events.push({ at: context.time, message: "Burst Stream crosses the arena!", tone: "danger", pulseKind: "breath" });
events.push({ at: context.time, message: "Storm breath crosses the arena!", tone: "danger", pulseKind: "breath" });
} else if (motion.mode === "breath_sweeping") {
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / CINDER_BREATH.sweepDuration));
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / SKY_SWEEPER_BREATH.sweepDuration));
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
party = party.map((member) => {
if (member.hp <= 0) return member;
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, CINDER_BREATH.halfAngle, CINDER_BREATH.range);
const exposed = pointInCone(context.partyPositions[member.id], motion.position, motion.breathAngle, SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.range);
if (!exposed) {
motion.mechanicNextDamageAt[member.id] = context.time;
return member;
@@ -129,13 +128,13 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
let next = member;
let tickAt = motion.mechanicNextDamageAt[member.id] ?? motion.phaseStartedAt;
while (tickAt <= context.time + 0.001) {
next = context.damageMember(next, CINDER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
tickAt += CINDER_BREATH.tickInterval;
next = context.damageMember(next, SKY_SWEEPER_BREATH.tickDamage, context.partyPositions[member.id], tickAt);
tickAt += SKY_SWEEPER_BREATH.tickInterval;
}
motion.mechanicNextDamageAt[member.id] = tickAt;
if (!motion.mechanicHitIds.includes(member.id)) {
motion.mechanicHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is struck by Burst Stream.`, tone: "danger", pulseKind: "breath", targetId: member.id });
events.push({ at: context.time, message: `${member.name} is struck by storm breath.`, tone: "danger", pulseKind: "breath", targetId: member.id });
}
return next;
});
@@ -150,12 +149,12 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
return { boss, motion, party, events };
}
export function upcomingCindermawMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
export function upcomingSkySweeperMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "breath_telegraph") return { name: "Burst Stream", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.sweepDuration, urgent: true };
if (motion.mode === "skyfall") return { name: "White Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
if (motion.mode === "breath_telegraph") return { name: "Storm Breath", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_BREATH.sweepDuration, urgent: true };
if (motion.mode === "skyfall") return { name: "Stormfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SKY_SWEEPER_SKYFALL.warning + SKY_SWEEPER_SKYFALL.stagger * 2, urgent: true };
const nextIsBreath = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsBreath ? "Burst Stream" : "White Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
return { name: nextIsBreath ? "Storm Breath" : "Stormfall", remaining, cycle: 8, urgent: remaining < 2.5 };
}
+2
View File
@@ -22,6 +22,8 @@ export interface BossMechanicContext {
partyPositions: Record<MemberId, WorldPosition>;
time: number;
delta: number;
/** Shared pool mechanics are reserved for solo encounters to avoid unreadable overlap in multi-boss fights. */
allowPooledMechanics?: boolean;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
}
+11 -12
View File
@@ -1,7 +1,7 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { distance, moveToward } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import { distance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const VEXA_TETHER = {
@@ -32,10 +32,10 @@ const VENOM_TARGETS: readonly MemberId[][] = [
["aelia", "vale"],
];
export function createVexaState(): BossState {
const definition = BOSS_DEFINITIONS.vexa;
export function createVexaState(bossId: BossId = "broodfang-spider"): BossState {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: "vexa",
id: bossId,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
@@ -46,8 +46,8 @@ export function createVexaState(): BossState {
};
}
export function createVexaMotion(): BossMotionState {
return { ...createBaseMotion("vexa"), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
export function createVexaMotion(bossId: BossId = "broodfang-spider"): BossMotionState {
return { ...createBaseMotion(bossId), position: [0, -7.4], nextMechanicAt: VEXA_TETHER.firstAt };
}
export function advanceVexaMechanics(context: BossMechanicContext): BossMechanicResult {
@@ -59,8 +59,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
returnBossToArenaCenter(motion, context.delta, 2.1);
}
if (motion.mode === "holding" && context.time >= motion.nextMechanicAt) {
@@ -78,7 +77,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
tetherBreakDistance: VEXA_TETHER.breakDistance,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: `Insect Queen binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
events.push({ at: context.time, message: `${boss.name} binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
}
} else {
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
@@ -103,7 +102,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: "Insect Queen injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
events.push({ at: context.time, message: `${boss.name} injects Widow Venom. Move away before cleansing!`, tone: "danger", pulseKind: "venom", targetId: targets[0] });
}
} else if (motion.mode === "tethering") {
const [first, second] = motion.tetherIds;
+9 -1
View File
@@ -1,10 +1,18 @@
import { describe, expect, it } from "vitest";
import { createBaseMotion } from "./bosses/shared";
import { ARENA_CENTER } from "./arena";
import { createBaseMotion, returnBossToArenaCenter } from "./bosses/shared";
import { freshParty } from "./data";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import type { MemberId, WorldPosition } from "./types";
describe("party boss positioning", () => {
it("brings each boss back to its center slot instead of following an edge-displaced tank", () => {
const motion = { ...createBaseMotion("bulldrome"), formationOffsetX: 2.65, position: [7, 5] as WorldPosition };
expect(returnBossToArenaCenter(motion, 10, 2)).toBe(true);
expect(motion.position).toEqual([ARENA_CENTER[0] + 2.65, ARENA_CENTER[1]]);
});
it("places Brann in front of the boss and Vale behind it", () => {
const formation = combatFormation([2, -1]);
+82 -9
View File
@@ -1,6 +1,6 @@
import { BULL_CHARGE } from "./bossMechanics";
import { clampToArena } from "./arena";
import { CINDER_BREATH } from "./bosses/cindermaw";
import { SKY_SWEEPER_BREATH } from "./bosses/skySweeper";
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
@@ -87,6 +87,21 @@ export const stackForPounceBehavior: PartyBehavior = {
},
};
export const stackForPooledSoakBehavior: PartyBehavior = {
id: "stack-for-pooled-soak",
decide: ({ memberId, bossMotion, time }) => {
const soak = bossMotion.poolTelegraphs.find((telegraph) =>
telegraph.kind === "soak" && !telegraph.resolved && telegraph.activatesAt > time && telegraph.activatesAt - time <= 2.2,
);
if (!soak) return null;
const offset = STACK_OFFSETS[memberId];
return {
target: [soak.center[0] + offset[0], soak.center[1] + offset[1]],
speed: 3.8,
};
},
};
export const breakTetherBehavior: PartyBehavior = {
id: "break-tether",
decide: ({ memberId, current, bossMotion, partyPositions }) => {
@@ -131,7 +146,7 @@ export const avoidBreathBehavior: PartyBehavior = {
decide: ({ memberId, bossMotion }) => {
if (bossMotion.mode !== "breath_telegraph" && bossMotion.mode !== "breath_sweeping") return null;
const side = EVADE_SIDES[memberId];
const safeAngle = bossMotion.breathAngle + side * (CINDER_BREATH.halfAngle + Math.PI * 0.42);
const safeAngle = bossMotion.breathAngle + side * (SKY_SWEEPER_BREATH.halfAngle + Math.PI * 0.42);
const radius = memberId === "brann" ? 4.1 : memberId === "vale" ? 3.5 : 5.4;
return {
target: clampToArena([
@@ -164,9 +179,10 @@ export const evadeSlashLanesBehavior: PartyBehavior = {
origin[0] + Math.sin(angle) * radius,
origin[1] + Math.cos(angle) * radius,
]);
const laneDistance = Math.min(...bossMotion.slashLanes.map((lane) =>
pointToSegmentDistance(candidate, lane.start, lane.end),
));
let laneDistance = Number.POSITIVE_INFINITY;
for (const lane of bossMotion.slashLanes) {
laneDistance = Math.min(laneDistance, pointToSegmentDistance(candidate, lane.start, lane.end));
}
const travelPenalty = Math.hypot(candidate[0] - current[0], candidate[1] - current[1]) * 0.08;
const score = laneDistance - travelPenalty;
if (score > bestScore) {
@@ -197,6 +213,57 @@ export const avoidCircleHazardsBehavior: PartyBehavior = {
},
};
export const reactToPooledTelegraphsBehavior: PartyBehavior = {
id: "react-to-pooled-telegraphs",
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
for (const telegraph of bossMotion.poolTelegraphs) {
if (telegraph.resolved || telegraph.kind === "soak" || telegraph.kind === "memory" || telegraph.activatesAt - time > 2.2) continue;
const fallbackAngle = (AI_MEMBER_IDS.indexOf(memberId) / AI_MEMBER_IDS.length) * Math.PI * 2;
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
const clearance = (telegraph.width ?? 0) * 0.5 + 0.7;
const currentUnsafe = pointToSegmentDistance(current, telegraph.start, telegraph.end) < clearance;
const formationUnsafe = pointToSegmentDistance(formationTarget, telegraph.start, telegraph.end) < clearance;
if (!currentUnsafe && !formationUnsafe) continue;
const source = formationUnsafe ? formationTarget : current;
return {
target: clampToArena(pointOutsideLane(source, telegraph.start, telegraph.end, clearance, EVADE_SIDES[memberId])),
speed: 4.8,
};
}
const distanceFromCenter = (position: WorldPosition) => Math.hypot(position[0] - telegraph.center[0], position[1] - telegraph.center[1]);
if (telegraph.kind === "donut") {
const innerSafeRadius = Math.max(0.5, (telegraph.innerRadius ?? 0) - 0.45);
const isUnsafe = (position: WorldPosition) => {
const distance = distanceFromCenter(position);
return distance >= innerSafeRadius && distance <= telegraph.radius + 0.45;
};
if (!isUnsafe(current) && !isUnsafe(formationTarget)) continue;
const source = isUnsafe(formationTarget) ? formationTarget : current;
const sourceDistance = distanceFromCenter(source);
const direction = sourceDistance > 0.01
? [(source[0] - telegraph.center[0]) / sourceDistance, (source[1] - telegraph.center[1]) / sourceDistance] as WorldPosition
: [Math.sin(fallbackAngle), Math.cos(fallbackAngle)] as WorldPosition;
return {
target: clampToArena([
telegraph.center[0] + direction[0] * innerSafeRadius,
telegraph.center[1] + direction[1] * innerSafeRadius,
]),
speed: 4.3,
};
}
const clearance = telegraph.radius + 0.55;
const currentUnsafe = distanceFromCenter(current) < clearance;
const formationUnsafe = distanceFromCenter(formationTarget) < clearance;
if (!currentUnsafe && !formationUnsafe) continue;
const source = formationUnsafe ? formationTarget : current;
return { target: clampToArena(pointOutsideCircle(source, telegraph.center, clearance, fallbackAngle)), speed: 4.1 };
}
return null;
},
};
export const maintainFormationBehavior: PartyBehavior = {
id: "maintain-formation",
decide: ({ formationTarget, bossMotion, memberId }) => {
@@ -208,9 +275,11 @@ export const maintainFormationBehavior: PartyBehavior = {
export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
breakTetherBehavior,
stackForPounceBehavior,
stackForPooledSoakBehavior,
evadeChargeBehavior,
evadeSlashLanesBehavior,
avoidBreathBehavior,
reactToPooledTelegraphsBehavior,
avoidCircleHazardsBehavior,
maintainFormationBehavior,
];
@@ -234,10 +303,14 @@ export function updatePartyPositions(
vale: [current.vale[0], current.vale[1]],
};
if (!activeMotions.length) return next;
const formationOrigin: WorldPosition = [
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[0] : motion.position[0]), 0) / activeMotions.length,
activeMotions.reduce((sum, motion) => sum + (DASH_MODES.includes(motion.mode) ? motion.chargeStart[1] : motion.position[1]), 0) / activeMotions.length,
];
let formationX = 0;
let formationZ = 0;
for (const motion of activeMotions) {
const origin = DASH_MODES.includes(motion.mode) ? motion.chargeStart : motion.position;
formationX += origin[0];
formationZ += origin[1];
}
const formationOrigin: WorldPosition = [formationX / activeMotions.length, formationZ / activeMotions.length];
const formation = combatFormation(formationOrigin);
for (let index = 0; index < AI_MEMBER_IDS.length; index += 1) {
+6 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { BOSS_ORDER } from "./bossCatalog";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { freshParty } from "./data";
import {
advancePartyCombat,
@@ -110,8 +110,8 @@ describe("party ability combat", () => {
});
it("lets Vale cleave only when both bosses are inside melee radius", () => {
const clustered = simulate(["vexa", "cindermaw"], { duration: 30, activeIds: ["vale"], stopOnVictory: false });
const separated = simulate(["vexa", "cindermaw"], {
const clustered = simulate(["broodfang-spider", "tempestscale-dragon"], { duration: 30, activeIds: ["vale"], stopOnVictory: false });
const separated = simulate(["broodfang-spider", "tempestscale-dragon"], {
duration: 30,
activeIds: ["vale"],
targetPositions: [[0, -4], [7, -4]],
@@ -132,7 +132,7 @@ describe("party ability combat", () => {
});
it("uses every button in each five-ability DPS rotation during a full fight", () => {
const result = simulate(["bulldrome", "vexa"]);
const result = simulate(["bulldrome", "broodfang-spider"]);
const movementPhase = simulate(["bulldrome"], { duration: 5, activeIds: ["nia"], movingIds: ["nia"], stopOnVictory: false });
const usedAbilities = new Set([...result.usedAbilities, ...movementPhase.usedAbilities]);
for (const memberId of ["nia", "orin", "vale"] as const) {
@@ -142,8 +142,8 @@ describe("party ability combat", () => {
});
describe("dual-boss damage simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
);
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
+8 -6
View File
@@ -128,7 +128,8 @@ const RANGED_IDS: readonly AiCombatantId[] = ["nia", "orin"];
const VALE_CLEAVE_RADIUS = 3.6;
const VALE_MELEE_RANGE = 3.65;
const BRANN_MELEE_RANGE = 4.8;
const PARTY_DAMAGE_SCALE = 1.65;
// Calibrated against full two-boss rotations: intended 5090 seconds, never over 100.
const PARTY_DAMAGE_SCALE = 2;
function combatant(id: AiCombatantId, hp: number): PartyCombatantState {
return {
@@ -312,7 +313,7 @@ function resolveImpact(state: PartyCombatState, actor: PartyCombatantState, acti
const source = context.positions[actor.id];
const range = actor.id === "vale" ? VALE_MELEE_RANGE : actor.id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
let target = targets.find((entry) => entry.instanceId === action.targetInstanceId && entry.boss.hp > 0 && distance(source, entry.motion.position) <= range);
target ??= targetFor(actor.id, { ...context, targets }, range);
target ??= targetFor(actor.id, context, range);
if (!target) return;
if (action.abilityId === "fan_of_blades" || action.abilityId === "sweeping_guard") {
@@ -342,6 +343,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
nextEventId: source.nextEventId,
};
const targets = context.targets.map((target) => ({ ...target, boss: { ...target.boss } }));
const combatContext: PartyCombatContext = { ...context, targets };
const events: PartyDamageEvent[] = [];
const elapsed = context.time - context.oldTime;
@@ -357,7 +359,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
if (id === "vale") actor.resource = Math.min(100, actor.resource + 12 * elapsed);
if (id === "brann" && member.hp < actor.lastHp) actor.revengeReadyUntil = context.time + 5;
actor.lastHp = member.hp;
const isMoving = moving(id, context);
const isMoving = moving(id, combatContext);
if (isMoving && actor.activeAction?.requiresStationary) {
actor.activeAction = null;
actor.readyAt = Math.max(actor.readyAt, context.oldTime + 0.2);
@@ -368,7 +370,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
const action = actor.activeAction;
if (action) {
while (action.nextImpactIndex < action.impactTimes.length && action.impactTimes[action.nextImpactIndex] <= context.time + 0.001) {
resolveImpact(state, actor, action, action.impactTimes[action.nextImpactIndex], context, targets, events);
resolveImpact(state, actor, action, action.impactTimes[action.nextImpactIndex], combatContext, targets, events);
action.nextImpactIndex += 1;
}
if (action.completesAt > context.time + 0.001) break;
@@ -378,7 +380,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
const startAt = Math.max(context.oldTime, actor.readyAt);
if (startAt > context.time + 0.001) break;
const baseSpec = chooseAbility(actor, startAt, isMoving, { ...context, targets });
const baseSpec = chooseAbility(actor, startAt, isMoving, combatContext);
const modifier = context.gearModifiers?.[id];
const spec = baseSpec && modifier ? {
...baseSpec,
@@ -390,7 +392,7 @@ export function advancePartyCombat(source: PartyCombatState, context: PartyComba
} : baseSpec;
if (!spec) { actor.readyAt = context.time + 0.1; break; }
const range = id === "vale" ? VALE_MELEE_RANGE : id === "brann" ? BRANN_MELEE_RANGE : Number.POSITIVE_INFINITY;
const target = targetFor(id, { ...context, targets }, range);
const target = targetFor(id, combatContext, range);
if (!target) { actor.readyAt = context.time + 0.1; break; }
startAction(actor, spec, target, startAt, state);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { useGameStore } from "./store";
describe("runtime performance budgets", () => {
it("keeps ten minutes of dual-boss simulation bounded", () => {
const store = useGameStore.getState();
store.configureHealer("priest", "Perf", [], ["cinderback-ricochet", "sandglass-scorpion"]);
store.configureHealer("priest", "Perf", [], ["emberfox", "sandglass-scorpion"]);
store.startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1_000_000_000, maxHp: 1_000_000_000 },
+16 -6
View File
@@ -7,28 +7,38 @@ import {
gearUpgradeCosts,
upgradeGearSlot,
} from "./gear";
import { bossCoinDrop, type MaterialStack } from "./loot";
import { groupDrop, type MaterialStack } from "./loot";
describe("IWT2-style gear progression", () => {
it("uses exact rank-one and rank-two boss coin costs", () => {
it("uses exact rank-one and rank-two group drop costs", () => {
const rankOne = gearUpgradeCosts("priest", "weapon", 0);
const rankTwo = gearUpgradeCosts("priest", "weapon", 1);
expect(rankOne.map((cost) => cost.quantity)).toEqual([2]);
expect(rankTwo.map((cost) => cost.quantity)).toEqual([3, 1]);
});
it("preserves IWT2 Veteran coin parity for ranks six through ten", () => {
it("preserves IWT2 Veteran drop parity for ranks six through ten", () => {
const recipe = GEAR_RECIPES.nia.weapon;
const costs = gearUpgradeCosts("nia", "weapon", 5);
expect(costs[0].itemId).toBe(bossCoinDrop(recipe.primaryBossId, "veteran").id);
expect(costs[0].itemId).toBe(groupDrop(recipe.primaryGroupId, "veteran").id);
expect(costs.map((cost) => cost.quantity)).toEqual([6, 5]);
});
it("requires two distinct mechanic groups for every gear recipe", () => {
for (const owner of Object.values(GEAR_RECIPES)) {
for (const recipe of Object.values(owner)) expect(recipe.primaryGroupId).not.toBe(recipe.secondaryGroupId);
}
expect(GEAR_RECIPES.priest.chest).toMatchObject({ primaryGroupId: "charge", secondaryGroupId: "shockwave-fall" });
expect(GEAR_RECIPES.brann.weapon).toMatchObject({ primaryGroupId: "charge", secondaryGroupId: "scuttle-burst" });
expect(GEAR_RECIPES.nia.chest).toMatchObject({ primaryGroupId: "charge", secondaryGroupId: "rift-lanes" });
expect(GEAR_RECIPES.vale.chest).toMatchObject({ primaryGroupId: "charge", secondaryGroupId: "rift-lanes" });
});
it("spends all costs atomically and advances one rank", () => {
const progress = createDefaultGearProgress();
const costs = gearUpgradeCosts("brann", "weapon", 0);
const coin = bossCoinDrop(GEAR_RECIPES.brann.weapon.primaryBossId, "initiate");
const inventory: MaterialStack[] = [{ id: coin.id, name: coin.name, quantity: 3, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
const drop = groupDrop(GEAR_RECIPES.brann.weapon.primaryGroupId, "initiate");
const inventory: MaterialStack[] = [{ id: drop.id, name: drop.name, quantity: 3, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
expect(canAffordGearUpgrade(inventory, costs)).toBe(true);
const result = upgradeGearSlot(progress, inventory, "brann", "weapon");
expect(result.gearProgress.brann.slots.weapon.level).toBe(1);
+36 -35
View File
@@ -1,5 +1,6 @@
import type { HealerClassId, MemberId, BossId, RunBuffId } from "../types";
import { bossCoinDrop, type DifficultySlug, type MaterialStack } from "./loot";
import type { BossGroupId } from "../bossCatalog";
import type { HealerClassId, MemberId, RunBuffId } from "../types";
import { groupDrop, type DifficultySlug, type MaterialStack } from "./loot";
export type GearOwnerId = HealerClassId | Exclude<MemberId, "aelia">;
export type GearSlotId = "weapon" | "helmet" | "chest" | "legs" | "feet";
@@ -22,8 +23,8 @@ export interface GearRecipe {
ownerId: GearOwnerId;
slotId: GearSlotId;
statId: GearStatId;
primaryBossId: BossId;
secondaryBossId: BossId;
primaryGroupId: BossGroupId;
secondaryGroupId: BossGroupId;
}
export interface GearUpgradeCost {
@@ -64,14 +65,14 @@ export const GEAR_STAT_LABELS: Record<GearStatId, string> = {
hazardDamageTaken: "Hazard Damage Taken",
};
type RecipeSeed = Record<GearSlotId, readonly [BossId, BossId]>;
type RecipeSeed = Record<GearSlotId, readonly [BossGroupId, BossGroupId]>;
const HEALER_RECIPES: RecipeSeed = {
weapon: ["vexa", "cindermaw"],
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
chest: ["obsidian-ram-golem", "bulldrome"],
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
feet: ["vexa", "sandglass-scorpion"],
weapon: ["bind-venom", "breath-skyfall"],
helmet: ["ricochet", "burrow-eruption"],
chest: ["charge", "shockwave-fall"],
legs: ["slash-cross", "ricochet"],
feet: ["bind-venom", "burrow-eruption"],
};
const OWNER_RECIPE_SEEDS: Record<GearOwnerId, RecipeSeed> = {
@@ -79,32 +80,32 @@ const OWNER_RECIPE_SEEDS: Record<GearOwnerId, RecipeSeed> = {
druid: HEALER_RECIPES,
shaman: HEALER_RECIPES,
brann: {
weapon: ["obsidian-ram-golem", "bulldrome"],
helmet: ["ember-mantis-duelist", "sandglass-scorpion"],
chest: ["obsidian-ram-golem", "cindermaw"],
legs: ["bulldrome", "cinderback-ricochet"],
feet: ["sandglass-scorpion", "vexa"],
weapon: ["charge", "scuttle-burst"],
helmet: ["slash-cross", "burrow-eruption"],
chest: ["charge", "breath-skyfall"],
legs: ["charge", "ricochet"],
feet: ["burrow-eruption", "bind-venom"],
},
nia: {
weapon: ["cinderback-ricochet", "cindermaw"],
helmet: ["vexa", "sandglass-scorpion"],
chest: ["bulldrome", "obsidian-ram-golem"],
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
feet: ["sandglass-scorpion", "vexa"],
weapon: ["ricochet", "breath-skyfall"],
helmet: ["bind-venom", "burrow-eruption"],
chest: ["charge", "rift-lanes"],
legs: ["ricochet", "slash-cross"],
feet: ["burrow-eruption", "bind-venom"],
},
orin: {
weapon: ["cindermaw", "vexa"],
helmet: ["sandglass-scorpion", "cinderback-ricochet"],
chest: ["vexa", "obsidian-ram-golem"],
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
feet: ["sandglass-scorpion", "bulldrome"],
weapon: ["breath-skyfall", "bind-venom"],
helmet: ["burrow-eruption", "ricochet"],
chest: ["bind-venom", "charge"],
legs: ["ricochet", "slash-cross"],
feet: ["burrow-eruption", "charge"],
},
vale: {
weapon: ["ember-mantis-duelist", "cindermaw"],
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
chest: ["bulldrome", "obsidian-ram-golem"],
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
feet: ["vexa", "sandglass-scorpion"],
weapon: ["slash-cross", "breath-skyfall"],
helmet: ["ricochet", "burrow-eruption"],
chest: ["charge", "rift-lanes"],
legs: ["slash-cross", "ricochet"],
feet: ["bind-venom", "burrow-eruption"],
},
};
@@ -119,8 +120,8 @@ function statFor(ownerId: GearOwnerId, slotId: GearSlotId): GearStatId {
export const GEAR_RECIPES = Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [
ownerId,
Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => {
const [primaryBossId, secondaryBossId] = OWNER_RECIPE_SEEDS[ownerId][slotId];
return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryBossId, secondaryBossId }];
const [primaryGroupId, secondaryGroupId] = OWNER_RECIPE_SEEDS[ownerId][slotId];
return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryGroupId, secondaryGroupId }];
})),
])) as Record<GearOwnerId, Record<GearSlotId, GearRecipe>>;
@@ -140,8 +141,8 @@ export function gearUpgradeCosts(ownerId: GearOwnerId, slotId: GearSlotId, curre
const nextLevel = currentLevel + 1 as Exclude<GearLevel, 0>;
const recipe = GEAR_RECIPES[ownerId][slotId];
const difficultySlug = upgradeDifficultySlug(nextLevel);
const primary = bossCoinDrop(recipe.primaryBossId, difficultySlug);
const secondary = bossCoinDrop(recipe.secondaryBossId, difficultySlug);
const primary = groupDrop(recipe.primaryGroupId, difficultySlug);
const secondary = groupDrop(recipe.secondaryGroupId, difficultySlug);
if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }];
const primaryQuantity = nextLevel <= 3 ? 3 : nextLevel <= 5 ? nextLevel : nextLevel;
const secondaryQuantity = nextLevel === 2 ? 1 : nextLevel === 3 ? 2 : nextLevel - 1;
@@ -204,7 +205,7 @@ function formatPercent(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
// IWT2 parity: +6 through +10 return to Veteran coins while infusion costs use higher tiers.
// IWT2 parity: +6 through +10 return to Veteran drops while infusion costs use higher tiers.
function upgradeDifficultySlug(level: Exclude<GearLevel, 0>): DifficultySlug {
if (level <= 2) return "initiate";
if (level >= 6) return "veteran";
+6 -6
View File
@@ -2,27 +2,27 @@ import { describe, expect, it } from "vitest";
import { createClassInventory } from "../healers";
import { useGameStore } from "../store";
import { createEncounterGearModifiers } from "./gearEffects";
import { createDefaultGearProgress } from "./gear";
import { GEAR_RECIPES, createDefaultGearProgress } from "./gear";
import {
equipActiveInfusion,
equipPassiveInfusion,
infusionCosts,
passiveInfusionUnlocked,
} from "./infusions";
import { bossCoinDrop, type MaterialStack } from "./loot";
import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot";
function stack(item: ReturnType<typeof bossCoinDrop>, quantity: number): MaterialStack {
function stack(item: ReturnType<typeof groupDrop>, quantity: number): MaterialStack {
return { id: item.id, name: item.name, rarity: item.rarity, itemLevel: item.itemLevel, glyph: item.glyph, quantity };
}
describe("IWT2-style gear infusions", () => {
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic coins", () => {
it("requires a +5 anchor and atomically spends five Ascendant plus five Mythic group drops", () => {
const progress = createDefaultGearProgress();
progress.brann.slots.weapon.level = 5;
const costs = infusionCosts("brann", "weapon", "brann-unbreakable");
const inventory = [
stack(bossCoinDrop("obsidian-ram-golem", "ascendant"), 6),
stack(bossCoinDrop("obsidian-ram-golem", "mythic"), 6),
stack(bossGroupDrop("bristlequake-boar", "ascendant"), 6),
stack(groupDrop(GEAR_RECIPES.brann.weapon.primaryGroupId, "mythic"), 6),
];
const result = equipActiveInfusion(progress, inventory, "brann", "weapon", "brann-unbreakable");
+20 -20
View File
@@ -10,7 +10,7 @@ import {
type GearSlotId,
type GearUpgradeCost,
} from "./gear";
import { bossCoinDrop, type MaterialStack } from "./loot";
import { bossGroupDrop, groupDrop, type MaterialStack } from "./loot";
export const ACTIVE_INFUSION_MIN_GEAR_LEVEL = 5;
export const PASSIVE_INFUSION_MIN_GEAR_LEVEL = 10;
@@ -38,39 +38,39 @@ type InfusionSeed = Omit<ActiveInfusionDefinition, "ownerId">;
const INFUSION_SEEDS: Record<GearOwnerId, readonly InfusionSeed[]> = {
priest: [
{ id: "priest-sanctuary", name: "Sanctuary", icon: "✦", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
{ id: "priest-guardian-grace", name: "Guardian Grace", icon: "✚", description: "8% more healing power.", linkedBossId: "cindermaw", effectKey: "healing-power" },
{ id: "priest-miracle-ward", name: "Miracle Ward", icon: "◇", description: "8% more maximum health.", linkedBossId: "obsidian-ram-golem", effectKey: "max-health" },
{ id: "priest-sanctuary", name: "Sanctuary", icon: "✦", description: "15% less hazard damage.", linkedBossId: "broodfang-spider", effectKey: "hazard-shield" },
{ id: "priest-guardian-grace", name: "Guardian Grace", icon: "✚", description: "8% more healing power.", linkedBossId: "tempestscale-dragon", effectKey: "healing-power" },
{ id: "priest-miracle-ward", name: "Miracle Ward", icon: "◇", description: "8% more maximum health.", linkedBossId: "bristlequake-boar", effectKey: "max-health" },
],
druid: [
{ id: "druid-barkskin", name: "Barkskin", icon: "♧", description: "8% more maximum health.", linkedBossId: "obsidian-ram-golem", effectKey: "max-health" },
{ id: "druid-verdant-pulse", name: "Verdant Pulse", icon: "❈", description: "8% more healing power.", linkedBossId: "ember-mantis-duelist", effectKey: "healing-power" },
{ id: "druid-barkskin", name: "Barkskin", icon: "♧", description: "8% more maximum health.", linkedBossId: "bristlequake-boar", effectKey: "max-health" },
{ id: "druid-verdant-pulse", name: "Verdant Pulse", icon: "❈", description: "8% more healing power.", linkedBossId: "warcaller-orc", effectKey: "healing-power" },
{ id: "druid-wildstep", name: "Wildstep", icon: "⌁", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" },
],
shaman: [
{ id: "shaman-spirit-ward", name: "Spirit Ward", icon: "◈", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
{ id: "shaman-ancestral-surge", name: "Ancestral Surge", icon: "ϟ", description: "8% more healing power.", linkedBossId: "cinderback-ricochet", effectKey: "healing-power" },
{ id: "shaman-spirit-ward", name: "Spirit Ward", icon: "◈", description: "15% less hazard damage.", linkedBossId: "broodfang-spider", effectKey: "hazard-shield" },
{ id: "shaman-ancestral-surge", name: "Ancestral Surge", icon: "ϟ", description: "8% more healing power.", linkedBossId: "emberfox", effectKey: "healing-power" },
{ id: "shaman-windwalk", name: "Windwalk", icon: "≋", description: "8% faster movement.", linkedBossId: "sandglass-scorpion", effectKey: "move-speed" },
],
brann: [
{ id: "brann-unbreakable", name: "Unbreakable", icon: "▣", description: "Immune to stuns.", linkedBossId: "obsidian-ram-golem", effectKey: "stun-immune" },
{ id: "brann-unbreakable", name: "Unbreakable", icon: "▣", description: "Immune to stuns.", linkedBossId: "bristlequake-boar", effectKey: "stun-immune" },
{ id: "brann-bulwark", name: "Bulwark", icon: "⬡", description: "8% more maximum health.", linkedBossId: "bulldrome", effectKey: "max-health" },
{ id: "brann-vanguard", name: "Vanguard", icon: "➶", description: "8% faster movement.", linkedBossId: "cinderback-ricochet", effectKey: "move-speed" },
{ id: "brann-vanguard", name: "Vanguard", icon: "➶", description: "8% faster movement.", linkedBossId: "emberfox", effectKey: "move-speed" },
],
nia: [
{ id: "nia-hunters-mark", name: "Hunter's Mark", icon: "◎", description: "8% more damage.", linkedBossId: "cinderback-ricochet", effectKey: "damage" },
{ id: "nia-quickdraw", name: "Quickdraw", icon: "➳", description: "8% faster attacks.", linkedBossId: "ember-mantis-duelist", effectKey: "cooldown" },
{ id: "nia-decoy", name: "Decoy", icon: "♙", description: "8% more maximum health.", linkedBossId: "vexa", effectKey: "max-health" },
{ id: "nia-hunters-mark", name: "Hunter's Mark", icon: "◎", description: "8% more damage.", linkedBossId: "emberfox", effectKey: "damage" },
{ id: "nia-quickdraw", name: "Quickdraw", icon: "➳", description: "8% faster attacks.", linkedBossId: "warcaller-orc", effectKey: "cooldown" },
{ id: "nia-decoy", name: "Decoy", icon: "♙", description: "8% more maximum health.", linkedBossId: "broodfang-spider", effectKey: "max-health" },
],
orin: [
{ id: "orin-overcharge", name: "Overcharge", icon: "ϟ", description: "8% more damage.", linkedBossId: "cindermaw", effectKey: "damage" },
{ id: "orin-overcharge", name: "Overcharge", icon: "ϟ", description: "8% more damage.", linkedBossId: "tempestscale-dragon", effectKey: "damage" },
{ id: "orin-temporal-flow", name: "Temporal Flow", icon: "◷", description: "8% faster attacks.", linkedBossId: "sandglass-scorpion", effectKey: "cooldown" },
{ id: "orin-arcane-barrier", name: "Arcane Barrier", icon: "◇", description: "8% more maximum health.", linkedBossId: "vexa", effectKey: "max-health" },
{ id: "orin-arcane-barrier", name: "Arcane Barrier", icon: "◇", description: "8% more maximum health.", linkedBossId: "broodfang-spider", effectKey: "max-health" },
],
vale: [
{ id: "vale-expose", name: "Expose", icon: "†", description: "8% more damage.", linkedBossId: "ember-mantis-duelist", effectKey: "damage" },
{ id: "vale-shadow-step", name: "Shadow Step", icon: "⌁", description: "8% faster movement.", linkedBossId: "cinderback-ricochet", effectKey: "move-speed" },
{ id: "vale-vanish", name: "Vanish", icon: "◌", description: "15% less hazard damage.", linkedBossId: "vexa", effectKey: "hazard-shield" },
{ id: "vale-expose", name: "Expose", icon: "†", description: "8% more damage.", linkedBossId: "warcaller-orc", effectKey: "damage" },
{ id: "vale-shadow-step", name: "Shadow Step", icon: "⌁", description: "8% faster movement.", linkedBossId: "emberfox", effectKey: "move-speed" },
{ id: "vale-vanish", name: "Vanish", icon: "◌", description: "15% less hazard damage.", linkedBossId: "broodfang-spider", effectKey: "hazard-shield" },
],
};
@@ -100,8 +100,8 @@ export function passiveInfusionUnlocked(progress: GearProgress): boolean {
export function infusionCosts(ownerId: GearOwnerId, slotId: GearSlotId, infusionId: string): GearUpgradeCost[] {
const definition = ACTIVE_INFUSIONS[infusionId];
if (!definition || definition.ownerId !== ownerId) return [];
const ascendant = bossCoinDrop(definition.linkedBossId, "ascendant");
const mythic = bossCoinDrop(GEAR_RECIPES[ownerId][slotId].primaryBossId, "mythic");
const ascendant = bossGroupDrop(definition.linkedBossId, "ascendant");
const mythic = groupDrop(GEAR_RECIPES[ownerId][slotId].primaryGroupId, "mythic");
return [
{ itemId: ascendant.id, itemName: ascendant.name, quantity: 5 },
{ itemId: mythic.id, itemName: mythic.name, quantity: 5 },
+194
View File
@@ -0,0 +1,194 @@
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "../bossCatalog";
import type { BossId } from "../types";
export type LootRarity = "common" | "uncommon" | "rare" | "epic" | "legendary";
export type DifficultySlug = "initiate" | "veteran" | "champion" | "mythic" | "ascendant";
export interface DifficultyDefinition {
slug: DifficultySlug;
name: string;
itemLevel: number;
rarity: LootRarity;
glyph: string;
coinPrefix: string;
healthMultiplier: number;
damageMultiplier: number;
}
export interface MaterialStack {
id: string;
name: string;
quantity: number;
rarity: LootRarity;
itemLevel: number;
glyph: string;
}
export interface BossCoinDrop {
kind: "coin";
id: string;
bossId: BossId;
difficultySlug: DifficultySlug;
name: string;
rarity: LootRarity;
itemLevel: number;
glyph: string;
chanceLabel: string;
}
export interface BossPetDrop {
kind: "pet";
id: string;
bossId: BossId;
name: string;
rarity: "legendary";
glyph: string;
dropRate: number;
chanceLabel: string;
}
export interface BossDropTable {
bossId: BossId;
coins: Record<DifficultySlug, BossCoinDrop>;
pet: BossPetDrop;
entries: readonly (BossCoinDrop | BossPetDrop)[];
}
export interface CollectionLog {
dropsFound: Record<string, number>;
petsFound: Record<string, number>;
}
export interface BossRewardAward {
bossId: BossId;
coin: BossCoinDrop;
quantity: number;
quantityAfter: number;
duplicate: boolean;
pet: BossPetDrop | null;
petQuantityAfter: number;
}
export const BOSS_PET_DROP_RATE = 1 / 500;
export const DIFFICULTIES: readonly DifficultyDefinition[] = [
{ slug: "initiate", name: "Initiate", itemLevel: 1, rarity: "common", glyph: "R", coinPrefix: "Raw", healthMultiplier: 1, damageMultiplier: 1 },
{ slug: "veteran", name: "Veteran", itemLevel: 10, rarity: "uncommon", glyph: "G", coinPrefix: "Green", healthMultiplier: 1.45, damageMultiplier: 1.25 },
{ slug: "champion", name: "Champion", itemLevel: 15, rarity: "rare", glyph: "B", coinPrefix: "Blue", healthMultiplier: 1.7, damageMultiplier: 1.45 },
{ slug: "mythic", name: "Mythic", itemLevel: 20, rarity: "epic", glyph: "P", coinPrefix: "Purple", healthMultiplier: 2.25, damageMultiplier: 1.85 },
{ slug: "ascendant", name: "Ascendant", itemLevel: 25, rarity: "legendary", glyph: "O", coinPrefix: "Orange", healthMultiplier: 2.8, damageMultiplier: 2.25 },
] as const;
export const DIFFICULTY_BY_SLUG = Object.fromEntries(
DIFFICULTIES.map((difficulty) => [difficulty.slug, difficulty]),
) as Record<DifficultySlug, DifficultyDefinition>;
const DIFFICULTY_ID_PREFIX: Record<DifficultySlug, string> = {
initiate: "raw",
veteran: "green",
champion: "blue",
mythic: "purple",
ascendant: "orange",
};
function createBossDropTable(bossId: BossId): BossDropTable {
const boss = BOSS_DEFINITIONS[bossId];
const coins = Object.fromEntries(DIFFICULTIES.map((difficulty) => [
difficulty.slug,
{
kind: "coin" as const,
id: `${DIFFICULTY_ID_PREFIX[difficulty.slug]}-${bossId}-coin`,
bossId,
difficultySlug: difficulty.slug,
name: `${difficulty.coinPrefix} ${boss.name} Coin`,
rarity: difficulty.rarity,
itemLevel: difficulty.itemLevel,
glyph: difficulty.glyph,
chanceLabel: "Guaranteed 1-3",
},
])) as Record<DifficultySlug, BossCoinDrop>;
const pet: BossPetDrop = {
kind: "pet",
id: `${bossId}-pet`,
bossId,
name: `${boss.name} Pet`,
rarity: "legendary",
glyph: boss.icon,
dropRate: BOSS_PET_DROP_RATE,
chanceLabel: "1 in 500",
};
return {
bossId,
coins,
pet,
entries: [...DIFFICULTIES.map((difficulty) => coins[difficulty.slug]), pet],
};
}
export const BOSS_DROP_TABLES = Object.fromEntries(
AVAILABLE_BOSS_IDS.map((bossId) => [bossId, createBossDropTable(bossId)]),
) as Record<BossId, BossDropTable>;
export function bossDropTable(bossId: BossId): BossDropTable {
return BOSS_DROP_TABLES[bossId];
}
export function bossCoinDrop(bossId: BossId, difficultySlug: DifficultySlug): BossCoinDrop {
return BOSS_DROP_TABLES[bossId].coins[difficultySlug];
}
export function coinDropQuantity(random: () => number = Math.random): number {
const roll = random();
if (roll < 0.15) return 3;
if (roll < 0.5) return 2;
return 1;
}
export function rollBossReward(
bossId: BossId,
difficultySlug: DifficultySlug,
inventory: readonly MaterialStack[],
collectionLog: CollectionLog,
random: () => number = Math.random,
): { award: BossRewardAward; inventory: MaterialStack[]; collectionLog: CollectionLog } {
const table = bossDropTable(bossId);
const coin = table.coins[difficultySlug];
const quantity = coinDropQuantity(random);
const existing = inventory.find((item) => item.id === coin.id);
const inventoryAfter = existing
? inventory.map((item) => item.id === coin.id ? { ...item, quantity: item.quantity + quantity } : { ...item })
: [...inventory.map((item) => ({ ...item })), { id: coin.id, name: coin.name, quantity, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
const petAwarded = random() < table.pet.dropRate;
const petQuantityAfter = (collectionLog.petsFound[table.pet.id] ?? 0) + Number(petAwarded);
return {
award: {
bossId,
coin,
quantity,
quantityAfter: (existing?.quantity ?? 0) + quantity,
duplicate: Boolean(existing),
pet: petAwarded ? table.pet : null,
petQuantityAfter,
},
inventory: inventoryAfter,
collectionLog: {
dropsFound: {
...collectionLog.dropsFound,
[coin.id]: (collectionLog.dropsFound[coin.id] ?? 0) + quantity,
},
petsFound: petAwarded
? { ...collectionLog.petsFound, [table.pet.id]: petQuantityAfter }
: { ...collectionLog.petsFound },
},
};
}
export function normalizeDifficultySlug(value: unknown): DifficultySlug {
return typeof value === "string" && value in DIFFICULTY_BY_SLUG
? value as DifficultySlug
: "initiate";
}
export function createEmptyCollectionLog(): CollectionLog {
return { dropsFound: {}, petsFound: {} };
}
+21 -16
View File
@@ -1,25 +1,30 @@
import { describe, expect, it } from "vitest";
import { BOSS_ORDER } from "../bossCatalog";
import { BOSS_DROP_TABLES, coinDropQuantity, createEmptyCollectionLog, rollBossReward } from "./loot";
import { BOSS_GROUPS } from "../bossCatalog";
import { GROUP_DROP_TABLES, bossGroupDrop, createEmptyCollectionLog, groupDropQuantity, rollBossReward } from "./loot";
describe("boss drop tables", () => {
it("defines tiered coins and a pet for every shipped boss", () => {
for (const bossId of BOSS_ORDER) {
const table = BOSS_DROP_TABLES[bossId];
expect(Object.keys(table.coins)).toEqual(["initiate", "veteran", "champion", "mythic", "ascendant"]);
expect(table.pet.dropRate).toBe(1 / 500);
describe("group drop tables", () => {
it("defines five shared drops for every mechanic group", () => {
for (const group of BOSS_GROUPS) {
const table = GROUP_DROP_TABLES[group.id];
expect(Object.keys(table.drops)).toEqual(["initiate", "veteran", "champion", "mythic", "ascendant"]);
expect(table.drops.veteran.name).toBe(`Green Group ${group.letter} Drop`);
}
});
it("rolls IWT2 coin quantities at exact boundaries", () => {
expect(coinDropQuantity(() => 0)).toBe(3);
expect(coinDropQuantity(() => 0.149999)).toBe(3);
expect(coinDropQuantity(() => 0.15)).toBe(2);
expect(coinDropQuantity(() => 0.499999)).toBe(2);
expect(coinDropQuantity(() => 0.5)).toBe(1);
it("awards the same material from every boss in a group", () => {
expect(bossGroupDrop("bulldrome", "veteran").id).toBe(bossGroupDrop("bristlequake-boar", "veteran").id);
expect(bossGroupDrop("bulldrome", "veteran").name).toBe("Green Group A Drop");
});
it("stacks duplicate coins while collection counts remain lifetime totals", () => {
it("rolls group drop quantities at exact boundaries", () => {
expect(groupDropQuantity(() => 0)).toBe(3);
expect(groupDropQuantity(() => 0.149999)).toBe(3);
expect(groupDropQuantity(() => 0.15)).toBe(2);
expect(groupDropQuantity(() => 0.499999)).toBe(2);
expect(groupDropQuantity(() => 0.5)).toBe(1);
});
it("stacks duplicate group drops while collection counts remain lifetime totals", () => {
const rolls = [0.1, 1, 0.2, 0];
const random = () => rolls.shift() ?? 1;
const first = rollBossReward("bulldrome", "initiate", [], createEmptyCollectionLog(), random);
@@ -28,7 +33,7 @@ describe("boss drop tables", () => {
expect(second.award.quantity).toBe(2);
expect(second.award.duplicate).toBe(true);
expect(second.inventory[0].quantity).toBe(5);
expect(second.collectionLog.dropsFound[second.award.coin.id]).toBe(5);
expect(second.collectionLog.dropsFound[second.award.drop.id]).toBe(5);
expect(second.award.pet?.id).toBe("bulldrome-pet");
});
});
+73 -59
View File
@@ -1,4 +1,4 @@
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../bossCatalog";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, bossGroupFor, type BossGroupId } from "../bossCatalog";
import type { BossId } from "../types";
export type LootRarity = "common" | "uncommon" | "rare" | "epic" | "legendary";
@@ -10,7 +10,7 @@ export interface DifficultyDefinition {
itemLevel: number;
rarity: LootRarity;
glyph: string;
coinPrefix: string;
dropPrefix: string;
healthMultiplier: number;
damageMultiplier: number;
}
@@ -24,10 +24,10 @@ export interface MaterialStack {
glyph: string;
}
export interface BossCoinDrop {
kind: "coin";
export interface GroupDrop {
kind: "group-drop";
id: string;
bossId: BossId;
groupId: BossGroupId;
difficultySlug: DifficultySlug;
name: string;
rarity: LootRarity;
@@ -47,11 +47,10 @@ export interface BossPetDrop {
chanceLabel: string;
}
export interface BossDropTable {
bossId: BossId;
coins: Record<DifficultySlug, BossCoinDrop>;
pet: BossPetDrop;
entries: readonly (BossCoinDrop | BossPetDrop)[];
export interface GroupDropTable {
groupId: BossGroupId;
drops: Record<DifficultySlug, GroupDrop>;
entries: readonly GroupDrop[];
}
export interface CollectionLog {
@@ -61,7 +60,7 @@ export interface CollectionLog {
export interface BossRewardAward {
bossId: BossId;
coin: BossCoinDrop;
drop: GroupDrop;
quantity: number;
quantityAfter: number;
duplicate: boolean;
@@ -72,11 +71,11 @@ export interface BossRewardAward {
export const BOSS_PET_DROP_RATE = 1 / 500;
export const DIFFICULTIES: readonly DifficultyDefinition[] = [
{ slug: "initiate", name: "Initiate", itemLevel: 1, rarity: "common", glyph: "R", coinPrefix: "Raw", healthMultiplier: 1, damageMultiplier: 1 },
{ slug: "veteran", name: "Veteran", itemLevel: 10, rarity: "uncommon", glyph: "G", coinPrefix: "Green", healthMultiplier: 1.45, damageMultiplier: 1.25 },
{ slug: "champion", name: "Champion", itemLevel: 15, rarity: "rare", glyph: "B", coinPrefix: "Blue", healthMultiplier: 1.7, damageMultiplier: 1.45 },
{ slug: "mythic", name: "Mythic", itemLevel: 20, rarity: "epic", glyph: "P", coinPrefix: "Purple", healthMultiplier: 2.25, damageMultiplier: 1.85 },
{ slug: "ascendant", name: "Ascendant", itemLevel: 25, rarity: "legendary", glyph: "O", coinPrefix: "Orange", healthMultiplier: 2.8, damageMultiplier: 2.25 },
{ slug: "initiate", name: "Initiate", itemLevel: 1, rarity: "common", glyph: "R", dropPrefix: "Raw", healthMultiplier: 1, damageMultiplier: 1 },
{ slug: "veteran", name: "Veteran", itemLevel: 10, rarity: "uncommon", glyph: "G", dropPrefix: "Green", healthMultiplier: 1.45, damageMultiplier: 1.25 },
{ slug: "champion", name: "Champion", itemLevel: 15, rarity: "rare", glyph: "B", dropPrefix: "Blue", healthMultiplier: 1.7, damageMultiplier: 1.45 },
{ slug: "mythic", name: "Mythic", itemLevel: 20, rarity: "epic", glyph: "P", dropPrefix: "Purple", healthMultiplier: 2.25, damageMultiplier: 1.85 },
{ slug: "ascendant", name: "Ascendant", itemLevel: 25, rarity: "legendary", glyph: "O", dropPrefix: "Orange", healthMultiplier: 2.8, damageMultiplier: 2.25 },
] as const;
export const DIFFICULTY_BY_SLUG = Object.fromEntries(
@@ -91,53 +90,68 @@ const DIFFICULTY_ID_PREFIX: Record<DifficultySlug, string> = {
ascendant: "orange",
};
function createBossDropTable(bossId: BossId): BossDropTable {
const boss = BOSS_DEFINITIONS[bossId];
const coins = Object.fromEntries(DIFFICULTIES.map((difficulty) => [
function createGroupDropTable(groupId: BossGroupId): GroupDropTable {
const group = BOSS_GROUP_BY_ID[groupId];
const drops = Object.fromEntries(DIFFICULTIES.map((difficulty) => [
difficulty.slug,
{
kind: "coin" as const,
id: `${DIFFICULTY_ID_PREFIX[difficulty.slug]}-${bossId}-coin`,
bossId,
kind: "group-drop" as const,
id: `${DIFFICULTY_ID_PREFIX[difficulty.slug]}-${groupId}-drop`,
groupId,
difficultySlug: difficulty.slug,
name: `${difficulty.coinPrefix} ${boss.name} Coin`,
name: `${difficulty.dropPrefix} Group ${group.letter} Drop`,
rarity: difficulty.rarity,
itemLevel: difficulty.itemLevel,
glyph: difficulty.glyph,
chanceLabel: "Guaranteed 1-3",
},
])) as Record<DifficultySlug, BossCoinDrop>;
const pet: BossPetDrop = {
kind: "pet",
id: `${bossId}-pet`,
bossId,
name: `${boss.name} Pet`,
rarity: "legendary",
glyph: boss.icon,
dropRate: BOSS_PET_DROP_RATE,
chanceLabel: "1 in 500",
};
])) as Record<DifficultySlug, GroupDrop>;
return {
bossId,
coins,
pet,
entries: [...DIFFICULTIES.map((difficulty) => coins[difficulty.slug]), pet],
groupId,
drops,
entries: DIFFICULTIES.map((difficulty) => drops[difficulty.slug]),
};
}
export const BOSS_DROP_TABLES = Object.fromEntries(
BOSS_ORDER.map((bossId) => [bossId, createBossDropTable(bossId)]),
) as Record<BossId, BossDropTable>;
export function bossDropTable(bossId: BossId): BossDropTable {
return BOSS_DROP_TABLES[bossId];
function createBossPetDrop(bossId: BossId): BossPetDrop {
const boss = BOSS_DEFINITIONS[bossId];
return {
kind: "pet",
id: `${bossId}-pet`,
bossId,
name: `${boss.name} Pet`,
rarity: "legendary",
glyph: boss.icon,
dropRate: BOSS_PET_DROP_RATE,
chanceLabel: "1 in 500",
};
}
export function bossCoinDrop(bossId: BossId, difficultySlug: DifficultySlug): BossCoinDrop {
return BOSS_DROP_TABLES[bossId].coins[difficultySlug];
export const GROUP_DROP_TABLES = Object.fromEntries(
Object.keys(BOSS_GROUP_BY_ID).map((groupId) => [groupId, createGroupDropTable(groupId as BossGroupId)]),
) as Record<BossGroupId, GroupDropTable>;
export const BOSS_PET_DROPS = Object.fromEntries(
AVAILABLE_BOSS_IDS.map((bossId) => [bossId, createBossPetDrop(bossId)]),
) as Record<BossId, BossPetDrop>;
export function groupDropTable(groupId: BossGroupId): GroupDropTable {
return GROUP_DROP_TABLES[groupId];
}
export function coinDropQuantity(random: () => number = Math.random): number {
export function groupDrop(groupId: BossGroupId, difficultySlug: DifficultySlug): GroupDrop {
return GROUP_DROP_TABLES[groupId].drops[difficultySlug];
}
export function bossGroupDrop(bossId: BossId, difficultySlug: DifficultySlug): GroupDrop {
return groupDrop(bossGroupFor(bossId).id, difficultySlug);
}
export function bossPetDrop(bossId: BossId): BossPetDrop {
return BOSS_PET_DROPS[bossId];
}
export function groupDropQuantity(random: () => number = Math.random): number {
const roll = random();
if (roll < 0.15) return 3;
if (roll < 0.5) return 2;
@@ -151,33 +165,33 @@ export function rollBossReward(
collectionLog: CollectionLog,
random: () => number = Math.random,
): { award: BossRewardAward; inventory: MaterialStack[]; collectionLog: CollectionLog } {
const table = bossDropTable(bossId);
const coin = table.coins[difficultySlug];
const quantity = coinDropQuantity(random);
const existing = inventory.find((item) => item.id === coin.id);
const drop = bossGroupDrop(bossId, difficultySlug);
const pet = bossPetDrop(bossId);
const quantity = groupDropQuantity(random);
const existing = inventory.find((item) => item.id === drop.id);
const inventoryAfter = existing
? inventory.map((item) => item.id === coin.id ? { ...item, quantity: item.quantity + quantity } : { ...item })
: [...inventory.map((item) => ({ ...item })), { id: coin.id, name: coin.name, quantity, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }];
const petAwarded = random() < table.pet.dropRate;
const petQuantityAfter = (collectionLog.petsFound[table.pet.id] ?? 0) + Number(petAwarded);
? inventory.map((item) => item.id === drop.id ? { ...item, quantity: item.quantity + quantity } : { ...item })
: [...inventory.map((item) => ({ ...item })), { id: drop.id, name: drop.name, quantity, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
const petAwarded = random() < pet.dropRate;
const petQuantityAfter = (collectionLog.petsFound[pet.id] ?? 0) + Number(petAwarded);
return {
award: {
bossId,
coin,
drop,
quantity,
quantityAfter: (existing?.quantity ?? 0) + quantity,
duplicate: Boolean(existing),
pet: petAwarded ? table.pet : null,
pet: petAwarded ? pet : null,
petQuantityAfter,
},
inventory: inventoryAfter,
collectionLog: {
dropsFound: {
...collectionLog.dropsFound,
[coin.id]: (collectionLog.dropsFound[coin.id] ?? 0) + quantity,
[drop.id]: (collectionLog.dropsFound[drop.id] ?? 0) + quantity,
},
petsFound: petAwarded
? { ...collectionLog.petsFound, [table.pet.id]: petQuantityAfter }
? { ...collectionLog.petsFound, [pet.id]: petQuantityAfter }
: { ...collectionLog.petsFound },
},
};
+3 -3
View File
@@ -27,11 +27,11 @@ describe("roguelike progression", () => {
it("selects a distinct pair that excludes both bosses from the prior round", () => {
const values = [0, 0];
const pair = selectRandomBossPair(["bulldrome", "vexa"], () => values.shift() ?? 0);
const pair = selectRandomBossPair(["bulldrome", "broodfang-spider"], () => values.shift() ?? 0);
expect(pair).toEqual(["cindermaw", "ember-mantis-duelist"]);
expect(pair).toEqual(["sandglass-scorpion", "cragclaw-crab"]);
expect(new Set(pair)).toHaveLength(2);
expect(pair).not.toContain("bulldrome");
expect(pair).not.toContain("vexa");
expect(pair).not.toContain("broodfang-spider");
});
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { BOSS_ORDER } from "./bossCatalog";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import type { BossId, PartyMember, RunBuffId } from "./types";
export interface RunBuffDefinition {
@@ -68,8 +68,8 @@ export function selectRandomBossPair(
random: () => number = Math.random,
): readonly [BossId, BossId] {
const excluded = new Set(excludedBossIds);
const eligibleBosses = BOSS_ORDER.filter((bossId) => !excluded.has(bossId));
const pool = eligibleBosses.length >= 2 ? eligibleBosses : BOSS_ORDER;
const eligibleBosses = AVAILABLE_BOSS_IDS.filter((bossId) => !excluded.has(bossId));
const pool = eligibleBosses.length >= 2 ? eligibleBosses : AVAILABLE_BOSS_IDS;
const firstIndex = Math.floor(random() * pool.length) % pool.length;
const secondOffset = 1 + (Math.floor(random() * (pool.length - 1)) % (pool.length - 1));
return [pool[firstIndex], pool[(firstIndex + secondOffset) % pool.length]];
+17 -14
View File
@@ -4,7 +4,7 @@ import { distance, pointToSegmentDistance } from "./geometry";
import { barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVexaVenomPool, VEXA_VENOM } from "./bosses/vexa";
import { isInsideArena } from "./arena";
import { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
describe("Disc Priest combat simulation", () => {
@@ -272,7 +272,7 @@ describe("Disc Priest combat simulation", () => {
bossMotion: {
...state.bossMotion,
mode: "returning",
position: [0, -4.25],
position: [0, -1],
chargesSincePounce: 3,
},
}));
@@ -300,9 +300,9 @@ describe("Disc Priest combat simulation", () => {
});
});
describe("Vexa encounter", () => {
describe("Broodfang encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "vexa");
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider");
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
});
@@ -364,20 +364,23 @@ describe("Vexa encounter", () => {
expect(useGameStore.getState().party[0].hp).toBe(secondTickHp);
});
it("follows Brann when the tank is displaced", () => {
it("returns to the room center when the tank is displaced", () => {
useGameStore.setState((state) => ({
partyPositions: { ...state.partyPositions, brann: [3, 1] },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
bossMotion: { ...state.bossMotion, position: [5, 5], nextMechanicAt: 999 },
}));
const startX = useGameStore.getState().bossMotion.position[0];
const start = useGameStore.getState().bossMotion.position;
useGameStore.getState().tick(0.5);
expect(useGameStore.getState().bossMotion.position[0]).toBeGreaterThan(startX);
const result = useGameStore.getState().bossMotion.position;
expect(Math.hypot(result[0] - ARENA_CENTER[0], result[1] - ARENA_CENTER[1])).toBeLessThan(
Math.hypot(start[0] - ARENA_CENTER[0], start[1] - ARENA_CENTER[1]),
);
});
});
describe("PVE dual-boss encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["vexa", "cindermaw"]);
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), ["broodfang-spider", "tempestscale-dragon"]);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
@@ -387,8 +390,8 @@ describe("PVE dual-boss encounter", () => {
it("runs two distinct bosses concurrently and requires both to fall", () => {
const initial = useGameStore.getState();
expect(initial.boss.id).toBe("vexa");
expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["cindermaw"]);
expect(initial.boss.id).toBe("broodfang-spider");
expect(initial.additionalBosses.map((entry) => entry.boss.id)).toEqual(["tempestscale-dragon"]);
expect(initial.bossMotion.position[0]).toBeLessThan(initial.additionalBosses[0].motion.position[0]);
useGameStore.getState().tick(1);
@@ -421,7 +424,7 @@ describe("Roguelike rounds", () => {
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "vexa"],
["bulldrome", "broodfang-spider"],
"roguelike",
);
useGameStore.getState().startEncounter();
@@ -489,9 +492,9 @@ describe("shared arena boundary", () => {
});
});
describe("Cindermaw encounter", () => {
describe("Tempestscale encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "cindermaw");
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "tempestscale-dragon");
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({ boss: { ...state.boss, nextMeleeAt: 999 } }));
});
+6 -6
View File
@@ -436,12 +436,9 @@ export const useGameStore = create<GameState>((set, get) => ({
case "purify":
{
const dispelledNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
if (state.boss.id === "vexa") {
const dispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
bossMotion = dispel.motion;
}
const primaryDispel = handleBossDispel(state.boss.id, state.bossMotion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
bossMotion = primaryDispel.motion;
additionalBosses = state.additionalBosses.map((entry) => {
if (entry.boss.id !== "vexa") return entry;
const dispel = handleBossDispel(entry.boss.id, entry.motion, selected.id, state.partyPositions[selected.id], state.time, dispelledNames);
return { ...entry, motion: dispel.motion };
});
@@ -491,7 +488,9 @@ export const useGameStore = create<GameState>((set, get) => ({
const oldTime = state.time;
const time = oldTime + Math.min(delta, 2);
let party = state.party.map((member) => ({ ...member, debuffs: member.debuffs.map((debuff) => ({ ...debuff })) }));
// Debuffs are copied only by the periodic-debuff pass below. Copying them here
// as well doubled short-lived allocations for every simulation step.
let party = state.party.map((member) => ({ ...member }));
let boss = { ...state.boss };
let bossMotion = { ...state.bossMotion };
let additionalBosses = state.additionalBosses.map((entry) => ({
@@ -575,6 +574,7 @@ export const useGameStore = create<GameState>((set, get) => ({
partyPositions,
time,
delta: time - oldTime,
allowPooledMechanics: encounterBosses.length === 1,
damageMember: (member, amount, position, at, kind) => damageMemberAt(member, amount, position, barrier, at, partyCombat, partyPositions.brann, state.difficultyDamageMultiplier, state.gearModifiers, kind),
});
encounterBosses[index] = { ...encounterBoss, boss: mechanicResult.boss, motion: constrainBossMotion(mechanicResult.motion) };
+79 -7
View File
@@ -2,17 +2,32 @@ export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale";
export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier";
export type BossId =
| "bulldrome"
| "vexa"
| "cindermaw"
| "ember-mantis-duelist"
| "obsidian-ram-golem"
| "cinderback-ricochet"
| "sandglass-scorpion"
| "cragclaw-crab"
| "mournveil-ghost"
| "crownshard-golem"
| "pumpking-king-of-ghosts"
| "blue-eyes-ultimate-dragon";
| "crystal-bat-matriarch"
| "stormwool-alpaca"
| "cluckhorn-colossus"
| "ashwing-demon"
| "riftclaw-demon"
| "tempestscale-dragon"
| "emberfox"
| "mirelord-frog"
| "stonebreaker-giant"
| "glub-sovereign"
| "scrapking-goblin"
| "warcaller-orc"
| "tuskmaw-orc"
| "broodfang-spider"
| "silkfang-spider"
| "thorncrown-stag"
| "sky-totem"
| "razorcrest-raptor"
| "bristlequake-boar"
| "moonfang-wolf"
| "frostmaw-yeti"
| "rimeclaw-yeti";
export type GamePhase = "briefing" | "combat" | "intermission" | "victory" | "defeat";
export type RunMode = "encounter" | "roguelike";
export type RunBuffId = "vital-bloom" | "deep-wells" | "restoring-grace";
@@ -73,6 +88,47 @@ export type CircleHazardKind =
| "crownfall"
| "royal_shockwave";
export type PoolTelegraphKind = "spread" | "donut" | "soak" | "beam" | "memory" | "soul-siphon";
export type MemorySymbolId = "triangle" | "cross" | "circle" | "square";
export interface MemoryTile {
symbol: MemorySymbolId;
center: WorldPosition;
}
/**
* Short-lived, encounter-agnostic boss mechanic. Individual boss scripts keep
* ownership of their signature moves; this shape lets an encounter add a
* balanced mechanic from the shared pool without adding another boss mode.
*/
export interface PoolTelegraph {
id: string;
kind: PoolTelegraphKind;
name: string;
center: WorldPosition;
radius: number;
innerRadius?: number;
start?: WorldPosition;
end?: WorldPosition;
width?: number;
activatesAt: number;
expiresAt: number;
damage: number;
totalDamage?: number;
minimumParticipants?: number;
targetId?: MemberId;
/** Healer-only Simon Says state. Present only for the Memory Sequence pool mechanic. */
sequence?: MemorySymbolId[];
tiles?: MemoryTile[];
inputStartsAt?: number;
inputIndex?: number;
lastHealerTileId?: MemorySymbolId;
soulSiphon?: SoulSiphonState;
resolved: boolean;
hitIds: MemberId[];
}
export interface CircleHazard {
id: string;
kind: CircleHazardKind;
@@ -96,6 +152,19 @@ export interface SlashLane {
damage: number;
}
/**
* Healer-only movement objective. The encounter owns the rules; renderers use
* this compact state to show the pursuing shade and its cleansing destination.
*/
export interface SoulSiphonState {
targetId: "aelia";
ghostPosition: WorldPosition;
wardPosition: WorldPosition;
wardRadius: number;
nextDamageAt: number;
tickCount: number;
}
export interface Debuff {
id: string;
name: string;
@@ -158,6 +227,9 @@ export interface BossMotionState {
breathEndAngle: number;
hazards: CircleHazard[];
slashLanes: SlashLane[];
nextPoolMechanicAt: number;
poolMechanicCount: number;
poolTelegraphs: PoolTelegraph[];
}
export interface AbilityDefinition {
-23
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef } from "react";
import { subscribeControllerToken } from "../input/controller";
import { ABILITY_ORDER } from "./data";
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick } from "./performance";
import { useGameStore } from "./store";
import type { AbilityId } from "./types";
@@ -21,28 +20,6 @@ const gamepadAbilityMap: Record<number, AbilityId> = {
5: "barrier",
};
export function useGameLoop() {
useEffect(() => {
let frame = 0;
let previous = performance.now();
let accumulator = 0;
const loop = (now: number) => {
const delta = Math.min((now - previous) / 1000, 0.25);
previous = now;
accumulator += delta;
if (accumulator >= 0.1) {
const tickStartedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
useGameStore.getState().tick(accumulator);
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - tickStartedAt);
accumulator = 0;
}
frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frame);
}, []);
}
export function useActionBindings(enabled = true, onExit?: () => void) {
const exitRef = useRef(onExit);
exitRef.current = onExit;
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { diffBottomGameSnapshot, type BottomGameSnapshot } from "./dualScreenSync";
function snapshot(): BottomGameSnapshot {
return {
bossId: "bulldrome",
paused: false,
healerClassId: "priest",
phase: "combat",
round: 1,
time: 10,
party: [],
boss: { id: "bulldrome", name: "Bulldrome", maxHp: 100, hp: 100, nextMeleeAt: 1, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 },
additionalBosses: [],
partyPositions: { aelia: [0, 0], brann: [0, 0], nia: [0, 0], orin: [0, 0], vale: [0, 0] },
bossMotion: {
bossId: "bulldrome", formationOffsetX: 0, mode: "holding", position: [0, 0], chargeStart: [0, 0], chargeEnd: [0, 0], chargeTargetId: "aelia", chargeHitIds: [], phaseEndsAt: 0,
nextChargeAt: Infinity, chargeCount: 0, chargesSincePounce: 0, pounceTargetId: "aelia", pounceCenter: [0, 0], pounceCount: 0,
nextMechanicAt: Infinity, mechanicCount: 0, phaseStartedAt: 0, mechanicHitIds: [], mechanicNextDamageAt: {}, tetherIds: [], tetherBreakDistance: 0,
breathAngle: 0, breathStartAngle: 0, breathEndAngle: 0, hazards: [], slashLanes: [], nextPoolMechanicAt: Infinity, poolMechanicCount: 0, poolTelegraphs: [],
},
partyCombat: {
combatants: {
brann: { id: "brann", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 },
nia: { id: "nia", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 },
orin: { id: "orin", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 },
vale: { id: "vale", readyAt: 0, resource: 0, points: 0, cooldowns: {}, activeAction: null, visualAction: null, overchargeStacks: 0, bladeFlurryUntil: 0, revengeReadyUntil: 0, lastHp: 100, damageDone: 0 },
},
tankAura: { expiresAt: 0, radius: 3, damageReduction: 0.3 },
nextEventId: 1,
},
mana: 100,
maxMana: 100,
selectedMemberId: "brann",
cooldowns: { mend: 0, renew: 0, shield: 0, purify: 0, radiance: 0, barrier: 0 },
globalCooldownUntil: 0,
activeTab: "combat",
selectedItemId: "",
inventory: [],
playerPosition: [0, 0],
activeCast: null,
barrier: { center: [0, 0], expiresAt: 0 },
};
}
describe("dual-screen game snapshots", () => {
it("sends a full initial projection, then only semantic changes", () => {
const initial = snapshot();
expect(diffBottomGameSnapshot(undefined, initial)).toBe(initial);
const clonedWithoutChanges = structuredClone(initial);
expect(diffBottomGameSnapshot(initial, clonedWithoutChanges)).toEqual({});
const next = { ...clonedWithoutChanges, time: 10.1, mana: 97 };
expect(diffBottomGameSnapshot(clonedWithoutChanges, next)).toEqual({ time: 10.1, mana: 97 });
});
});
+91 -4
View File
@@ -2,7 +2,7 @@ import type { AppScreen } from "../frontend/types";
import type { FrontendSnapshot } from "../frontend/store";
import { useFrontendStore } from "../frontend/store";
import { emitControllerToken, setExternalControllerMovement, type ControllerMovement, type ControllerTokenEvent } from "../input/controller";
import { getGameSnapshot, type GameSnapshot, useGameStore } from "../game/store";
import { type GameState, useGameStore } from "../game/store";
import type { AbilityId, BottomTab, MemberId, RunBuffId } from "../game/types";
import type { BossId, HealerClassId } from "../game/types";
import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types";
@@ -54,7 +54,7 @@ export type FrontendCommand =
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
export type DualScreenMessage =
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: GameSnapshot }
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial<BottomGameSnapshot> }
| { type: "controller-token"; id: string; event: ControllerTokenEvent }
| { type: "controller-echo"; id: string; event: ControllerTokenEvent }
| { type: "controller-motion"; movement: ControllerMovement }
@@ -123,6 +123,93 @@ export function receiveAuthoritativeMessage(message: DualScreenMessage) {
if (message.type === "frontend-command") executeFrontendCommand(message.command);
}
export function currentGameSnapshot() {
return getGameSnapshot();
/** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */
export type BottomGameSnapshot = Pick<GameState,
| "bossId"
| "paused"
| "healerClassId"
| "phase"
| "round"
| "time"
| "party"
| "boss"
| "additionalBosses"
| "partyPositions"
| "bossMotion"
| "partyCombat"
| "mana"
| "maxMana"
| "selectedMemberId"
| "cooldowns"
| "globalCooldownUntil"
| "activeTab"
| "selectedItemId"
| "inventory"
| "playerPosition"
| "activeCast"
| "barrier"
>;
const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [
"bossId", "paused", "healerClassId", "phase", "round", "time", "party", "boss", "additionalBosses",
"partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns",
"globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier",
];
function structurallyEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (!structurallyEqual(left[index], right[index])) return false;
}
return true;
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const keys = Object.keys(leftRecord);
if (keys.length !== Object.keys(rightRecord).length) return false;
return keys.every((key) => Object.prototype.hasOwnProperty.call(rightRecord, key) && structurallyEqual(leftRecord[key], rightRecord[key]));
}
export function currentBottomGameSnapshot(): BottomGameSnapshot {
const state = useGameStore.getState();
return {
bossId: state.bossId,
paused: state.paused,
healerClassId: state.healerClassId,
phase: state.phase,
round: state.round,
time: state.time,
party: state.party,
boss: state.boss,
additionalBosses: state.additionalBosses,
partyPositions: state.partyPositions,
bossMotion: state.bossMotion,
partyCombat: state.partyCombat,
mana: state.mana,
maxMana: state.maxMana,
selectedMemberId: state.selectedMemberId,
cooldowns: state.cooldowns,
globalCooldownUntil: state.globalCooldownUntil,
activeTab: state.activeTab,
selectedItemId: state.selectedItemId,
inventory: state.inventory,
playerPosition: state.playerPosition,
activeCast: state.activeCast,
barrier: state.barrier,
};
}
export function diffBottomGameSnapshot(
previous: BottomGameSnapshot | undefined,
next: BottomGameSnapshot,
): Partial<BottomGameSnapshot> {
if (!previous) return next;
const changes: Partial<BottomGameSnapshot> = {};
for (const key of BOTTOM_GAME_SNAPSHOT_KEYS) {
if (!structurallyEqual(previous[key], next[key])) Object.assign(changes, { [key]: next[key] });
}
return changes;
}
+19 -5
View File
@@ -2,7 +2,14 @@ import { useEffect } from "react";
import { emitControllerToken, setExternalControllerMovement, subscribeControllerToken } from "../input/controller";
import { getFrontendSnapshot, useFrontendStore } from "../frontend/store";
import { useGameStore } from "../game/store";
import { createDualScreenChannel, currentGameSnapshot, receiveAuthoritativeMessage, type DualScreenMessage } from "./dualScreenSync";
import {
createDualScreenChannel,
currentBottomGameSnapshot,
diffBottomGameSnapshot,
receiveAuthoritativeMessage,
type BottomGameSnapshot,
type DualScreenMessage,
} from "./dualScreenSync";
import { forceBothThorDisplays, listenForDisplayDisconnect, shouldOwnNativeDisplays } from "./nativeDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
@@ -38,25 +45,30 @@ export function useAuthoritativeDualScreenSync() {
let companionReady = false;
let receivingRelayedToken = false;
let topTokenSequence = 0;
let lastPublishedGame: BottomGameSnapshot | undefined;
const publish = (includeFrontend: boolean) => {
const hunter = latest.activeSlotId
? latest.slots.find((slot) => slot.id === latest.activeSlotId)?.local ?? null
: null;
const snapshot = latest.screen === "game" ? currentBottomGameSnapshot() : undefined;
const game = snapshot ? diffBottomGameSnapshot(lastPublishedGame, snapshot) : undefined;
lastPublishedGame = snapshot;
channel.postMessage({
type: "app-state",
screen: latest.screen,
hunterName: hunter?.hunterName ?? null,
notice: latest.notice,
frontend: includeFrontend ? getFrontendSnapshot() : undefined,
game: latest.screen === "game" ? currentGameSnapshot() : undefined,
game,
} satisfies DualScreenMessage);
};
// Player motion and the simulation can update the same store several times per
// frame interval. One 10 Hz companion snapshot is enough for tactical UI and
// avoids repeatedly structured-cloning the full game state across displays.
// Player motion and simulation can update the same store several times per
// frame interval. The lower display receives a 10 Hz tactical projection and
// only fields that changed semantically since the last publication.
const gamePublisher = createRateLimitedPublisher(() => publish(false), GAME_SYNC_INTERVAL_MS);
const unsubscribeFrontend = useFrontendStore.subscribe((state) => {
latest = state;
if (state.screen !== "game") lastPublishedGame = undefined;
if (companionReady) publish(true);
});
const unsubscribeGame = useGameStore.subscribe(() => {
@@ -74,9 +86,11 @@ export function useAuthoritativeDualScreenSync() {
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "companion-ready") {
companionReady = true;
lastPublishedGame = undefined;
publish(true);
} else if (event.data.type === "companion-closing") {
companionReady = false;
lastPublishedGame = undefined;
gamePublisher.cancel();
setExternalControllerMovement({ x: 0, y: 0 });
} else if (event.data.type === "controller-token") {
+17 -3
View File
@@ -200,6 +200,12 @@ button:focus-visible {
inset: 0;
}
.scene-loading {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 42%, #17241f, #07110f 68%);
}
.top-vignette {
position: absolute;
inset: 0;
@@ -1897,8 +1903,12 @@ button:focus-visible {
.mode-hero p { margin: 0; color: #98aca4; font-size: 13px; line-height: 1.45; }
.mode-hero > b { display: block; margin-top: 15px; color: #cbd9d4; font-size: 10px; font-weight: 600; }
.boss-picker { position: absolute; top: 84px; right: 38px; left: 38px; display: grid; gap: 4px; }
.boss-picker > span { color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.boss-choice-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; }
.boss-picker-heading { min-height: 18px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.boss-picker-heading > span { color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.boss-picker-heading > div { display: flex; gap: 4px; }
.boss-picker-heading button { min-width: 72px; padding: 2px 6px; border: 1px solid var(--line); color: #a9bbb4; background: rgba(6,18,16,0.82); font-size: 7px; text-transform: uppercase; }
.boss-picker-heading button:disabled { opacity: 0.32; }
.boss-choice-grid { display: grid; grid-template-columns: repeat(var(--boss-grid-columns), minmax(0, 1fr)); grid-template-rows: repeat(var(--boss-grid-rows), minmax(40px, auto)); grid-auto-flow: column; gap: 4px; }
.boss-choice { min-height: 40px; display: grid; grid-template-columns: 26px 1fr 14px; align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid var(--line); color: #dce8e3; background: rgba(6,18,16,0.82); text-align: left; }
.boss-choice > i { width: 24px; height: 24px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--boss-accent) 55%, transparent); border-radius: 50%; color: var(--boss-accent); font-size: 11px; font-style: normal; }
.boss-choice > span { display: grid; min-width: 0; }
@@ -2049,7 +2059,9 @@ button:focus-visible {
.mode-hero > span, .mode-hero > b { margin-top: 4px; font-size: 5px; }
.boss-picker { top: 54px; right: 14px; left: 14px; gap: 3px; }
.boss-choice-grid { grid-template-rows: repeat(var(--boss-grid-rows), minmax(27px, auto)); gap: 3px; }
.boss-picker > span { font-size: 4px; }
.boss-picker-heading { min-height: 11px; }
.boss-picker-heading > span { font-size: 4px; }
.boss-picker-heading button { min-width: 42px; padding: 1px 3px; font-size: 4px; }
.boss-choice { min-height: 27px; grid-template-columns: 18px 1fr 9px; gap: 4px; padding: 3px 4px; }
.boss-choice > i { width: 16px; height: 16px; font-size: 7px; }
.boss-choice strong { font-size: 6px; }
@@ -2077,6 +2089,8 @@ button:focus-visible {
.difficulty-picker button strong { font-size: 8px; }
.difficulty-picker button small { color: #71867e; font-size: 6px; }
.difficulty-picker button.is-selected { border-color: var(--gold); color: var(--gold-strong); background: rgba(96,76,27,.22); }
.mode-dungeons .difficulty-picker { bottom: 75px; }
.mode-dungeons .mode-launch { bottom: 68px; }
.mode-loot-preview { position: absolute; right: 5.5%; bottom: 89px; left: 5.5%; display: grid; padding: 9px 12px; border-left: 2px solid var(--gold); background: rgba(69,55,19,.14); }
.mode-loot-preview span { color: var(--gold); font-size: 7px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.mode-loot-preview b { font-size: 10px; }