Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

+7 -7
View File
@@ -13,6 +13,7 @@ import { useCombatStore } from "../game/combatStore";
import {
combatEffectStyle,
preloadCombatEffectManifest,
effectiveCombatVfxMode,
subscribeCombatPresentation,
type CombatPresentationEvent,
type CombatVfxQuality,
@@ -20,6 +21,7 @@ import {
import {
combatAudioOutputEnabled,
configureCombatAudio,
combatMediaPreloadEnabled,
playCombatAudio,
resetCombatAudio,
setCombatAudioListener,
@@ -254,17 +256,18 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
const camera = useThree((state) => state.camera);
const forward = useMemo(() => new Vector3(), []);
const audioOutputEnabled = combatAudioOutputEnabled(settings, masterVolume);
const vfxMode = effectiveCombatVfxMode(settings);
useEffect(() => {
if (!import.meta.env.DEV) return;
document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY] = JSON.stringify({
mode: settings.vfxMode,
mode: vfxMode,
quality: effectiveQuality,
systems: effects.length,
activeLights: 0,
allocatedLights: 0,
});
}, [effectiveQuality, effects.length, settings.vfxMode]);
}, [effectiveQuality, effects.length, vfxMode]);
useEffect(() => () => {
if (import.meta.env.DEV) delete document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY];
@@ -274,10 +277,7 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
settingsRef.current = settings;
qualityRef.current = safeGraphics && settings.vfxQuality === "high" ? "low" : settings.vfxQuality;
configureCombatAudio(settings, masterVolume);
if (
qualityRef.current !== "off"
|| combatAudioOutputEnabled(settings, masterVolume)
) void preloadCombatEffectManifest();
if (combatMediaPreloadEnabled(settings, masterVolume)) void preloadCombatEffectManifest();
setEffects((current) => capCombatEffects(current.filter(({ event }) => (
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
)), qualityRef.current));
@@ -333,7 +333,7 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
return (
<group name="combat-effects">
{effects.map((effect) => (
settings.vfxMode === "original" ? (
vfxMode === "original" ? (
<OriginalCombatEffect
key={`original:${effect.key}`}
event={effect.event}
+96 -1
View File
@@ -1,12 +1,107 @@
import { Fragment, useMemo } from "react";
import { DoubleSide } from "three";
import { useCombatStore } from "../game/combatStore";
import { useCombatStore, type EnemyActiveCast } from "../game/combatStore";
import type { EnemyAttackDefinition, EnemyDamageSchool } from "../game/enemyAttacks";
import {
enemyTelegraphOrigin,
enemyTelegraphYaw,
} from "../game/enemyTelegraphGeometry";
const TELEGRAPH_COLORS: Readonly<Record<EnemyDamageSchool, string>> = Object.freeze({
physical: "#f2b45e",
arcane: "#c68cff",
fire: "#ff684e",
frost: "#75cfff",
nature: "#8ddf68",
shadow: "#bd79e8",
holy: "#ffe47a",
});
function EnemyCastTelegraph({
cast,
attack,
}: {
readonly cast: EnemyActiveCast;
readonly attack: EnemyAttackDefinition;
}) {
const telegraph = attack.telegraph;
if (!telegraph) return null;
const placement = {
sourcePosition: cast.sourcePosition,
targetPosition: cast.targetPosition,
sourceYaw: cast.sourceYaw,
};
const origin = enemyTelegraphOrigin(telegraph, placement);
const position = [origin[0], origin[1] + 0.055, origin[2]] as const;
const color = TELEGRAPH_COLORS[attack.school];
if (telegraph.shape === "circle") {
const radius = telegraph.radius ?? 1;
return (
<group position={position} name={`enemy-telegraph-${cast.attackId}`}>
<mesh rotation={[-Math.PI / 2, 0, 0]} renderOrder={6}>
<circleGeometry args={[radius, 64]} />
<meshBasicMaterial color={color} transparent opacity={0.18} depthWrite={false} side={DoubleSide} />
</mesh>
<mesh position={[0, 0.008, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={7}>
<ringGeometry args={[Math.max(0.1, radius - 0.18), radius, 64]} />
<meshBasicMaterial color={color} transparent opacity={0.9} depthWrite={false} side={DoubleSide} />
</mesh>
</group>
);
}
const yaw = enemyTelegraphYaw(placement);
if (telegraph.shape === "cone") {
const length = telegraph.length ?? 1;
const angle = ((telegraph.angleDegrees ?? 90) * Math.PI) / 180;
const start = -Math.PI / 2 - angle / 2;
return (
<group position={position} rotation={[0, yaw, 0]} name={`enemy-telegraph-${cast.attackId}`}>
<mesh rotation={[-Math.PI / 2, 0, 0]} renderOrder={6}>
<circleGeometry args={[length, 48, start, angle]} />
<meshBasicMaterial color={color} transparent opacity={0.2} depthWrite={false} side={DoubleSide} />
</mesh>
<mesh position={[0, 0.008, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={7}>
<ringGeometry args={[Math.max(0.1, length - 0.18), length, 48, 1, start, angle]} />
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} side={DoubleSide} />
</mesh>
</group>
);
}
const length = telegraph.length ?? 1;
const width = telegraph.width ?? 1;
return (
<group position={position} rotation={[0, yaw, 0]} name={`enemy-telegraph-${cast.attackId}`}>
<mesh position={[0, 0, length / 2]} rotation={[Math.PI / 2, 0, 0]} renderOrder={6}>
<planeGeometry args={[width + 0.22, length + 0.22]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} side={DoubleSide} />
</mesh>
<mesh position={[0, 0.009, length / 2]} rotation={[Math.PI / 2, 0, 0]} renderOrder={7}>
<planeGeometry args={[width, length]} />
<meshBasicMaterial color={color} transparent opacity={0.2} depthWrite={false} side={DoubleSide} />
</mesh>
</group>
);
}
/** Presentation-only rings; placement and ticking remain in the combat runtime. */
export function GroundEffectTelegraphs() {
const effects = useCombatStore((state) => state.areaEffects);
const pending = useCombatStore((state) => state.pendingGroundTarget);
const mobs = useCombatStore((state) => state.mobs);
const enemyCasts = useMemo(() => Object.values(mobs).flatMap((mob) => {
if (!mob.engaged || mob.dead || !mob.activeCast) return [];
const attack = mob.attacks.find((candidate) => candidate.id === mob.activeCast?.attackId);
return attack?.telegraph ? [{ mobId: mob.id, cast: mob.activeCast, attack }] : [];
}), [mobs]);
return (
<group name="combat-ground-effects">
{enemyCasts.map(({ mobId, cast, attack }) => (
<Fragment key={`${mobId}:${cast.attackId}:${cast.startedAt}`}>
<EnemyCastTelegraph cast={cast} attack={attack} />
</Fragment>
))}
{pending ? (
<mesh
name="ground-target-reticle"
+100
View File
@@ -39,6 +39,7 @@ import {
} from "../game/partyRuntimeRegistry";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import {
getMobRuntimeTransform,
registerMobRuntime,
updateMobRuntimePosition,
} from "../game/mobRuntimeRegistry";
@@ -1450,6 +1451,100 @@ function StaticPopulationCluster({
);
}
function SummonedPopulationMember({
instanceId,
entity,
active,
useOriginalModels,
}: {
readonly instanceId: string;
readonly entity: PopulationEntityDefinition;
readonly active: boolean;
readonly useOriginalModels: boolean;
}) {
const groupRef = useRef<Group>(null);
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
const positionRef = useRef<MutableMobVector3>([...initial]);
useFrame((_, delta) => {
const mob = useCombatStore.getState().mobs[instanceId];
if (!mob || mob.dead || mob.despawned) return;
const player = useGameStore.getState().playerPosition;
const targetPosition = mobTargetPosition(mob.targetActorId, player);
const position = positionRef.current;
const now = Date.now();
const movementLocked = mob.statuses.some((status) => status.endsAt > now
&& MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind));
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
if (!movementLocked && feared) {
stepFearedMobAway(
position,
targetPosition,
mob.homePosition ?? initial,
maximumStep * 1.1,
mob.leashRange,
position,
);
} else if (!movementLocked && mob.combatPhase === "chasing") {
stepToward(position, targetPosition, maximumStep);
}
const facingSign = feared && !movementLocked ? -1 : 1;
const yaw = Math.atan2(
(targetPosition[0] - position[0]) * facingSign,
(targetPosition[2] - position[2]) * facingSign,
);
if (groupRef.current) {
groupRef.current.position.set(position[0], position[1], position[2]);
groupRef.current.rotation.y = yaw;
}
updateMobRuntimePosition(instanceId, position[0], position[1], position[2], yaw);
});
return (
<group ref={groupRef} position={initial} name={`summoned-mob-${instanceId}`}>
<ProxyMob
definition={entity}
instanceId={instanceId}
active={active}
moving
showLabel={false}
useOriginalModels={useOriginalModels}
/>
</group>
);
}
function SummonedEnemyPopulation({
entities,
active,
useOriginalModels,
}: {
readonly entities: PopulationDefinitionMap;
readonly active: boolean;
readonly useOriginalModels: boolean;
}) {
const mobs = useCombatStore((state) => state.mobs);
const summons = useMemo(() => Object.values(mobs).flatMap((mob) => {
if (!mob.summonedBy || mob.serverEntry === null || mob.despawned) return [];
const entity = entities[`entry-${mob.serverEntry}`]
?? Object.values(entities).find((candidate) => candidate.combat?.serverEntry === mob.serverEntry);
return entity ? [{ instanceId: mob.id, entity }] : [];
}), [entities, mobs]);
return (
<group name="summoned-enemy-population">
{summons.map(({ instanceId, entity }) => (
<SummonedPopulationMember
key={instanceId}
instanceId={instanceId}
entity={entity}
active={active}
useOriginalModels={useOriginalModels}
/>
))}
</group>
);
}
export interface MobPopulationProps {
readonly entities: PopulationDefinitionMap;
readonly roamingPacks: readonly MobPackDefinition[];
@@ -1512,6 +1607,11 @@ export function MobPopulation({
useOriginalModels={useOriginalModels}
/>
))}
<SummonedEnemyPopulation
entities={entities}
active={active}
useOriginalModels={useOriginalModels}
/>
</group>
);
}
+7 -1
View File
@@ -1,6 +1,6 @@
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef, type CSSProperties } from "react";
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { Group } from "three";
import {
classById,
@@ -17,6 +17,7 @@ import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { useCombatStore } from "../game/combatStore";
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
import { usePartyStore } from "../game/partyStore";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
const labelStyle: CSSProperties = {
color: "#d8f6ff",
@@ -60,6 +61,11 @@ function RemoteOnlinePlayer({ player, active }: {
const actorId = onlinePlayerActorId(player.accountId);
const selected = useCombatStore((state) => state.selectedFriendlyActorId === actorId);
useEffect(() => {
const root = rootRef.current;
return root ? registerCombatEffectAnchor(actorId, root) : undefined;
}, [actorId]);
useFrame((_, delta) => {
const root = rootRef.current;
if (!root) return;
+48 -15
View File
@@ -1,5 +1,5 @@
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
BufferAttribute,
@@ -15,6 +15,7 @@ import {
Texture,
TextureLoader,
Vector2,
Vector3,
} from "three";
import {
combatOriginalPhaseAssets,
@@ -23,6 +24,7 @@ import {
type CombatPresentationEvent,
type CombatVfxQuality,
} from "../game/combatPresentation";
import { fetchCombatMediaAsset } from "../game/combatMediaPack";
import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
import { getMobPosition } from "../game/mobRuntimeRegistry";
@@ -44,6 +46,7 @@ import {
parseOriginalCombatMeshGraph,
type OriginalCombatMeshGraph,
} from "./originalCombatMeshRuntime";
import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph;
@@ -201,7 +204,7 @@ async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
// Graph filenames are keyed to the immutable source M2, while the decoded
// browser schema can improve between releases. Revalidate the small JSON so
// an older cached decoder payload cannot fail the current strict parser.
const request = fetch(url, { cache: "no-cache" })
const request = fetchCombatMediaAsset(url)
.then((response) => response.ok
? response.json()
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
@@ -218,11 +221,18 @@ async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
async function loadTexture(url: string): Promise<Texture> {
const existing = textureRequestCache.get(url);
if (existing) return existing;
const request = textureLoader.loadAsync(url).then((texture) => {
texture.colorSpace = SRGBColorSpace;
texture.name = `original-combat-effect:${url}`;
texture.needsUpdate = true;
return texture;
const request = fetchCombatMediaAsset(url).then(async (response) => {
if (!response.ok) throw new Error(`Original effect texture request failed (${response.status}): ${url}`);
const objectUrl = URL.createObjectURL(await response.blob());
try {
const texture = await textureLoader.loadAsync(objectUrl);
texture.colorSpace = SRGBColorSpace;
texture.name = `original-combat-effect:${url}`;
texture.needsUpdate = true;
return texture;
} finally {
URL.revokeObjectURL(objectUrl);
}
});
textureRequestCache.set(url, request);
request.catch(() => textureRequestCache.delete(url));
@@ -446,7 +456,8 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
readonly active: boolean;
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) {
const billboardRef = useRef<Group>(null);
const camera = useThree((state) => state.camera);
const sourcePlaneRef = useRef<Group>(null);
const geometry = useMemo(() => {
const next = new BufferGeometry();
const sourcePositions = graph.geometry.positions;
@@ -457,7 +468,7 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
const positions = new Float32Array(sourcePositions.length);
const normals = new Float32Array(sourceNormals.length);
for (let index = 0; index < sourcePositions.length; index += 3) {
// Turn WoW's Z-up source plane into an upright, camera-facing local XY plane.
// Turn WoW's Z-up source plane into an upright local XY plane.
positions[index] = -sourcePositions[index + 1];
positions[index + 1] = sourcePositions[index + 2];
positions[index + 2] = sourcePositions[index] - xCenter;
@@ -501,9 +512,14 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
for (const material of materials) material.dispose();
}, [geometry, materials]);
useFrame(({ camera }) => {
useEffect(() => {
// WoW authored this mesh as a billboard. Resolve that orientation once at
// spawn so it stays readable without rotating whenever the camera moves.
sourcePlaneRef.current?.lookAt(camera.position);
}, [camera]);
useFrame(() => {
if (!active) return;
if (billboardRef.current) billboardRef.current.lookAt(camera.position);
const playback = originalCombatMeshPlaybackFrame(
graph,
playbackRef.current.elapsedMs,
@@ -517,8 +533,22 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
});
return (
<group ref={billboardRef}>
<mesh geometry={geometry} material={materials} renderOrder={20} frustumCulled={false} />
<group ref={sourcePlaneRef}>
<mesh
name="original-combat-static-source-plane"
geometry={geometry}
material={materials}
renderOrder={20}
frustumCulled={false}
/>
<mesh
name="original-combat-static-cross-plane"
geometry={geometry}
material={materials}
rotation={[0, Math.PI / 2, 0]}
renderOrder={20}
frustumCulled={false}
/>
</group>
);
});
@@ -533,6 +563,7 @@ function LoadedOriginalCombatEffect({
const elapsedRef = useRef(0);
const doneRef = useRef(false);
const groupRef = useRef<Group>(null);
const liveAnchorPosition = useMemo(() => new Vector3(), []);
const auraDecayStartedAtRef = useRef<number | null>(null);
const staticMeshPlaybackRef = useRef({ elapsedMs: 0, decaying: false });
const particleBudgets = useMemo(() => {
@@ -617,8 +648,10 @@ function LoadedOriginalCombatEffect({
);
} else {
const actorId = originalCombatAnchorActorId(event);
const live = liveActorPosition(actorId);
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
if (!updateCombatEffectAnchorPosition(actorId, groupRef.current, liveAnchorPosition)) {
const live = liveActorPosition(actorId);
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
}
}
}
const terminalDuration = projectile
+8 -1
View File
@@ -84,6 +84,7 @@ import { useManastormStore } from "../game/manastormStore";
import { registerCombatSpatialProvider } from "../game/combatSpatial";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5;
@@ -563,7 +564,13 @@ function PartyActors({
}
if (!lastHealthRef.current.has(member.id)) lastHealthRef.current.set(member.id, member.health);
actorRefs.current[index]?.position.set(position[0], position[1], position[2]);
return registerPartyRuntimePosition(member.id, position);
const runtimeCleanup = registerPartyRuntimePosition(member.id, position);
const actor = actorRefs.current[index];
const anchorCleanup = actor ? registerCombatEffectAnchor(member.id, actor) : undefined;
return () => {
runtimeCleanup();
anchorCleanup?.();
};
});
// Make the initial facing agree with the player instead of flashing at yaw zero.
for (const actor of actorRefs.current) {
+7 -1
View File
@@ -19,7 +19,7 @@ import {
consumeMouseLook,
readInputSnapshot,
} from "../game/inputManager";
import { useCombatStore } from "../game/combatStore";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import {
equipmentItemsForOwner,
@@ -59,6 +59,7 @@ import {
} from "../game/playerMovement";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
const PLAYER_RADIUS = 0.36;
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
@@ -145,6 +146,11 @@ function PlayerController({
world.removeCharacterController(characterController);
}, [characterController, world]);
useEffect(() => {
const avatar = avatarRef.current;
return avatar ? registerCombatEffectAnchor(PLAYER_AURA_ENTITY_ID, avatar) : undefined;
}, [avatarRef]);
useEffect(() => {
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
lastReported.current = [...spawn.footPosition];
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it } from "vitest";
import { Group, Vector3 } from "three";
import {
clearCombatEffectAnchorRegistry,
registerCombatEffectAnchor,
updateCombatEffectAnchorPosition,
} from "./combatEffectAnchorRegistry";
afterEach(() => clearCombatEffectAnchorRegistry());
describe("combat-effect visual anchors", () => {
it("copies the rendered actor position into the effect parent's local space", () => {
const world = new Group();
world.position.set(10, 2, -4);
const actor = new Group();
actor.position.set(3, 1, 5);
world.add(actor);
const effectParent = new Group();
effectParent.position.set(-2, 4, 1);
world.add(effectParent);
const effect = new Group();
effectParent.add(effect);
registerCombatEffectAnchor("party-tank", actor);
expect(updateCombatEffectAnchorPosition("party-tank", effect, new Vector3())).toBe(true);
expect(effect.getWorldPosition(new Vector3()).toArray()).toEqual(
actor.getWorldPosition(new Vector3()).toArray(),
);
});
it("keeps a replacement registration when an older actor unmounts", () => {
const first = new Group();
const second = new Group();
second.position.set(7, 8, 9);
const unregisterFirst = registerCombatEffectAnchor("player", first);
registerCombatEffectAnchor("player", second);
unregisterFirst();
const effect = new Group();
expect(updateCombatEffectAnchorPosition("player", effect, new Vector3())).toBe(true);
expect(effect.position.toArray()).toEqual([7, 8, 9]);
});
it("falls back cleanly when an actor has no mounted visual", () => {
const effect = new Group();
effect.position.set(1, 2, 3);
expect(updateCombatEffectAnchorPosition("missing", effect, new Vector3())).toBe(false);
expect(effect.position.toArray()).toEqual([1, 2, 3]);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { Vector3, type Object3D } from "three";
const anchors = new Map<string, Object3D>();
export function registerCombatEffectAnchor(actorId: string, object: Object3D): () => void {
anchors.set(actorId, object);
return () => {
if (anchors.get(actorId) === object) anchors.delete(actorId);
};
}
/**
* Copies an actor's current rendered world position into an effect's parent
* space. This follows the scene object directly instead of a throttled combat
* or network snapshot while deliberately avoiding inherited actor rotation.
*/
export function updateCombatEffectAnchorPosition(
actorId: string | undefined,
effect: Object3D,
worldPosition: Vector3,
): boolean {
if (!actorId) return false;
const anchor = anchors.get(actorId);
if (!anchor) return false;
anchor.getWorldPosition(worldPosition);
effect.parent?.worldToLocal(worldPosition);
effect.position.copy(worldPosition);
return true;
}
export function clearCombatEffectAnchorRegistry(): void {
anchors.clear();
}
@@ -10,6 +10,7 @@ const graphUrl = new URL(
"../../public/assets/game/combat-effects/original/wow335/priest/graphs/holydivineshield-state-base-83b519d78c12.json",
import.meta.url,
);
const componentSource = readFileSync(new URL("./OriginalCombatEffect.tsx", import.meta.url), "utf8");
describe("original combat static-mesh contract", () => {
it("parses the shipped Power Word: Shield mesh and its three original layers", () => {
@@ -77,6 +78,12 @@ describe("original combat static-mesh contract", () => {
expect(originalCombatMeshPlaybackFrame(graph, 1_100, true).complete).toBe(true);
});
it("locks the source shield at spawn and never billboards it during frame updates", () => {
expect(componentSource.match(/lookAt\(camera\.position\)/g)).toHaveLength(1);
expect(componentSource).not.toContain("useFrame(({ camera })");
expect(componentSource).toContain('name="original-combat-static-cross-plane"');
});
it("rejects geometry and draw references that leave the packaged source", () => {
const invalidIndex = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
((invalidIndex.geometry as Record<string, unknown>).indices as number[])[0] = 12;