feat: audit phase 6 (T4) — node allocation, the Path of Exile payoff

The site borrowed PoE's visual language — passive-tree layout, plasma
connections, an RPG stats panel — without any of its mechanic. Nodes were
purely decorative. This is the interaction the concept was promising.

- New utils/allocation.ts: path-to-root walking, allocate/deallocate, edge
  state, and URL encode/decode. Pure, 17 unit tests.
- Allocation is path-connected like the game: allocating a leaf brings its
  whole path, and deallocating a node removes its descendants so nothing is
  ever orphaned. pathToRoot is cycle-safe, and a test asserts every real node
  reaches the origin.
- New pixi/allocation.ts: the path reads as "lit" by dimming everything else
  rather than brightening the selection — the tree is already near its
  brightness ceiling, so recession is the only headroom. Nodes dim via alpha
  (nothing tweens it after the entrance); connections dim via tint, because
  their alpha is driven by the permanent plasma pulse and writing to it would
  fight the animation every frame.
- Shareable via ?build=. Encoded as node ids, not indices: a bitset would be
  shorter, but any edit to data.ts would silently re-point every previously
  shared link at the wrong nodes. Decoding re-derives each path, so a
  hand-edited or partially-stale link can't produce a broken build, and
  unknown ids are dropped rather than throwing.
- Allocation hangs off a button in the modal rather than the node click,
  which already opens that modal. BuildBar shows the count with share/reset;
  share falls back to rewriting the address bar when the clipboard is blocked.

Two bugs found while verifying:
- Both deep links (?build and ?tour) had ended up nested inside the
  first-visit coach-hint setTimeout, so a shared link did nothing at all for
  a returning visitor and waited out the whole intro for everyone else —
  backwards, since a shared link is precisely the cold-arrival case. Hoisted
  to run immediately, unconditionally. A ?tour link now also skips the intro.
- A restored ?build= was wiped by the entrance, which tweens every node's
  alpha to 1 after the deep link applies. The result was a full-brightness
  tree with a counter claiming 4 allocated. Now re-applied once the entrance
  finishes.

