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:
1 parent
aa1cda8245
commit
85086e31b2
6 files changed
+443
-5
No files matched your search
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,17 +393,42 @@
|
||||
// mount instead would flag someone who bounced after a second as
|
||||
// having seen it, and they'd never get it.
|
||||
markVisited();
|
||||
}, 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 = new URLSearchParams(window.location.search).get('tour');
|
||||
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);
|
||||
}
|
||||
}, entrance.durationMs + 300);
|
||||
life.add(() => clearTimeout(hintTimer));
|
||||
// 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
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user