Files
murder-mystery-oneshot/investigation-graph.html
2026-07-16 17:38:38 +02:00

1234 lines
58 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nether Inn — Investigation Map</title>
<style>
:root {
color-scheme: dark;
--ink: #eee7d9;
--muted: #9e998f;
--faint: #6e6b66;
--paper: #121315;
--paper-2: #18191c;
--paper-3: #202126;
--line: #34363d;
--gold: #d6aa55;
--core: #d6aa55;
--cult: #a982d4;
--assassin: #cf6970;
--ethereal: #60bdb5;
--exploration: #8cac75;
--finale: #e18b4d;
--danger: #d75c55;
--info: #76a9d3;
--gear: #9f81cf;
--node-w: 260px;
--node-h: 112px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
overflow: hidden;
background:
radial-gradient(circle at 20% 0%, rgba(214,170,85,.08), transparent 34rem),
#0d0e10;
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select { font: inherit; }
button {
color: inherit;
border: 1px solid var(--line);
background: #1a1b1f;
border-radius: 7px;
cursor: pointer;
}
button:hover { border-color: #5a5d66; background: #22242a; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.app {
display: grid;
grid-template-rows: auto auto 1fr;
height: 100%;
min-height: 0;
}
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
min-height: 78px;
padding: 13px 18px 11px 22px;
border-bottom: 1px solid #2a2b30;
background: rgba(13,14,16,.94);
backdrop-filter: blur(12px);
z-index: 20;
}
.title-wrap { min-width: 270px; }
.eyebrow {
margin: 0 0 3px;
color: var(--gold);
font-size: 10px;
font-weight: 800;
letter-spacing: .19em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(22px, 2vw, 31px);
font-weight: 500;
line-height: 1;
letter-spacing: -.02em;
}
.subtitle {
margin: 5px 0 0;
color: var(--muted);
font-size: 12px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 7px;
}
.search-wrap { position: relative; }
.search-wrap::before {
content: "⌕";
position: absolute;
left: 10px;
top: 7px;
color: var(--faint);
font-size: 17px;
pointer-events: none;
}
input[type="search"] {
width: 205px;
height: 34px;
padding: 0 10px 0 31px;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 7px;
background: #17181b;
}
select {
height: 34px;
padding: 0 30px 0 10px;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 7px;
background: #17181b;
}
.tool-button { height: 34px; padding: 0 11px; }
.tool-button.square { width: 34px; padding: 0; font-size: 17px; }
.progress-wrap {
display: grid;
grid-template-columns: auto 112px auto;
align-items: center;
gap: 8px;
margin-left: 5px;
color: var(--muted);
font-size: 11px;
}
.progress-track {
height: 5px;
overflow: hidden;
border-radius: 99px;
background: #2a2b30;
}
.progress-fill {
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--gold), #e18b4d);
transition: width .2s ease;
}
.legend-bar {
display: flex;
align-items: center;
gap: 20px;
min-height: 35px;
padding: 7px 20px;
border-bottom: 1px solid #27282d;
background: #121316;
color: var(--muted);
font-size: 10px;
letter-spacing: .03em;
z-index: 15;
}
.legend-group { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.legend-label { color: #e2ddcf; font-weight: 700; text-transform: uppercase; letter-spacing: .12em; }
.legend-item { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--swatch); }
.edge-sample { width: 23px; border-top: 2px solid var(--swatch); }
.edge-sample.dashed { border-top-style: dashed; }
.legend-tip { margin-left: auto; color: var(--faint); white-space: nowrap; }
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 366px;
min-height: 0;
}
.viewport {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
cursor: grab;
background-color: #101114;
background-image:
linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,.012) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.012) 1px, transparent 1px);
background-size: 80px 80px, 80px 80px, 20px 20px, 20px 20px;
}
.viewport.dragging { cursor: grabbing; }
.scene {
position: absolute;
left: 0;
top: 0;
width: 2030px;
height: 1320px;
transform-origin: 0 0;
will-change: transform;
}
.column-label {
position: absolute;
top: 20px;
color: #6d6f75;
font-family: Georgia, "Times New Roman", serif;
font-size: 15px;
font-style: italic;
letter-spacing: .02em;
pointer-events: none;
}
.column-label::after {
content: "";
display: block;
width: 245px;
margin-top: 7px;
border-top: 1px solid #2e3036;
}
.edges {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.edge {
fill: none;
stroke: var(--edge-color);
stroke-width: 1.8;
opacity: .46;
transition: opacity .15s ease, stroke-width .15s ease;
}
.edge.danger { stroke-dasharray: 7 6; }
.edge.equips { stroke-dasharray: 2 5; }
.edge.dimmed { opacity: .055; }
.edge.active { opacity: .95; stroke-width: 2.8; }
.edge.hidden { display: none; }
.edge-label {
fill: #aaa59c;
font-size: 10px;
font-weight: 650;
paint-order: stroke;
stroke: #101114;
stroke-width: 5px;
stroke-linejoin: round;
opacity: .72;
transition: opacity .15s ease;
}
.edge-label.dimmed { opacity: .03; }
.edge-label.active { fill: #eee7d9; opacity: 1; }
.edge-label.hidden { display: none; }
.node {
--track: var(--core);
position: absolute;
width: var(--node-w);
min-height: var(--node-h);
padding: 13px 33px 12px 15px;
overflow: hidden;
border: 1px solid #393b42;
border-left: 3px solid var(--track);
border-radius: 9px;
background:
linear-gradient(100deg, color-mix(in srgb, var(--track) 8%, transparent), transparent 36%),
#1a1b1f;
box-shadow: 0 8px 20px rgba(0,0,0,.22);
cursor: pointer;
transition: opacity .15s ease, filter .15s ease, border-color .15s ease, transform .15s ease, box-shadow .15s ease;
}
.node:hover {
transform: translateY(-2px);
border-color: #62656e;
border-left-color: var(--track);
box-shadow: 0 11px 24px rgba(0,0,0,.34);
}
.node.selected {
border-color: color-mix(in srgb, var(--track) 72%, white);
border-left-color: var(--track);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--track) 24%, transparent), 0 12px 28px rgba(0,0,0,.4);
}
.node.dimmed { opacity: .18; filter: saturate(.35); }
.node.hidden { display: none; }
.node.discovered {
background:
linear-gradient(100deg, color-mix(in srgb, var(--track) 16%, transparent), transparent 55%),
#1d1f21;
}
.node.discovered::after {
content: "✓";
position: absolute;
right: 10px;
bottom: 8px;
color: var(--track);
font-weight: 900;
font-size: 12px;
}
.node-kicker {
margin: 0 0 5px;
color: var(--track);
font-size: 9px;
font-weight: 850;
letter-spacing: .15em;
line-height: 1.1;
text-transform: uppercase;
}
.node-title {
margin: 0;
color: #f2ede3;
font-family: Georgia, "Times New Roman", serif;
font-size: 17px;
font-weight: 600;
line-height: 1.05;
}
.node-summary {
margin: 7px 0 0;
color: #aca79e;
font-size: 10.5px;
line-height: 1.35;
}
.node-check {
position: absolute;
top: 9px;
right: 9px;
width: 18px;
height: 18px;
padding: 0;
border-color: #4a4c53;
border-radius: 50%;
background: #121316;
color: transparent;
font-size: 10px;
line-height: 16px;
}
.node-check:hover { border-color: var(--track); }
.discovered .node-check { border-color: var(--track); background: var(--track); color: #111; }
.clock-node {
border-style: dashed;
background: linear-gradient(100deg, rgba(207,105,112,.1), transparent 45%), #18191d;
}
.inspector {
min-width: 0;
overflow-y: auto;
border-left: 1px solid #2e3035;
background: #151619;
scrollbar-color: #45474e #191a1e;
}
.inspector-inner { padding: 22px 21px 28px; }
.inspector-empty h2, .inspector h2 {
margin: 3px 0 8px;
font-family: Georgia, "Times New Roman", serif;
font-size: 24px;
font-weight: 500;
line-height: 1.08;
}
.inspector-empty p {
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.read-path {
margin: 22px 0 0;
padding: 14px 15px;
border: 1px solid #303238;
border-radius: 8px;
background: #191a1e;
}
.read-path h3 { margin-top: 0; }
.read-path ol {
margin: 10px 0 0;
padding-left: 18px;
color: #b8b2a8;
font-size: 12px;
line-height: 1.55;
}
.inspector-kicker {
color: var(--selected, var(--gold));
font-size: 10px;
font-weight: 850;
letter-spacing: .16em;
text-transform: uppercase;
}
.inspector-summary {
margin: 0 0 17px;
color: #aaa59c;
font-size: 13px;
line-height: 1.5;
}
.detail-block {
margin-top: 17px;
padding-top: 15px;
border-top: 1px solid #2d2f34;
}
.detail-block h3 {
margin: 0 0 7px;
color: #ddd7ca;
font-size: 10px;
font-weight: 850;
letter-spacing: .14em;
text-transform: uppercase;
}
.detail-block p, .detail-block li {
color: #aaa59c;
font-size: 12px;
line-height: 1.55;
}
.detail-block p { margin: 0; }
.detail-block ul { margin: 0; padding-left: 17px; }
.outcome-box {
margin-top: 17px;
padding: 13px 14px;
border-left: 3px solid var(--selected, var(--gold));
border-radius: 0 7px 7px 0;
background: color-mix(in srgb, var(--selected, var(--gold)) 8%, #1b1c20);
}
.outcome-box h3 { margin: 0 0 6px; color: var(--selected, var(--gold)); font-size: 10px; text-transform: uppercase; letter-spacing: .13em; }
.outcome-box p { margin: 0; color: #d2ccc0; font-size: 12px; line-height: 1.5; }
.source-link {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 18px;
color: #d7b875;
font-size: 12px;
text-decoration: none;
}
.source-link:hover { text-decoration: underline; }
.mark-button {
width: 100%;
min-height: 38px;
margin-top: 18px;
border-color: color-mix(in srgb, var(--selected, var(--gold)) 55%, #333);
background: color-mix(in srgb, var(--selected, var(--gold)) 9%, #1b1c20);
font-size: 12px;
font-weight: 700;
}
.mark-button.on { color: #111; background: var(--selected, var(--gold)); border-color: var(--selected, var(--gold)); }
.gate-list { list-style: none; padding: 0 !important; }
.gate-list li { position: relative; padding-left: 16px; }
.gate-list li::before { content: "◇"; position: absolute; left: 0; color: var(--selected, var(--gold)); }
.no-results {
display: none;
position: absolute;
left: 50%;
top: 44%;
translate: -50% -50%;
padding: 18px 22px;
border: 1px solid #393b42;
border-radius: 8px;
background: #17181c;
color: var(--muted);
text-align: center;
z-index: 5;
}
.no-results.visible { display: block; }
@media (max-width: 920px) {
.masthead { align-items: flex-start; flex-direction: column; gap: 10px; max-height: 154px; overflow-y: auto; }
.toolbar { justify-content: flex-start; }
.legend-tip { display: none; }
.workspace { grid-template-columns: 1fr; }
.inspector {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 30;
width: min(366px, calc(100vw - 20px));
max-height: 48vh;
border: 1px solid #3b3d44;
border-radius: 10px;
box-shadow: 0 15px 45px rgba(0,0,0,.55);
}
.inspector-empty { display: none; }
.progress-wrap { display: none; }
}
@media print {
body { overflow: visible; background: #101114; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
.app { display: block; height: auto; }
.masthead { position: static; }
.toolbar, .legend-tip, .inspector { display: none; }
.workspace { display: block; }
.viewport { width: 2030px; height: 1320px; overflow: visible; }
.scene { transform: none !important; }
}
</style>
</head>
<body>
<main class="app">
<header class="masthead">
<div class="title-wrap">
<p class="eyebrow">DM investigation control board</p>
<h1>Threads Beneath the Nether Inn</h1>
<p class="subtitle">Player interaction → revealed truth → changed allegiance → available ending</p>
</div>
<div class="toolbar" aria-label="Graph controls">
<label class="search-wrap">
<span hidden>Search clues and people</span>
<input id="search" type="search" placeholder="Find a clue, NPC, outcome…">
</label>
<label>
<span hidden>Filter storyline</span>
<select id="track-filter">
<option value="all">All storylines</option>
<option value="core">Core murder</option>
<option value="cult">Order of Mending</option>
<option value="assassin">House of Scales</option>
<option value="ethereal">Ethereal / possession</option>
<option value="exploration">Deep Cellar</option>
<option value="finale">Endgame options</option>
</select>
</label>
<button class="tool-button square" id="zoom-out" title="Zoom out" aria-label="Zoom out"></button>
<button class="tool-button square" id="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
<button class="tool-button" id="fit" title="Fit the whole investigation">Fit map</button>
<button class="tool-button" id="reset-progress" title="Clear all discovered marks">Reset</button>
<div class="progress-wrap" aria-label="Investigation progress">
<span>Discovered</span>
<div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
<strong id="progress-count">0 / 0</strong>
</div>
</div>
</header>
<div class="legend-bar">
<div class="legend-group">
<span class="legend-label">Threads</span>
<span class="legend-item"><i class="dot" style="--swatch:var(--core)"></i>Murder</span>
<span class="legend-item"><i class="dot" style="--swatch:var(--cult)"></i>Cult</span>
<span class="legend-item"><i class="dot" style="--swatch:var(--assassin)"></i>Assassins</span>
<span class="legend-item"><i class="dot" style="--swatch:var(--ethereal)"></i>Ethereal</span>
<span class="legend-item"><i class="dot" style="--swatch:var(--exploration)"></i>Exploration</span>
<span class="legend-item"><i class="dot" style="--swatch:var(--finale)"></i>Finale</span>
</div>
<div class="legend-group">
<span class="legend-label">Arrows</span>
<span class="legend-item"><i class="edge-sample" style="--swatch:var(--gold)"></i>reveals</span>
<span class="legend-item"><i class="edge-sample" style="--swatch:var(--ethereal)"></i>unlocks</span>
<span class="legend-item"><i class="edge-sample dashed" style="--swatch:var(--danger)"></i>escalates / risks</span>
<span class="legend-item"><i class="edge-sample dashed" style="--swatch:var(--gear)"></i>equips</span>
</div>
<span class="legend-tip">Click a card to trace its whole route · drag to pan · scroll to zoom</span>
</div>
<section class="workspace">
<div class="viewport" id="viewport" aria-label="Interactive investigation graph">
<div class="scene" id="scene">
<div class="column-label" style="left:70px">Inciting beat</div>
<div class="column-label" style="left:385px">Player interactions</div>
<div class="column-label" style="left:730px">Investigation threads</div>
<div class="column-label" style="left:1085px">Breakthroughs</div>
<div class="column-label" style="left:1395px">Confrontation plan</div>
<div class="column-label" style="left:1690px">Reckoning options</div>
<svg class="edges" id="edges" aria-hidden="true"></svg>
<div id="nodes"></div>
</div>
<div class="no-results" id="no-results">No investigation beats match that search.</div>
</div>
<aside class="inspector" id="inspector" aria-live="polite">
<div class="inspector-inner inspector-empty">
<p class="eyebrow">How to use this map</p>
<h2>Follow what the players touch.</h2>
<p>Each card is a player-facing opportunity or a story-state change. Arrows say what that interaction reveals, unlocks, equips, or makes more dangerous.</p>
<div class="read-path">
<h3 class="eyebrow">At the table</h3>
<ol>
<li>Click the interaction the party chose.</li>
<li>Read its trigger and immediate payoff.</li>
<li>Mark it discovered; browser storage remembers it.</li>
<li>Follow the lit arrows to the newly available choices.</li>
</ol>
</div>
<p>Filters isolate one subplot without pretending the other threads stop moving. The midnight clock is always live.</p>
</div>
</aside>
</section>
</main>
<script>
const palette = {
core: '#d6aa55', cult: '#a982d4', assassin: '#cf6970',
ethereal: '#60bdb5', exploration: '#8cac75', finale: '#e18b4d'
};
const nodes = [
{
id: 'murder', x: 70, y: 150, track: 'core', tracks: ['core','cult','assassin','ethereal','exploration','finale'],
kicker: 'Act 2 · fixed beat', title: 'Orenn is murdered',
summary: 'Blackout, impossible death, failed Revivify. Mags seals every exit.',
trigger: 'Rennts Thunderwave kills the lights during the brawl. Xarrul partially manifests behind the bar and Brands Orenn.',
result: 'The PCs become neutral investigators. The murder scene, both factions, and every side thread open at once.',
details: ['The mundane head wound is survivable.', 'The hand-shaped brand is the true killing wound.', 'The murder is the easy mystery; the moral identity of the villain is the deep one.'],
source: 'storylines/murder-mystery.md'
},
{
id: 'clock', x: 70, y: 365, track: 'assassin', tracks: ['core','cult','assassin','ethereal','exploration','finale'], className: 'clock-node',
kicker: 'Always running', title: 'The midnight clock',
summary: 'The sealed tavern is the closed vessel Xarrul needs to become real.',
trigger: 'Mags shatters the ward-crystal. As time passes the inn grows colder, Xarrul grows bolder, and the crossing advances.',
result: 'If nobody interferes, Xarrul crosses permanently at midnight. Loud investigation slows its work; quiet investigation accelerates it.',
details: ['Slow it: breach an exit, ring the iron bell, repair the foundation sigils, or burn-and-seal the cellar.', 'Let it run: this becomes the crossing condition for the Open Door ending.'],
source: 'storylines/the-reckoning.md'
},
{
id: 'body', x: 385, y: 85, track: 'core', tracks: ['core','cult','ethereal','finale'],
kicker: 'Examine · Medicine / Arcana', title: 'Read Orenns body',
summary: 'Separate the brawl injury from the fiendish Brand beneath his skin.',
trigger: 'Medicine 14 shows damage radiating from the heart. Arcana 15 identifies a fiend-origin signature. Investigation 16 finds partial claw marks.',
result: 'Rules out mundane murder and mortal spellcasting. It proves fiend involvement—not which fiend, where it is now, or how to touch it.',
details: ['Detect Magic within 30 minutes finds a trail toward the cellar.', 'Arcana 18 finds a true-name fragment, but it still needs Orenns bestiary, Haddons texts, or a knowledgeable witness to interpret.'],
source: 'storylines/murder-mystery.md'
},
{
id: 'factions', x: 385, y: 245, track: 'core', tracks: ['core','cult','assassin','finale'],
kicker: 'Question · social', title: 'Question both factions',
summary: 'Conflicting accusations expose motive, fear, and the fault lines in each group.',
trigger: 'Compare Kaels anger, Daces eager “help,” Haddons polished alibi, and Esseks reaction to the Brand.',
result: 'Opens the cult interrogation and assassin-mole tracks. Mishandled accusations push the room toward faction war.',
details: ['Insight 16: Dace steers without evidence.', 'Insight 14: Essek recognizes what made the Brand.', 'Rennt will brag about the practical summoning work if provoked.'],
source: 'storylines/murder-mystery.md'
},
{
id: 'bren', x: 385, y: 405, track: 'ethereal', tracks: ['core','ethereal','finale'],
kicker: 'Help · patience / magic', title: 'Reach Bren through Skein',
summary: 'A possessed witness saw “a hand that wasnt a hand” reach through the thin place.',
trigger: 'Agitate Skein with questions, Detect Magic, Veil Dust, or temporary protection; then create a lucid window.',
result: 'Bren identifies the killers manner and acts as a live proximity detector for Xarrul.',
details: ['Temporary help yields testimony before Skein returns.', 'Killing Skein frees Bren permanently and gains a fragile ally.', 'Ignoring him leaves this route closed.'],
source: 'storylines/possession.md'
},
{
id: 'vetch', x: 385, y: 565, track: 'ethereal', tracks: ['core','ethereal','exploration','finale'],
kicker: 'Befriend / dose', title: 'Follow Vetch beyond the veil',
summary: 'Veil Dust turns the invisible investigation into a visible trail.',
trigger: 'Agree to recover Orenns confiscated packet. Vetch shows the cold seam behind the barrel racks; the Workroom drawer holds four doses.',
result: 'Vetch opens the concealed-descent lead and can guide the shared trip. The dust reveals Xarrul, Skein, and the thin points two-plane nature.',
details: ['Vetch has no dose when found—that is the problem.', 'Main hall: see the fiends passage and Skein on Bren.', 'Upper Cellar: the effect lasts 10 minutes and permits direct Ethereal interaction.', 'The comedown still imposes its normal cost.'],
source: 'storylines/ethereal-trip.md'
},
{
id: 'rooms', x: 385, y: 725, track: 'core', tracks: ['core','cult','exploration','finale'],
kicker: 'Search · physical evidence', title: 'Search the back rooms',
summary: 'Orenns preparations and the private-room sigils turn suspicion downward.',
trigger: 'Search storage, the private room, and Magss apron. Track the black key and Orenns contingency letter.',
result: 'Finds warding supplies, a cult pin, mapped summoner sigils, and “if it happens in the bar, it came from between.”',
details: ['Private room evidence supports Daces cult meetings.', 'Orenns key opens the concealed Deep Cellar door and Vault.', 'The letter suggests repair or burn-and-seal containment.'],
source: 'location/back-rooms.md'
},
{
id: 'cult-talk', x: 730, y: 120, track: 'cult', tracks: ['core','cult','finale'],
kicker: 'Interrogate · four approaches', title: 'Crack the cults facade',
summary: 'Use Haddons ego, Rennts pride, Dallas love, or Esseks fear.',
trigger: 'Academic contradiction pressures Haddon; practical questions make Rennt boast; kindness opens Dalla; isolation and protection reach Essek.',
result: 'Confirms the Order summoned a fiend and opens either the shallow rebinding solution or the deeper research trail.',
details: ['Three failed deceptions crack Haddons composure.', 'Rennt confesses without believing it is shameful.', 'Essek only talks alone and after the trust ladder is met.'],
source: 'storylines/order-of-mending.md'
},
{
id: 'mole', x: 730, y: 315, track: 'assassin', tracks: ['core','assassin','finale'],
kicker: 'Investigate · social + physical', title: 'Trace the assassin mole',
summary: 'Sevra needs proof that Dace sold guild contracts to Haddon.',
trigger: 'Combine two concrete pieces: the pin on Dace, Haddons authenticated journal page, private-room evidence, Kaels contract records, or confession.',
result: 'Exposing Dace calms immediate faction hostility and earns Sevras favor—but may complete Theras plan for him.',
details: ['Kael supplies suspicion and contract records.', 'Haddons ciphered journal names Dace and records both payments.', 'Search Dace: a second cult pin hides in his boot.', 'Cornered Dace takes a hostage or bargains for passage.'],
source: 'storylines/house-of-scales.md'
},
{
id: 'ethereal-trail', x: 730, y: 510, track: 'ethereal', tracks: ['core','ethereal','finale'],
kicker: 'Synthesize · clue cluster', title: 'Prove an unseen fiend',
summary: 'Brand, claw marks, Bren, and Veil Dust establish how the killer moved unseen.',
trigger: 'Collect any strong combination of fiendish signature, timed trail, Brens testimony, cellar sigils, or direct Ethereal sight.',
result: 'The party knows an Ethereal fiend committed the murder and retreated below. Naming Xarrul and devising a way to manifest it are still separate problems.',
details: ['The trail descends toward the thin point.', 'It shows the killer used both factions as cover rather than belonging to either.', 'Ordinary attacks still cannot reach a creature remaining in the Border Ethereal.'],
source: 'storylines/murder-mystery.md'
},
{
id: 'upper-cellar', x: 730, y: 705, track: 'exploration', tracks: ['core','cult','ethereal','exploration','finale'],
kicker: 'Explore · central location', title: 'Read the Upper Cellar',
summary: 'Binding circle, siphoned sigils, thin point, and a concealed door coexist here.',
trigger: 'Descend from storage. Arcana 1315 reads siphoning from the old foundation work; Investigation or Perception 15 finds the stone-faced door.',
result: 'Confirms long-term Ethereal interference, locates the Reckoning site, and opens the Deep Cellar exploration spine.',
details: ['Veil Dust lasts 10 minutes here.', 'A warding chalk circle becomes full Magic Circle.', 'The dual-plane kill happens here—not in the lower rift.'],
source: 'location/cellar.md'
},
{
id: 'deep-cellar', x: 730, y: 900, track: 'exploration', tracks: ['cult','exploration','finale'],
kicker: 'Delve · optional spine', title: 'Open the Deep Cellar',
summary: 'The hidden level trades danger for cult intel, moral rot, and endgame equipment.',
trigger: 'Use Orenns black key, Knock, or beat the Arcane Lock. Survive Twinkle, the Long Cut, and the faults deeper bleed.',
result: 'The chalk wall corroborates the Order; cells expose Orenn and Mags; the Vault arms the party.',
details: ['This does not relocate the final confrontation.', 'The fifth rule can burn-and-seal the fault at the cost of loot and any caged Guest.'],
source: 'location/deep-cellar.md'
},
{
id: 'xarrul', x: 1085, y: 85, track: 'ethereal', tracks: ['core','cult','ethereal','finale'],
kicker: 'Truth milestone · identity', title: 'Name the unbound fiend',
summary: 'The murderer is an unbound fiend—and the only witness who never lies.',
trigger: 'Combine proof of the Ethereal killer with an identifying source: Esseks confession, Haddons binding records, a researched true-name fragment, or a sighting matched to the cults bound asset.',
result: 'The party can address Xarrul, understand its motive, and negotiate or discuss rebinding. Identification alone still does not make a direct attack possible.',
gates: ['Proof that an Ethereal fiend killed Orenn', 'A source that connects that fiend to the Orders missing bound asset'],
details: ['The Order summoned it.', 'The House accidentally freed it.', 'It murdered Orenn to provoke the lockdown and finish crossing.', 'It can Ethereal Jaunt away unless something anchors or reaches it.'],
source: 'npc/xarrul.md'
},
{
id: 'extracts', x: 1085, y: 260, track: 'cult', tracks: ['cult','ethereal','finale'],
kicker: 'Deep truth · two handouts', title: 'Understand Vyres extracts',
summary: 'The “cure” drained Nessa, and the Order caused the wound it claimed to treat.',
trigger: 'Find the maternal-conduit extract (DC 14 to explain), then the EIII dislocation extract (DC 16). Essek can interpret both.',
result: 'Dalla fractures, rebinding loses willing blood, and the dual-plane kill plus Open Door become morally legible options.',
details: ['First truth: Dallas blood holds Nessa in the binding.', 'Second truth: Vyre caused Nessas dislocation and postponed her return.', 'Xarrul may plant or trade both documents.'],
source: 'storylines/order-of-mending.md'
},
{
id: 'dace', x: 1085, y: 435, track: 'assassin', tracks: ['assassin','finale'],
kicker: 'Faction state change', title: 'Expose or lose Dace',
summary: 'Proof settles the surface crime inside the House—but exposes a deeper seam.',
trigger: 'Present two pieces of evidence to Sevra, force a confession, capture him after violence, or let him slip.',
result: 'Captured: the guild de-escalates and Sevra owes a favor. Escaped: the guild stays volatile. Either path advances Theras fork.',
details: ['A captured, condemned Dace is exactly what Thera wanted.', 'His delayed shadow and grey throat seam reveal he is already marked.'],
source: 'storylines/house-of-scales.md'
},
{
id: 'thera', x: 1085, y: 610, track: 'assassin', tracks: ['assassin','ethereal','finale'],
kicker: 'Hidden second floor', title: 'Prove Thera harvests souls',
summary: 'The guilds perfect killer obeys every written rule while damning every target.',
trigger: 'Connect Kaels resurrection story, her ritual gestures, Daces mark, and finally a soulcoin or rescued testimony.',
result: 'Convince Sevra with physical proof and loyalist assassins can turn against Thera—or support the PCs in a larger finale.',
details: ['Argument does not move Sevra; one undeniable physical crack does.', 'Destroying a charged soulcoin releases its witness.', 'This thread is optional campaign-level horror, not Orenns murder solution.'],
source: 'storylines/house-of-scales.md'
},
{
id: 'guest', x: 1085, y: 785, track: 'exploration', tracks: ['cult','exploration','finale'],
kicker: 'Moral fork · accomplice', title: 'Bargain with the Guest',
summary: 'A caged liar offers the easiest shot at Xarrul while concealing what it intends to inherit.',
trigger: 'Reach Cell One, notice Xarruls conversation stain, and trade freedom for cult intelligence and a promised moment of vulnerability.',
result: 'The Guest corroborates Vyre, admits Xarrul wants its body, and steers the party toward the Vacancy. It omits that it helped choose Orenn and wants both the fiends imprint and the Inn.',
details: ['Free it: the Vacancy becomes possible.', 'Kill it: Xarrul abducts a replacement vessel.', 'Leave it: burn-and-seal may kill it, or Xarrul attempts an imperfect transfer.'],
source: 'npc/the-guest.md'
},
{
id: 'arsenal', x: 1085, y: 960, track: 'exploration', tracks: ['cult','exploration','ethereal','finale'],
kicker: 'Exploration reward', title: 'Recover the planar field kit',
summary: 'The Workroom and Vault provide the physical tools and evidence the party needs.',
trigger: 'Search the first Workroom and open the Vault. The drawer is DC 14; the Vault needs the black key, Knock, or Dispel Magic.',
result: 'The party gains four Veil Dust doses, a complete binding coffer, finished Veilhook Salt, its recipe, silvered gear, and warding chalk.',
details: ['Veilhook Salt drags a perceived Ethereal target fully onto the Material Plane on a failed DC 14 CHA save.', 'The Workroom coffer supplies every material component needed for rebinding.', 'The Veil Dust cache pays off Vetchs lead and enables the dual-plane routes.'],
source: 'location/deep-cellar.md'
},
{
id: 'containment', x: 1085, y: 1135, track: 'exploration', tracks: ['core','exploration','finale'],
kicker: 'Clock intervention', title: 'Break the closed vessel',
summary: 'Repair, breach, ring, or burn to deny Xarrul an uninterrupted crossing.',
trigger: 'Re-power foundation sigils, breach an exit, ring the iron bell, or obey Orenns fifth rule: burn cellar stores and seal the door.',
result: 'Slows or stalls the midnight crossing. Burn-and-seal sacrifices loot and can kill the caged Guest.',
details: ['This buys time; it does not itself answer what to do with Xarrul.', 'Letting the clock run is an active route to the Open Door.'],
source: 'storylines/the-reckoning.md'
},
{
id: 'manifestation-trap', x: 1395, y: 80, track: 'ethereal', tracks: ['core','ethereal','exploration','finale'],
kicker: 'Required plan · three-part gate', title: 'Devise a manifestation trap',
summary: 'Knowing the murderer is not enough: locate it, force it present, and keep it present.',
trigger: 'Combine Xarruls identity and planar behavior with the Upper Cellar thin point, then choose at least one anchoring method.',
result: 'Only now is conventional combat a viable plan. The party has a site, a lure or detection method, and a way to stop Ethereal Jaunt.',
gates: ['Know Xarrul is the Ethereal killer', 'Locate or lure it to the Upper Cellar thin point', 'Choose an anchor: Veilhook Salt, iron bell, binding circle, warding chalk, or reliable force damage'],
details: ['Bren or Veil Dust can track its presence.', 'A Veil Dust user can throw Veilhook Salt across the boundary and drag Xarrul Material.', 'The party must then drive it into the binding circle; Xarrul gets a DC 17 CHA save.', 'Without a constraint, direct assault becomes an attrition fight Xarrul wins.'],
source: 'storylines/the-reckoning.md'
},
{
id: 'vacancy', x: 1395, y: 780, track: 'finale', tracks: ['core','ethereal','exploration','finale'],
kicker: 'Ending · exposed transfer', title: 'The Vacancy',
summary: 'For three rounds the Guest and Xarrul float outside one empty body, and either spirit can be destroyed.',
trigger: 'Free the Guest, bring it to the Upper Cellar binding circle, and let its negotiated exchange with Xarrul begin.',
result: 'Kill Xarrul: the Guest inherits its imprint and claims the Inn. Wait: Xarrul takes the body and the Guest becomes the spectral Host. Kill the Guest: Xarrul takes the body alone. Mags attacks the winners settlement.',
gates: ['Guest freed', 'Xarrul identified', 'Transfer accepted', 'Anti-Ethereal tools if the party intends to strike Xarrul'],
details: ['Vacancy Xarrul has 39 HP and cannot Ethereal Jaunt, Slip Between, or withdraw.', 'The projected Guest has 26 HP and cannot attack without forfeiting its bodily claim.', 'The vacant body is a third target.', 'Nessas stolen vitality enters an empowered Guest instead of returning to her.'],
source: 'storylines/the-reckoning.md'
},
{
id: 'assault', x: 1690, y: 80, track: 'finale', tracks: ['core','ethereal','exploration','finale'],
kicker: 'Ending · combat', title: 'Direct assault',
summary: 'Pin Xarrul in the Upper Cellar and destroy its manifested body.',
trigger: 'Execute the prepared manifestation trap, keep Xarrul from escaping between planes, and survive its kill chain.',
result: 'Xarrul dies and justice lands, but stored vitality dissipates; Nessa remains trapped and the wider Opening continues.',
gates: ['Manifestation trap prepared', 'Xarrul successfully lured, detected, or forced into reach'],
source: 'storylines/the-reckoning.md'
},
{
id: 'rebinding', x: 1690, y: 255, track: 'finale', tracks: ['cult','ethereal','finale'],
kicker: 'Ending · shallow answer', title: 'Rebind the weapon',
summary: 'Protect Haddon for ten minutes while he puts Xarrul back on the Orders leash.',
trigger: 'Use the Workrooms summoner coffer, the Upper Cellar circle, Dallas willing blood, and Haddons ritual.',
result: 'Nessa only stabilizes; the cult regains its fiend and proceeds to Phase Two. After the first extract, blood requires coercion or deceit.',
gates: ['Xarrul identified', 'Components + circle', 'Dalla loyal or coerced', 'Haddon alive'],
source: 'storylines/the-reckoning.md'
},
{
id: 'dual-kill', x: 1690, y: 430, track: 'finale', tracks: ['cult','ethereal','finale'],
kicker: 'Ending · high-risk truth', title: 'Dual-plane kill',
summary: 'Destroy both halves within one round while gambling Nessas silver cord.',
trigger: 'Split the party across planes at the Upper Cellar thin point and synchronize two half-HP kills.',
result: 'Xarrul dies and Nessa secretly wakes—but the veil tears for six seconds and the cult gains proof for Phase Two.',
gates: ['Conduit evidence, Essek, or combined records + Ethereal observation', 'Material and Ethereal teams', 'Synchronized kill'],
source: 'storylines/the-reckoning.md'
},
{
id: 'negotiation', x: 1690, y: 605, track: 'finale', tracks: ['core','cult','ethereal','finale'],
kicker: 'Ending · devils bargain', title: 'Let Xarrul walk',
summary: 'Trade freedom for true intelligence and a promise to recover Nessa later.',
trigger: 'Corner Xarrul and hear its exact offer. Accept escape after lockdown without actively completing the crossing.',
result: 'The party gains real cult intelligence; Xarrul is loose and Mags becomes an enemy or future hunter.',
gates: ['Xarrul identified and able to negotiate', 'Party accepts an unverifiable future rescue'],
source: 'storylines/the-reckoning.md'
},
{
id: 'open-door', x: 1690, y: 780, track: 'finale', tracks: ['cult','ethereal','exploration','finale'],
kicker: 'Ending · active complicity', title: 'Open the Door',
summary: 'Help the honest murderer become real because the human institutions are worse.',
trigger: 'Supply a vessel or leave the door open, protect the crossing, and get past Magss refusal.',
result: 'Xarrul keeps every promise: Nessa wakes and the local cult is gutted. If the Guest supplies the body, it becomes the spectral Host; a murderer walks free.',
gates: ['Phase Two understood', 'Orenn and Magss cages exposed', 'Xarruls offer understood', 'Vessel or uninterrupted crossing', 'Defeat or bypass Mags'],
source: 'storylines/the-reckoning.md'
},
{
id: 'peace', x: 1690, y: 955, track: 'finale', tracks: ['core','cult','assassin','finale'],
kicker: 'Faction state · hard win', title: 'Broker a temporary alliance',
summary: 'Prove a third party played both factions and turn their attention toward the same threat.',
trigger: 'Engage deeply with both subplot groups, settle Daces surface crime, and show compelling evidence of Xarrul.',
result: 'The factions suspend hostilities and support a joint Reckoning. This changes the odds, not the moral choice.',
gates: ['Fiend evidence', 'Trust with Sevra', 'Cult truth or fracture'],
source: 'storylines/murder-mystery.md'
},
{
id: 'war', x: 1690, y: 1130, track: 'finale', tracks: ['core','cult','assassin','finale'],
kicker: 'Failure pressure · lethal', title: 'Faction war',
summary: 'Accusations or delay turn the opening brawl into actual killing.',
trigger: 'Two hours without resolution, summoning exposed without control, Dace exposed at the wrong moment, or any NPC attacked.',
result: 'Kael, Rennt, Dalla, and Essek are likely casualties while Haddon escapes and Xarruls clock advances untouched.',
gates: ['Tension left unmanaged—or deliberately ignited'],
source: 'storylines/the-reckoning.md'
}
];
const edges = [
['murder','body','opens','reveals'], ['murder','factions','forces inquiry','reveals'],
['murder','bren','agitates Skein','reveals'],
['murder','vetch','makes his sight useful','reveals'], ['murder','rooms','opens the house','reveals'],
['murder','clock','seals the vessel','danger'],
['body','ethereal-trail','fiend signature','reveals'], ['body','cult-talk','show them the Brand','reveals'],
['factions','cult-talk','pressure the weak points','reveals'], ['factions','mole','Dace steers too hard','reveals'],
['factions','war','accusation without proof','danger'],
['bren','ethereal-trail','witness testimony','reveals'], ['bren','manifestation-trap','detects its nearness','equips'],
['vetch','ethereal-trail','makes tracks visible','reveals'], ['vetch','xarrul','matches the bound fiend','reveals'],
['vetch','deep-cellar','shows the cold seam','unlocks'], ['vetch','dual-kill','Ethereal access','unlocks'],
['rooms','mole','pin + sigil room','reveals'], ['rooms','upper-cellar','key + trail downward','unlocks'],
['rooms','deep-cellar','black key','unlocks'], ['rooms','containment','letter + supplies','unlocks'],
['cult-talk','xarrul','summoning confession','reveals'], ['cult-talk','extracts','Haddon panics / Essek talks','reveals'],
['cult-talk','mole','Haddons journal','reveals'],
['cult-talk','rebinding','Haddon proposes it','unlocks'], ['cult-talk','war','summoning exposed','danger'],
['mole','dace','two pieces of proof','reveals'], ['mole','peace','wins Sevras trust','unlocks'],
['ethereal-trail','xarrul','killer confirmed','reveals'],
['upper-cellar','ethereal-trail','siphoned sigils','reveals'], ['upper-cellar','deep-cellar','concealed stone door','unlocks'],
['upper-cellar','manifestation-trap','circle + thin point','unlocks'], ['upper-cellar','rebinding','binding circle','unlocks'],
['upper-cellar','dual-kill','two-plane overlap','unlocks'], ['upper-cellar','containment','foundation sigils','unlocks'],
['deep-cellar','guest','Cell One','unlocks'], ['deep-cellar','arsenal','Vault','unlocks'],
['deep-cellar','containment','fifth rule','unlocks'],
['xarrul','manifestation-trap','learn its planar escape','unlocks'], ['xarrul','rebinding','can be leashed','unlocks'],
['xarrul','negotiation','it offers truths','unlocks'], ['xarrul','open-door','offers the lesser monster','unlocks'],
['xarrul','vacancy','negotiated transfer','unlocks'],
['xarrul','peace','third-party proof','unlocks'],
['extracts','rebinding','Dalla refuses blood','danger'], ['extracts','dual-kill','conduit model','unlocks'],
['extracts','negotiation','controlled rescue offer','unlocks'], ['extracts','open-door','Dalla changes sides','unlocks'],
['extracts','peace','cult fractures','unlocks'],
['dace','thera','marked shadow','reveals'], ['dace','peace','guild de-escalates','unlocks'],
['dace','war','bad exposure timing','danger'], ['thera','peace','loyalists can turn','unlocks'],
['guest','vacancy','body + concealed gambit','unlocks'], ['guest','open-door','willing vessel','unlocks'], ['guest','peace','cult infiltration intel','reveals'],
['arsenal','ethereal-trail','Workroom Veil Dust','equips'], ['arsenal','manifestation-trap','Veilhook Salt + chalk','equips'],
['arsenal','rebinding','summoners coffer','equips'], ['arsenal','dual-kill','dust + planar control','equips'], ['arsenal','vacancy','tools strike specters','equips'],
['manifestation-trap','assault','fight becomes viable','unlocks'],
['vacancy','open-door','protect the fair exchange','danger'],
['containment','clock','stalls crossing','unlocks'], ['clock','open-door','let it finish','danger'], ['clock','war','delay raises pressure','danger']
].map(([from,to,label,type]) => ({from,to,label,type}));
const edgeColors = { reveals: '#d6aa55', unlocks: '#60bdb5', danger: '#d75c55', equips: '#9f81cf' };
const nodeById = new Map(nodes.map(node => [node.id, node]));
const nodesRoot = document.getElementById('nodes');
const edgeRoot = document.getElementById('edges');
const inspector = document.getElementById('inspector');
const viewport = document.getElementById('viewport');
const scene = document.getElementById('scene');
const search = document.getElementById('search');
const filter = document.getElementById('track-filter');
const noResults = document.getElementById('no-results');
const progressFill = document.getElementById('progress-fill');
const progressCount = document.getElementById('progress-count');
let selectedId = null;
let discovered = new Set(JSON.parse(localStorage.getItem('nether-inn-investigation') || '[]'));
let view = { x: 20, y: 20, scale: .72 };
let drag = null;
const escapeHtml = value => String(value).replace(/[&<>'"]/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[char]);
function renderNodes() {
nodesRoot.innerHTML = '';
nodes.forEach(node => {
const el = document.createElement('article');
el.className = `node ${node.className || ''}`;
el.dataset.id = node.id;
el.style.left = `${node.x}px`;
el.style.top = `${node.y}px`;
el.style.setProperty('--track', palette[node.track]);
el.innerHTML = `
<button class="node-check" title="Mark discovered" aria-label="Mark ${escapeHtml(node.title)} discovered">✓</button>
<p class="node-kicker">${escapeHtml(node.kicker)}</p>
<h3 class="node-title">${escapeHtml(node.title)}</h3>
<p class="node-summary">${escapeHtml(node.summary)}</p>`;
el.addEventListener('click', event => {
if (event.target.closest('.node-check')) {
event.stopPropagation();
toggleDiscovered(node.id);
return;
}
selectedId = selectedId === node.id ? null : node.id;
updateSelection();
renderInspector();
});
nodesRoot.appendChild(el);
});
updateProgress();
}
function edgePath(from, to) {
const startX = from.x + 260;
const startY = from.y + 56;
const endX = to.x;
const endY = to.y + 56;
if (endX <= startX) {
const bend = Math.max(startX, endX) + 70;
return `M ${startX} ${startY} C ${bend} ${startY}, ${bend} ${endY}, ${endX} ${endY}`;
}
const delta = Math.max(60, (endX - startX) * .48);
return `M ${startX} ${startY} C ${startX + delta} ${startY}, ${endX - delta} ${endY}, ${endX} ${endY}`;
}
function renderEdges() {
edgeRoot.innerHTML = `
<defs>
${Object.entries(edgeColors).map(([type,color]) => `
<marker id="arrow-${type}" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,4 L0,8 z" fill="${color}"></path>
</marker>`).join('')}
</defs>`;
edges.forEach((edge, index) => {
const from = nodeById.get(edge.from);
const to = nodeById.get(edge.to);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', edgePath(from, to));
path.setAttribute('class', `edge ${edge.type}`);
path.setAttribute('style', `--edge-color:${edgeColors[edge.type]}`);
path.setAttribute('marker-end', `url(#arrow-${edge.type})`);
path.dataset.index = index;
edgeRoot.appendChild(path);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
const midX = (from.x + 260 + to.x) / 2;
const midY = (from.y + to.y) / 2 + 48;
text.setAttribute('x', midX);
text.setAttribute('y', midY);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('class', 'edge-label');
text.dataset.index = index;
text.textContent = edge.label;
edgeRoot.appendChild(text);
});
}
function getConnected(startId) {
if (!startId) return new Set(nodes.map(node => node.id));
const ancestors = new Set([startId]);
const descendants = new Set([startId]);
const walk = (found, direction) => {
let changed = true;
while (changed) {
changed = false;
edges.forEach(edge => {
const known = direction === 'up' ? edge.to : edge.from;
const next = direction === 'up' ? edge.from : edge.to;
if (found.has(known) && !found.has(next)) {
found.add(next);
changed = true;
}
});
}
};
walk(ancestors, 'up');
walk(descendants, 'down');
return new Set([...ancestors, ...descendants]);
}
function visibleIds() {
const query = search.value.trim().toLowerCase();
const activeTrack = filter.value;
return new Set(nodes.filter(node => {
const inTrack = activeTrack === 'all' || node.tracks.includes(activeTrack);
const haystack = [node.title,node.kicker,node.summary,node.trigger,node.result,...(node.details || []),...(node.gates || [])].join(' ').toLowerCase();
return inTrack && (!query || haystack.includes(query));
}).map(node => node.id));
}
function updateSelection() {
const visible = visibleIds();
const connected = getConnected(selectedId);
document.querySelectorAll('.node').forEach(el => {
const id = el.dataset.id;
el.classList.toggle('hidden', !visible.has(id));
el.classList.toggle('selected', id === selectedId);
el.classList.toggle('dimmed', Boolean(selectedId) && !connected.has(id));
el.classList.toggle('discovered', discovered.has(id));
});
edgeRoot.querySelectorAll('.edge').forEach(path => {
const edge = edges[Number(path.dataset.index)];
const shown = visible.has(edge.from) && visible.has(edge.to);
const active = Boolean(selectedId) && connected.has(edge.from) && connected.has(edge.to);
path.classList.toggle('hidden', !shown);
path.classList.toggle('active', active);
path.classList.toggle('dimmed', Boolean(selectedId) && !active);
});
edgeRoot.querySelectorAll('.edge-label').forEach(label => {
const edge = edges[Number(label.dataset.index)];
const shown = visible.has(edge.from) && visible.has(edge.to);
const active = Boolean(selectedId) && connected.has(edge.from) && connected.has(edge.to);
label.classList.toggle('hidden', !shown);
label.classList.toggle('active', active);
label.classList.toggle('dimmed', Boolean(selectedId) && !active);
});
noResults.classList.toggle('visible', visible.size === 0);
}
function renderInspector() {
const node = nodeById.get(selectedId);
if (!node) {
inspector.innerHTML = `
<div class="inspector-inner inspector-empty">
<p class="eyebrow">How to use this map</p>
<h2>Follow what the players touch.</h2>
<p>Each card is a player-facing opportunity or a story-state change. Arrows say what that interaction reveals, unlocks, equips, or makes more dangerous.</p>
<div class="read-path"><h3 class="eyebrow">At the table</h3><ol>
<li>Click the interaction the party chose.</li><li>Read its trigger and immediate payoff.</li>
<li>Mark it discovered; browser storage remembers it.</li><li>Follow the lit arrows to newly available choices.</li>
</ol></div>
<p>Filters isolate one subplot without pretending the other threads stop moving. The midnight clock is always live.</p>
</div>`;
return;
}
const details = node.details?.length ? `
<div class="detail-block"><h3>At the table</h3><ul>${node.details.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div>` : '';
const gates = node.gates?.length ? `
<div class="detail-block"><h3>Requires</h3><ul class="gate-list">${node.gates.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div>` : '';
inspector.innerHTML = `
<div class="inspector-inner" style="--selected:${palette[node.track]}">
<div class="inspector-kicker">${escapeHtml(node.kicker)}</div>
<h2>${escapeHtml(node.title)}</h2>
<p class="inspector-summary">${escapeHtml(node.summary)}</p>
<div class="detail-block"><h3>Player trigger</h3><p>${escapeHtml(node.trigger)}</p></div>
<div class="outcome-box"><h3>Story progress</h3><p>${escapeHtml(node.result)}</p></div>
${gates}${details}
<a class="source-link" href="${encodeURI(node.source)}">↗ Open source note</a>
<button class="mark-button ${discovered.has(node.id) ? 'on' : ''}" id="inspector-mark">
${discovered.has(node.id) ? '✓ Discovered at the table' : 'Mark as discovered'}
</button>
</div>`;
document.getElementById('inspector-mark').addEventListener('click', () => toggleDiscovered(node.id));
}
function toggleDiscovered(id) {
if (discovered.has(id)) discovered.delete(id); else discovered.add(id);
localStorage.setItem('nether-inn-investigation', JSON.stringify([...discovered]));
updateProgress();
updateSelection();
if (selectedId === id) renderInspector();
}
function updateProgress() {
const count = nodes.filter(node => discovered.has(node.id)).length;
progressCount.textContent = `${count} / ${nodes.length}`;
progressFill.style.width = `${count / nodes.length * 100}%`;
}
function applyTransform() {
scene.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.scale})`;
}
function fitMap() {
const rect = viewport.getBoundingClientRect();
const padding = 35;
const scale = Math.min((rect.width - padding * 2) / 2030, (rect.height - padding * 2) / 1320, 1);
view.scale = Math.max(.28, scale);
view.x = (rect.width - 2030 * view.scale) / 2;
view.y = (rect.height - 1320 * view.scale) / 2;
applyTransform();
}
function zoomAt(factor, clientX, clientY) {
const rect = viewport.getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
const old = view.scale;
const next = Math.min(1.45, Math.max(.28, old * factor));
view.x = px - (px - view.x) * (next / old);
view.y = py - (py - view.y) * (next / old);
view.scale = next;
applyTransform();
}
viewport.addEventListener('wheel', event => {
event.preventDefault();
zoomAt(event.deltaY < 0 ? 1.09 : .91, event.clientX, event.clientY);
}, { passive: false });
viewport.addEventListener('pointerdown', event => {
if (event.target.closest('.node')) return;
drag = { x: event.clientX, y: event.clientY, startX: view.x, startY: view.y };
viewport.setPointerCapture(event.pointerId);
viewport.classList.add('dragging');
});
viewport.addEventListener('pointermove', event => {
if (!drag) return;
view.x = drag.startX + event.clientX - drag.x;
view.y = drag.startY + event.clientY - drag.y;
applyTransform();
});
viewport.addEventListener('pointerup', () => { drag = null; viewport.classList.remove('dragging'); });
viewport.addEventListener('pointercancel', () => { drag = null; viewport.classList.remove('dragging'); });
document.getElementById('zoom-in').addEventListener('click', () => {
const rect = viewport.getBoundingClientRect(); zoomAt(1.15, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
document.getElementById('zoom-out').addEventListener('click', () => {
const rect = viewport.getBoundingClientRect(); zoomAt(.87, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
document.getElementById('fit').addEventListener('click', fitMap);
document.getElementById('reset-progress').addEventListener('click', () => {
discovered = new Set();
localStorage.removeItem('nether-inn-investigation');
updateProgress(); updateSelection(); renderInspector();
});
search.addEventListener('input', updateSelection);
filter.addEventListener('change', updateSelection);
window.addEventListener('resize', fitMap);
renderNodes();
renderEdges();
updateSelection();
requestAnimationFrame(fitMap);
</script>
</body>
</html>