Verified in the production build: allocating TypeScript lights
origin -> Skills -> Languages -> TypeScript and dims the rest; the counter
reads 4; the generated link restores exactly that state on a cold load; Reset
clears it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TehRiehlDealandClaude Opus 5 committed 2026-08-09 02:01:53 -07:00
1 parent aa1cda8245
commit 85086e31b2
6 files changed
+448 -10

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
// Controls for the allocated path: how many nodes, share, reset.
const {
count = 0,
onReset,
onShare
}: {
count?: number;
onReset: () => void;
onShare: () => Promise<boolean> | boolean;
} = $props();
let copied = $state(false);
async function share() {
copied = await onShare();
if (copied) setTimeout(() => (copied = false), 2000);
}
</script>
{#if count > 0}
<div class="build-bar" role="status" aria-live="polite">
<span class="count"><strong>{count}</strong> allocated</span>
<button onclick={share}>{copied ? '✓ Link copied' : 'Share build'}</button>
<button class="reset" onclick={onReset}>Reset</button>
</div>
{/if}
<style>
.build-bar {
position: fixed;
top: var(--space-5);
right: var(--space-5);
z-index: var(--z-panel-toggle);
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid rgba(255, 215, 0, 0.4);
border-radius: var(--radius-pill);
background: rgba(10, 10, 20, 0.85);
font-size: 0.78rem;
}
.count {
color: var(--text-muted);
}
.count strong {
color: var(--color-gold);
font-variant-numeric: tabular-nums;
}
button {
padding: 0.25rem 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.72rem;
cursor: pointer;
}
button:hover {
border-color: var(--color-gold);
}
.reset {
border-color: transparent;
color: var(--text-subtle);
}
@media (max-width: 767px) {
.build-bar {
top: auto;
bottom: calc(var(--space-5) + 3.2rem);
right: var(--space-2);
}
}
</style>
+42 -1
View File
@@ -6,8 +6,13 @@
node,
visible,
onClose,
onDismiss
onDismiss,
allocated = false,
onToggleAllocate
}: {
allocated?: boolean;
/** Allocate/remove this node and its path. */
onToggleAllocate?: (id: string) => void;
node: TreeNode;
visible: boolean;
/** Explicit close (✕ button) — always immediate. */
@@ -84,6 +89,19 @@
>
<button class="close-btn" onclick={onClose} aria-label="Close dialog">✕</button>
{#if onToggleAllocate}
<!-- Allocation lives on a button rather than the node click, which
already opens this dialog. -->
<button
class="allocate-btn"
class:on={allocated}
onclick={() => onToggleAllocate(node.id)}
aria-pressed={allocated}
>
{allocated ? '✓ Allocated' : '⊕ Allocate path'}
</button>
{/if}
<h2 id="modal-title">
{#if node.icon}
<img src={node.icon} alt="" class="modal-icon" />
@@ -275,6 +293,29 @@
backdrop-filter: blur(4px);
}
.allocate-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-bottom: var(--space-3);
padding: 0.3rem 0.75rem;
border: 1px solid rgba(255, 215, 0, 0.45);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-gold);
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.allocate-btn:hover {
background: rgba(255, 215, 0, 0.12);
}
.allocate-btn.on {
background: rgba(255, 215, 0, 0.18);
}
.modal:focus {
outline: none;
}
+76 -9
View File
@@ -14,6 +14,9 @@
import CoachHint from './CoachHint.svelte';
import SearchPalette from './SearchPalette.svelte';
import JourneyMode from './JourneyMode.svelte';
import BuildBar from './BuildBar.svelte';
import { allocate, deallocate, encodeBuild, decodeBuild } from '$lib/utils/allocation';
import { updateAllocation } from '$lib/pixi/allocation';
import { getJourneyStops, findStopIndex } from '$lib/utils/journey';
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
import {
@@ -55,6 +58,34 @@
let journeyActive = $state(false);
let journeyStartIndex = $state(0);
// Allocated path. SvelteSet so mutation is tracked — a plain Set is not
// proxied by $state, the same trap that silently broke the stats panel.
const allocated = new SvelteSet<string>();
function toggleAllocate(id: string) {
const next = allocated.has(id) ? deallocate(allocated, id) : allocate(allocated, id);
allocated.clear();
for (const nodeId of next) allocated.add(nodeId);
}
function resetBuild() {
allocated.clear();
}
async function shareBuild(): Promise<boolean> {
const url = new URL(window.location.href);
url.searchParams.set('build', encodeBuild(allocated));
try {
await navigator.clipboard.writeText(url.toString());
return true;
} catch {
// Clipboard can be blocked (insecure origin, permissions). Put the link
// in the address bar so it is still copyable by hand.
history.replaceState(null, '', url);
return false;
}
}
function startJourney(index = 0) {
journeyStartIndex = index;
journeyActive = true;
@@ -362,18 +393,43 @@
// mount instead would flag someone who bounced after a second as
// having seen it, and they'd never get it.
markVisited();
// ?tour=peraton deep-links straight into that stop of the journey.
const tourParam = new URLSearchParams(window.location.search).get('tour');
if (tourParam !== null) {
const stops = getJourneyStops();
const found = findStopIndex(stops, tourParam);
// A bare ?tour starts from the beginning.
startJourney(found === -1 ? 0 : found);
}
}, entrance.durationMs + 300);
life.add(() => clearTimeout(hintTimer));
}
// Deep links, applied immediately and regardless of whether this is a
// first or return visit. These were previously nested inside the
// first-visit hint timer, so a shared ?build= or ?tour= link did nothing
// for a returning visitor and waited out the whole intro for everyone
// else — backwards, since a shared link is exactly the cold-arrival case.
const params = new URLSearchParams(window.location.search);
// ?build=... restores a shared allocation.
const shared = decodeBuild(params.get('build'));
if (shared.size) {
for (const id of shared) allocated.add(id);
updateAllocation(controller, allocated);
// The entrance tweens every node's alpha to 1, which lands after this
// and wipes the dimming. Re-apply once it has finished, or the shared
// build shows a full-brightness tree with a counter that disagrees.
const repaint = setTimeout(
() => updateAllocation(controller, allocated),
entrance.durationMs + 100
);
life.add(() => clearTimeout(repaint));
}
// ?tour=peraton deep-links straight into that stop of the journey.
const tourParam = params.get('tour');
if (tourParam !== null) {
const stops = getJourneyStops();
const found = findStopIndex(stops, tourParam);
// A bare ?tour starts from the beginning.
startJourney(found === -1 ? 0 : found);
// Don't make someone who followed a tour link sit through the intro.
skipIntro();
}
} catch (err) {
// No WebGL, or a texture failed to load. Don't strand the user on a
// spinner that never resolves — reveal the text version instead.
@@ -391,6 +447,13 @@
return () => life.dispose();
});
// Repaint allocation styling whenever the build changes.
$effect(() => {
// Touch .size so this re-runs on every mutation of the SvelteSet.
void allocated.size;
if (controllerRef) updateAllocation(controllerRef, allocated);
});
// Live tooltip element, so flip decisions use its real measured size rather
// than a hardcoded 280x120 guess (descriptions vary in length, so the old
// height constant mis-flipped tall tooltips near the viewport bottom).
@@ -445,6 +508,8 @@
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
<Hud showSkip={introPlaying} onSkip={skipIntro} onStartJourney={() => startJourney(0)} />
<BuildBar count={allocated.size} onReset={resetBuild} onShare={shareBuild} />
<JourneyMode
active={journeyActive}
startIndex={journeyStartIndex}
@@ -499,6 +564,8 @@
visible={modalVisible}
onClose={closeModal}
onDismiss={dismissModal}
allocated={allocated.has(selectedNode.id)}
onToggleAllocate={toggleAllocate}
/>
{/if}
+40
View File
@@ -0,0 +1,40 @@
// Visual treatment for allocated nodes.
//
// The path reads as "lit" by dimming everything else rather than brightening
// the selection — the tree is already near its brightness ceiling, so
// recession is the only headroom available.
import { isEdgeAllocated } from '$lib/utils/allocation';
import type { SkillTreeController } from './controller';
/** Alpha for nodes outside the current build. */
const DIMMED_NODE_ALPHA = 0.22;
/** Tint applied to connections outside the current build. */
const DIMMED_LINE_TINT = 0x2a2a35;
/**
* Apply (or clear) allocation styling.
*
* Nodes are dimmed via `alpha`, which nothing tweens after the entrance
* finishes. Connections are dimmed via `tint` instead — their alpha is driven
* by the permanent plasma-beam pulse, so writing to it here would fight the
* animation every frame.
*/
export function updateAllocation(ctrl: SkillTreeController, allocated: ReadonlySet<string>) {
const active = allocated.size > 0;
for (const obj of ctrl.nodeObjects) {
const lit = !active || allocated.has(obj.node.id);
const alpha = lit ? 1 : DIMMED_NODE_ALPHA;
obj.graphics.alpha = alpha;
obj.glowGraphics.alpha = lit ? 1 : DIMMED_NODE_ALPHA * 0.5;
if (obj.label) obj.label.alpha = alpha;
}
for (const line of ctrl.lineObjects) {
const lit = !active || isEdgeAllocated(allocated, line.node);
const tint = lit ? 0xffffff : DIMMED_LINE_TINT;
line.graphics.tint = tint;
line.glowGraphics.tint = tint;
line.plasmaBeam.tint = tint;
}
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import {
pathToRoot,
allocate,
deallocate,
isEdgeAllocated,
encodeBuild,
decodeBuild
} from './allocation';
import { treeNodes, type TreeNode } from '$lib/data';
const node = (id: string, parentId: string | null): TreeNode =>
({
id,
label: id,
description: '',
x: 0,
y: 0,
parentId,
category: 'skills',
size: 'small'
}) as TreeNode;
const chain = [node('root', null), node('mid', 'root'), node('leaf', 'mid')];
describe('pathToRoot', () => {
it('walks a leaf up to the root inclusive', () => {
expect(pathToRoot('leaf', chain)).toEqual(['leaf', 'mid', 'root']);
});
it('returns just the root for the root', () => {
expect(pathToRoot('root', chain)).toEqual(['root']);
});
it('returns nothing for an unknown id', () => {
expect(pathToRoot('nope', chain)).toEqual([]);
});
it('terminates on a cyclic parent chain', () => {
const cyclic = [node('a', 'b'), node('b', 'a')];
expect(pathToRoot('a', cyclic).length).toBe(2);
});
it('reaches the origin for every real node', () => {
for (const n of treeNodes) {
expect(pathToRoot(n.id).at(-1)).toBe('origin');
}
});
});
describe('allocate / deallocate', () => {
it('allocating a leaf brings its whole path', () => {
expect([...allocate([], 'leaf', chain)].sort()).toEqual(['leaf', 'mid', 'root']);
});
it('is idempotent', () => {
const once = allocate([], 'leaf', chain);
expect([...allocate(once, 'leaf', chain)].sort()).toEqual([...once].sort());
});
it('deallocating removes descendants so nothing is orphaned', () => {
const full = allocate([], 'leaf', chain);
expect([...deallocate(full, 'mid', chain)]).toEqual(['root']);
});
it('deallocating a leaf leaves its ancestors alone', () => {
const full = allocate([], 'leaf', chain);
expect([...deallocate(full, 'leaf', chain)].sort()).toEqual(['mid', 'root']);
});
it('does not mutate the input', () => {
const start = new Set(['root']);
allocate(start, 'leaf', chain);
expect([...start]).toEqual(['root']);
});
});
describe('isEdgeAllocated', () => {
it('is true only when both ends are allocated', () => {
const allocated = new Set(['root', 'mid']);
expect(isEdgeAllocated(allocated, chain[1])).toBe(true);
expect(isEdgeAllocated(allocated, chain[2])).toBe(false);
});
it('is false for a root node, which has no edge', () => {
expect(isEdgeAllocated(new Set(['root']), chain[0])).toBe(false);
});
});
describe('encodeBuild / decodeBuild', () => {
it('round-trips', () => {
const build = allocate([], 'lang-typescript');
expect(decodeBuild(encodeBuild(build))).toEqual(build);
});
it('is order-independent, so the same build is always the same link', () => {
expect(encodeBuild(['b', 'a', 'c'])).toBe(encodeBuild(['c', 'b', 'a']));
});
it('returns an empty build for empty or missing input', () => {
expect(decodeBuild(null).size).toBe(0);
expect(decodeBuild('').size).toBe(0);
});
it('discards ids that no longer exist', () => {
expect(decodeBuild('lang-typescript~was-deleted').has('was-deleted')).toBe(false);
expect(decodeBuild('lang-typescript~was-deleted').has('lang-typescript')).toBe(true);
});
it('repairs a hand-edited link that skips ancestors', () => {
// Only the leaf in the URL — the path must come back with it.
const decoded = decodeBuild('lang-typescript');
expect(decoded.has('origin')).toBe(true);
expect(decoded.has('languages')).toBe(true);
});
});
+93
View File
@@ -0,0 +1,93 @@
// ==========================================================================
// ALLOCATION — Path-of-Exile style node allocation. Pure; no DOM, no PixiJS.
// ==========================================================================
import { treeNodes, type TreeNode } from '$lib/data';
/**
* Every node from `id` up to the root, inclusive.
*
* Allocation is path-connected like the game it borrows from: you cannot
* light a leaf without the branch that reaches it. Cycle-safe — a malformed
* parent chain terminates rather than looping forever.
*/
export function pathToRoot(id: string, nodes: TreeNode[] = treeNodes): string[] {
const byId = new Map(nodes.map((n) => [n.id, n]));
const path: string[] = [];
const seen = new Set<string>();
let current = byId.get(id);
while (current && !seen.has(current.id)) {
seen.add(current.id);
path.push(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path;
}
/**
* Allocate a node: adds it and its whole path to the root.
* Returns a new set — callers hold reactive state.
*/
export function allocate(allocated: Iterable<string>, id: string, nodes = treeNodes): Set<string> {
const next = new Set(allocated);
for (const nodeId of pathToRoot(id, nodes)) next.add(nodeId);
return next;
}
/**
* Deallocate a node and everything downstream of it, since orphaned children
* would violate path-connectedness.
*/
export function deallocate(
allocated: Iterable<string>,
id: string,
nodes = treeNodes
): Set<string> {
const next = new Set(allocated);
// Collect descendants breadth-first.
const doomed = [id];
while (doomed.length) {
const current = doomed.pop() as string;
next.delete(current);
for (const node of nodes) {
if (node.parentId === current && next.has(node.id)) doomed.push(node.id);
}
}
return next;
}
/** Is this edge (child → its parent) fully allocated? */
export function isEdgeAllocated(allocated: ReadonlySet<string>, node: TreeNode): boolean {
return !!node.parentId && allocated.has(node.id) && allocated.has(node.parentId);
}
/**
* Encode for a shareable URL.
*
* Node ids rather than indices: a bitset would be shorter, but any edit to
* data.ts would silently re-point every previously shared link at the wrong
* nodes. Ids only break if an id itself is renamed, which is both rarer and
* fails loudly (the node is simply skipped).
*/
export function encodeBuild(allocated: Iterable<string>): string {
return [...allocated].sort().join('~');
}
/** Decode a ?build= value, discarding anything that no longer exists. */
export function decodeBuild(value: string | null, nodes: TreeNode[] = treeNodes): Set<string> {
if (!value) return new Set();
const valid = new Set(nodes.map((n) => n.id));
const result = new Set<string>();
for (const id of value.split('~')) {
if (!valid.has(id)) continue;
// Re-derive the path so a hand-edited link can't produce a broken build.
for (const nodeId of pathToRoot(id, nodes)) result.add(nodeId);
}
return result;
}