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
+448
-10
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,
|
node,
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
onDismiss
|
onDismiss,
|
||||||
|
allocated = false,
|
||||||
|
onToggleAllocate
|
||||||
}: {
|
}: {
|
||||||
|
allocated?: boolean;
|
||||||
|
/** Allocate/remove this node and its path. */
|
||||||
|
onToggleAllocate?: (id: string) => void;
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
/** Explicit close (✕ button) — always immediate. */
|
/** Explicit close (✕ button) — always immediate. */
|
||||||
@@ -84,6 +89,19 @@
|
|||||||
>
|
>
|
||||||
<button class="close-btn" onclick={onClose} aria-label="Close dialog">✕</button>
|
<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">
|
<h2 id="modal-title">
|
||||||
{#if node.icon}
|
{#if node.icon}
|
||||||
<img src={node.icon} alt="" class="modal-icon" />
|
<img src={node.icon} alt="" class="modal-icon" />
|
||||||
@@ -275,6 +293,29 @@
|
|||||||
backdrop-filter: blur(4px);
|
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 {
|
.modal:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
import CoachHint from './CoachHint.svelte';
|
import CoachHint from './CoachHint.svelte';
|
||||||
import SearchPalette from './SearchPalette.svelte';
|
import SearchPalette from './SearchPalette.svelte';
|
||||||
import JourneyMode from './JourneyMode.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 { getJourneyStops, findStopIndex } from '$lib/utils/journey';
|
||||||
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
|
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +58,34 @@
|
|||||||
let journeyActive = $state(false);
|
let journeyActive = $state(false);
|
||||||
let journeyStartIndex = $state(0);
|
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) {
|
function startJourney(index = 0) {
|
||||||
journeyStartIndex = index;
|
journeyStartIndex = index;
|
||||||
journeyActive = true;
|
journeyActive = true;
|
||||||
@@ -362,18 +393,43 @@
|
|||||||
// mount instead would flag someone who bounced after a second as
|
// mount instead would flag someone who bounced after a second as
|
||||||
// having seen it, and they'd never get it.
|
// having seen it, and they'd never get it.
|
||||||
markVisited();
|
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);
|
}, entrance.durationMs + 300);
|
||||||
life.add(() => clearTimeout(hintTimer));
|
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) {
|
} catch (err) {
|
||||||
// No WebGL, or a texture failed to load. Don't strand the user on a
|
// No WebGL, or a texture failed to load. Don't strand the user on a
|
||||||
// spinner that never resolves — reveal the text version instead.
|
// spinner that never resolves — reveal the text version instead.
|
||||||
@@ -391,6 +447,13 @@
|
|||||||
return () => life.dispose();
|
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
|
// Live tooltip element, so flip decisions use its real measured size rather
|
||||||
// than a hardcoded 280x120 guess (descriptions vary in length, so the old
|
// than a hardcoded 280x120 guess (descriptions vary in length, so the old
|
||||||
// height constant mis-flipped tall tooltips near the viewport bottom).
|
// 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 -->
|
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
|
||||||
<Hud showSkip={introPlaying} onSkip={skipIntro} onStartJourney={() => startJourney(0)} />
|
<Hud showSkip={introPlaying} onSkip={skipIntro} onStartJourney={() => startJourney(0)} />
|
||||||
|
|
||||||
|
<BuildBar count={allocated.size} onReset={resetBuild} onShare={shareBuild} />
|
||||||
|
|
||||||
<JourneyMode
|
<JourneyMode
|
||||||
active={journeyActive}
|
active={journeyActive}
|
||||||
startIndex={journeyStartIndex}
|
startIndex={journeyStartIndex}
|
||||||
@@ -499,6 +564,8 @@
|
|||||||
visible={modalVisible}
|
visible={modalVisible}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
onDismiss={dismissModal}
|
onDismiss={dismissModal}
|
||||||
|
allocated={allocated.has(selectedNode.id)}
|
||||||
|
onToggleAllocate={toggleAllocate}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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