(function () {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const shuffle = (items) => items.map((value) => ({ value, sort: Math.random() })).sort((a, b) => a.sort - b.sort).map((item) => item.value);
const quips = [
"A margin note blinks, then pretends it did not.",
"Somewhere, a bookmark changes its mind.",
"The archive is pleased by unnecessary curiosity.",
"A tiny bell rings in a room you have not built yet.",
"The page remembers that you came here."
];
const avatarRegistry = window.MemoryAvatarRegistry || { fallback: "z", characters: {} };
function avatarConfig(character) {
return avatarRegistry.characters?.[character] || avatarRegistry.characters?.[avatarRegistry.fallback || "z"] || {};
}
function avatarSource(character, expression = "calm") {
const config = avatarConfig(character);
return config.expressions?.[expression] || config.avatar || avatarConfig("z").avatar || "";
}
function avatarExpression(entry, character) {
const text = [entry?.title, entry?.content, entry?.triggerConditions, entry?.emotionalRole, entry?.mysteryLevel, ...(entry?.symbols || []), ...(entry?.tags || []), ...(entry?.narrativeArcs || [])].join(" ").toLowerCase();
if (character === "aphy" && /error|terminal|system|diagnostic|debug|console|search|symbol/.test(text)) return "thinking";
if (character === "lima" && /safe|home|warm|protect|care|eat|rest|light/.test(text)) return "protective";
if (character === "young z" && /draw|crayon|game|play|sun|blanket|child/.test(text)) return "surprised";
if (character === "future z" && /time|future|return|ordinary|warning|later/.test(text)) return "nostalgic";
if (character === "sensei chi" && /quiet|tea|patience|lesson|garden|wisdom/.test(text)) return "reflective";
return "calm";
}
function primaryCharacter(entry) {
return characterPresence(entry)[0]?.character || (entry?.characters || [])[0] || "z";
}
function characterPresence(entry) {
const text = [entry?.title, entry?.content, entry?.triggerConditions, entry?.pageLocation, entry?.emotionalRole, entry?.mysteryLevel, ...(entry?.tags || []), ...(entry?.symbols || []), ...(entry?.narrativeArcs || [])].join(" ").toLowerCase();
const scores = Object.keys(avatarRegistry.characters || {}).map((character) => {
const config = avatarConfig(character);
let score = (entry?.characters || []).includes(character) ? 10 : 0;
(config.observatory?.presenceKeywords || []).forEach((keyword) => {
if (text.includes(String(keyword).toLowerCase())) score += 3;
});
if (character === "future z" && entryDepth(entry || {}) === 4) score += 3;
return { character, score };
}).filter((item) => item.score > 0).sort((a, b) => b.score - a.score);
return scores.length ? scores : [{ character: "z", score: 1 }];
}
function avatarHtml(character, entry, size = "small", extraClass = "") {
const config = avatarConfig(character);
const expression = avatarExpression(entry, character);
const src = avatarSource(character, expression);
const label = config.displayLabel || character;
const color = config.glow || config.accent || "#d3a64d";
return `${escapeHtml((label || "?").slice(0, 1))}`;
}
document.addEventListener("DOMContentLoaded", () => {
const root = $(".play-root[data-play-page]");
if (!root) return;
const page = root.dataset.playPage;
const inits = { hub, memory, constellation, poem, bookshelf, recipe, timeline, ink, terminal, study, sigil, rpg, gentleConstellarium, futureZReturnLog, subconsciousIndex };
if (inits[page]) inits[page](root);
});
function hub(root) {
const links = $$(".play-grid a", root);
const nodes = $$(".play-orbit__node", root);
const title = $("#play-hub-title", root);
const kind = $("#play-hub-kind", root);
const desc = $("#play-hub-desc", root);
const cta = $("#play-hub-link", root);
const whisper = document.createElement("p");
let active = 0;
whisper.className = "play-whisper";
whisper.setAttribute("aria-live", "polite");
$(".play-console__screen", root).appendChild(whisper);
nodes.forEach((node, index) => {
const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2;
node.style.left = `${50 + Math.cos(angle) * 43}%`;
node.style.top = `${50 + Math.sin(angle) * 43}%`;
node.addEventListener("click", () => setActive(index));
});
function setActive(index) {
active = (index + links.length) % links.length;
const link = links[active];
links.forEach((item, i) => item.classList.toggle("is-active", i === active));
nodes.forEach((item, i) => item.classList.toggle("is-active", i === active));
title.textContent = link.querySelector("strong").textContent;
kind.textContent = link.dataset.kind;
desc.textContent = link.dataset.desc;
cta.href = link.href;
whisper.textContent = quips[active % quips.length];
}
links.forEach((link, index) => {
link.addEventListener("mouseenter", () => setActive(index));
link.addEventListener("focus", () => setActive(index));
});
$("[data-play-prev]", root).addEventListener("click", () => setActive(active - 1));
$("[data-play-next]", root).addEventListener("click", () => setActive(active + 1));
$("[data-play-random]", root).addEventListener("click", () => setActive(Math.floor(Math.random() * links.length)));
$(".play-orbit", root).addEventListener("dblclick", () => {
whisper.textContent = "You knocked twice. The cabinet knocked once back.";
root.classList.toggle("is-odd");
});
root.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") setActive(active - 1);
if (event.key === "ArrowRight") setActive(active + 1);
});
setActive(0);
}
function memory(root) {
const symbols = ["Ink", "Lamp", "Tea", "Book", "Map", "Key"];
const board = $("[data-memory-board]", root);
const turnsEl = $("[data-memory-turns]", root);
const matchesEl = $("[data-memory-matches]", root);
const messageEl = $("[data-memory-message]", root);
let open = [];
let turns = 0;
let matches = 0;
let deck = [];
function render() {
board.innerHTML = "";
open = [];
turns = 0;
matches = 0;
deck = shuffle([...symbols, ...symbols]);
turnsEl.textContent = "0";
matchesEl.textContent = "0";
messageEl.textContent = "Cards are shuffled.";
deck.forEach((symbol, index) => {
const card = document.createElement("button");
card.type = "button";
card.className = "memory-card";
card.dataset.symbol = symbol;
card.textContent = "?";
card.style.setProperty("--tilt", `${(index % 5) - 2}deg`);
card.setAttribute("aria-label", "Hidden card");
card.addEventListener("click", () => flip(card));
board.appendChild(card);
});
}
function flip(card) {
if (card.classList.contains("is-open") || card.classList.contains("is-matched") || open.length === 2) return;
card.classList.add("is-open");
card.textContent = card.dataset.symbol;
card.setAttribute("aria-label", card.dataset.symbol);
open.push(card);
if (open.length !== 2) return;
turns += 1;
turnsEl.textContent = String(turns);
const [a, b] = open;
if (a.dataset.symbol === b.dataset.symbol) {
a.classList.add("is-matched");
b.classList.add("is-matched");
open = [];
matches += 1;
matchesEl.textContent = String(matches);
messageEl.textContent = matches === symbols.length ? `Cabinet solved in ${turns} turns.` : "A drawer clicks open.";
} else {
messageEl.textContent = "No match. The cabinet quietly re-files them.";
setTimeout(() => {
a.classList.remove("is-open");
b.classList.remove("is-open");
a.textContent = "?";
b.textContent = "?";
a.setAttribute("aria-label", "Hidden card");
b.setAttribute("aria-label", "Hidden card");
open = [];
}, 650);
}
}
$("[data-memory-reset]", root).addEventListener("click", render);
$("[data-memory-peek]", root).addEventListener("click", () => {
const hidden = $$(".memory-card:not(.is-open):not(.is-matched)", root);
hidden.forEach((card) => {
card.textContent = card.dataset.symbol;
card.classList.add("is-peeking");
});
messageEl.textContent = "A very brief lapse in scholarly discipline.";
setTimeout(() => {
hidden.forEach((card) => {
if (!card.classList.contains("is-open") && !card.classList.contains("is-matched")) card.textContent = "?";
card.classList.remove("is-peeking");
});
}, 900);
});
render();
}
function constellation(root) {
const canvas = $("[data-star-canvas]", root);
const ctx = canvas.getContext("2d");
const linesEl = $("[data-star-lines]", root);
const nameEl = document.createElement("strong");
const stars = Array.from({ length: 18 }, () => ({ x: 50 + Math.random() * 800, y: 45 + Math.random() * 430 }));
const lines = [];
let selected = null;
nameEl.className = "play-live-note";
linesEl.closest(".play-scorebar").appendChild(nameEl);
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#18130f";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "rgba(219, 184, 104, 0.65)";
ctx.lineWidth = 2;
lines.forEach(([a, b]) => {
ctx.beginPath();
ctx.moveTo(stars[a].x, stars[a].y);
ctx.lineTo(stars[b].x, stars[b].y);
ctx.stroke();
});
stars.forEach((star, index) => {
ctx.beginPath();
ctx.fillStyle = index === selected ? "#f6d889" : "#fff8e8";
ctx.arc(star.x, star.y, index === selected ? 7 : 4, 0, Math.PI * 2);
ctx.fill();
});
linesEl.textContent = String(lines.length);
if (lines.length >= 3) nameEl.textContent = `Named: ${pick(["The Patient Spoon", "The South Window", "The Tired Comet", "The Fifth Errand"])}`;
}
canvas.addEventListener("click", (event) => {
const rect = canvas.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * canvas.width;
const y = ((event.clientY - rect.top) / rect.height) * canvas.height;
const hit = stars.findIndex((star) => Math.hypot(star.x - x, star.y - y) < 22);
if (hit < 0) return;
if (selected === null) selected = hit;
else if (selected !== hit) {
lines.push([selected, hit]);
selected = hit;
}
draw();
});
$("[data-star-reset]", root).addEventListener("click", () => {
lines.length = 0;
selected = null;
nameEl.textContent = "";
draw();
});
draw();
}
function poem(root) {
const output = $("[data-poem-output]", root);
const a = ["In the margin", "By the kettle", "Under the desk lamp", "Between two errands", "After the house quiets"];
const b = ["a careful thought", "an old joke", "a stubborn question", "a useful mistake", "a bright scrap"];
const c = ["learns to wait.", "asks for a second reading.", "finds its proper shelf.", "becomes tomorrow's note.", "keeps the page warm."];
let count = 0;
function make() {
count += 1;
output.innerHTML = `${pick(a)}
${pick(b)}
${pick(c)}`;
output.dataset.stamp = count % 5 === 0 ? "approved by the margin" : "";
}
$("[data-poem-generate]", root).addEventListener("click", make);
make();
}
function bookshelf(root) {
const titles = ["Algebra at Breakfast", "Cabinet of Weather", "Domestic Orbits", "Evening Margins", "Household Engines", "Zettels and Tea"];
const shelf = $("[data-bookshelf]", root);
const status = $("[data-bookshelf-status]", root);
let sortedOnce = false;
setupSortable(shelf, render, check);
$("[data-bookshelf-shuffle]", root).addEventListener("click", () => render(shuffle(titles)));
render(shuffle(titles));
function render(items) {
shelf.innerHTML = "";
items.forEach((title) => {
const book = document.createElement("div");
book.className = "book-spine";
book.draggable = true;
book.dataset.value = title;
book.textContent = title;
shelf.appendChild(book);
});
check();
}
function check() {
const current = $$(".book-spine", shelf).map((book) => book.dataset.value);
const sorted = current.join("|") === titles.join("|");
status.textContent = sorted ? "Shelf sorted. A secret pamphlet slides out." : "Unsorted";
if (sorted && !sortedOnce) {
sortedOnce = true;
const pamphlet = document.createElement("div");
pamphlet.className = "booklet";
pamphlet.textContent = "Pamphlet: On the Correct Order of Small Things";
shelf.appendChild(pamphlet);
}
}
}
function recipe(root) {
const wheel = $("[data-recipe-spin]", root);
const result = $("[data-recipe-result]", root);
const groups = [
["Rice", "Flatbread", "Roast potatoes", "Noodles"],
["Lemon chicken", "Spiced lentils", "Tomato eggs", "Pepper stew"],
["Cucumber salad", "Mint yoghurt", "Pickled onions", "Charred greens"],
["Serve with stories", "Eat outside", "Use the blue plates", "Make extra tea"]
];
let spin = 0;
let feast = 0;
wheel.addEventListener("click", () => {
spin += 540 + Math.floor(Math.random() * 540);
feast += 1;
wheel.style.transform = `rotate(${spin}deg)`;
result.innerHTML = groups.map((group) => `
The observatory data is resting. Try again after the hidden details are available.
"; return; } sky.innerHTML = entries.map((entry, index) => { const angle = (index / entries.length) * Math.PI * 2 - Math.PI / 2; const radius = 34 + entryDepth(entry) * 8; const active = selected.some((item) => item.id === entry.id); const character = primaryCharacter(entry); const presence = characterPresence(entry)[0]?.score || 1; const config = avatarConfig(character); const atmosphere = config.observatory?.atmosphere || "observatory light"; const focus = active ? " focus" : presence >= 10 ? " close" : presence >= 6 ? " medium" : " distant"; return ``; }).join(""); $$("[data-id]", sky).forEach((button) => { button.addEventListener("click", () => choose(entries.find((entry) => entry.id === button.dataset.id))); }); renderLog(); } function choose(entry) { if (!entry) return; if (selected.length === 2) selected = []; selected.push(entry); title.textContent = entry.title; reading.textContent = characterLine(entry); root.style.setProperty("--active-avatar-glow", avatarConfig(primaryCharacter(entry)).glow || "#d6b36a"); if (selected.length === 2) saveThread(selected[0], selected[1]); render(); } function renderLog() { const threads = state.restoredThreads || []; log.innerHTML = threads.length ? threads.slice(-6).reverse().map((thread) => { const entry = entries.find((item) => item.id === thread.source || item.id === thread.target); const character = primaryCharacter(entry || { characters: ["z"] }); return `${avatarHtml(character, entry, "tiny")}${escapeHtml(thread.relation.replace("Links", ""))}: ${escapeHtml(thread.title)}
`; }).join("") : "No thread has been restored here yet.
"; } $$("[data-constellarium-relation]", root).forEach((button) => { button.addEventListener("click", () => { relation = button.dataset.constellariumRelation; status.textContent = `The next thread will be a ${relation.replace("Links", "").replace("Entries", "")}.`; }); }); $("[data-constellarium-reset]", root).addEventListener("click", () => { selected = []; status.textContent = "The sky rests without forgetting."; title.textContent = "Waiting for a first light"; reading.textContent = "lima keeps a hand near the thread, not pulling, just steadying."; render(); }); render(); } async function futureZReturnLog(root) { const entries = importantEntries(await loadObservatory(), ["future z", "young z", "lima", "sensei chi"]).slice(0, 9); const now = Date.now(); let state = readObservatoryState(); const visits = { ...(state.returnVisits || {}) }; const page = visits["future-z-return-log"] || { count: 0, firstSeenAt: now, lastSeenAt: 0 }; const gap = page.lastSeenAt ? now - page.lastSeenAt : 0; visits["future-z-return-log"] = { ...page, count: page.count + 1, lastSeenAt: now }; state = { ...state, returnVisits: visits }; writeObservatoryState(state); const stage = $("[data-return-stage]", root); const heading = $("[data-return-heading]", root); const message = $("[data-return-message]", root); const context = $("[data-return-context]", root); const status = $("[data-return-status]", root); const list = $("[data-return-list]", root); const restored = (state.restoredThreads || []).length; const symbols = Object.keys(state.discoveredSymbols || {}).length; const patient = Number(state.patientMoments || 0); function render() { const count = visits["future-z-return-log"].count; const minutes = Math.floor(gap / 60000); stage.textContent = count <= 1 ? "First opening" : count < 4 ? `${count} returns` : "familiar return"; heading.textContent = restored ? "future z found one of your restored threads." : count > 1 ? "The page recognizes your shape in time." : "The log is still warming up."; message.textContent = restored ? "future z: I remember the thread you helped hold together. Some repairs become routes." : count > 2 ? "future z: returning is not the same as being stuck. Sometimes it is how care checks the lock." : "future z has left the first note face down, so the ink does not smudge."; context.textContent = minutes ? `Last gap: about ${minutes} minutes. ${symbols ? "One discovered symbol is travelling with you." : "No symbol has followed you here yet."}` : patient ? "sensei chi notices the quiet you brought from another room." : "aphy has no elapsed-time anomaly to report yet."; list.innerHTML = entries.map((entry, index) => { const seen = visits[entry.id] || {}; const character = primaryCharacter(entry); return ``; }).join(""); $$("[data-id]", list).forEach((button) => button.addEventListener("click", () => observe(button.dataset.id))); } function observe(id) { const entry = entries.find((item) => item.id === id); if (!entry) return; const seen = visits[id] || { count: 0, firstSeenAt: now }; visits[id] = { ...seen, count: seen.count + 1, lastSeenAt: Date.now() }; state = { ...readObservatoryState(), returnVisits: visits }; if ((entry.characters || []).includes("future z")) state.crossGameFlags = { ...(state.crossGameFlags || {}), "future-z:observed": true }; writeObservatoryState(state); status.textContent = characterLine(entry, "future z folds the observation into the log."); render(); } $("[data-return-observe]", root).addEventListener("click", () => entries[0] && observe(entries[0].id)); $("[data-return-wait]", root).addEventListener("click", () => { state = readObservatoryState(); state.patientMoments = Number(state.patientMoments || 0) + 1; state.crossGameFlags = { ...(state.crossGameFlags || {}), "patience:return-log": true }; writeObservatoryState(state); status.textContent = "sensei chi notices that you did not rush the page."; render(); }); $("[data-return-reset]", root).addEventListener("click", () => { state = readObservatoryState(); delete state.returnVisits; writeObservatoryState(state); status.textContent = "The local drift was cleared. Future z leaves the kindness, not the counter."; }); render(); } async function subconsciousIndex(root) { const entries = await loadObservatory(); let state = readObservatoryState(); let selected = []; const cloud = $("[data-symbol-cloud]", root); const status = $("[data-subconscious-status]", root); const sentence = $("[data-dream-sentence]", root); const reading = $("[data-dream-reading]", root); const routes = $("[data-subconscious-routes]", root); const counts = new Map(); entries.forEach((entry) => (entry.symbols || []).forEach((symbol) => counts.set(symbol, (counts.get(symbol) || 0) + 1))); [["ring", 1], ["tea", 1], ["crayon sun", 1], ["clock", 1], ["door", 1], ["mirror", 1], ["console", 1], ["kitchen light", 1]].forEach(([symbol, count]) => { if (!counts.has(symbol)) counts.set(symbol, count); }); const symbols = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 16); function render() { cloud.innerHTML = symbols.map(([symbol, count]) => { const character = primaryCharacter({ title: symbol, content: symbol, symbols: [symbol] }); return ``; }).join(""); $$("[data-symbol]", cloud).forEach((button) => button.addEventListener("click", () => toggleSymbol(button.dataset.symbol))); const known = state.discoveredSymbols || {}; routes.innerHTML = Object.keys(known).length ? Object.entries(known).slice(-8).map(([symbol, info]) => `${escapeHtml(symbol)}: ${escapeHtml(info.meaning)}
`).join("") : "No object has admitted its meaning yet.
"; } function toggleSymbol(symbol) { selected = selected.includes(symbol) ? selected.filter((item) => item !== symbol) : [...selected, symbol].slice(-3); const phrase = dreamPhrase(selected); sentence.textContent = phrase.title; reading.textContent = phrase.reading; if (selected.length >= 2) { const discoveredSymbols = { ...(state.discoveredSymbols || {}) }; selected.forEach((item) => { discoveredSymbols[item] = { meaning: phrase.meaning, discoveredAt: new Date().toISOString(), path: selected }; }); state = { ...state, discoveredSymbols, lastDreamPath: selected, crossGameFlags: { ...(state.crossGameFlags || {}), "symbol:dream-sentence": true } }; writeObservatoryState(state); status.textContent = selected.length === 3 ? "The website answers in objects, then pretends it was only navigation." : "A symbolic route has begun to breathe."; } render(); } $("[data-subconscious-clear]", root).addEventListener("click", () => { selected = []; sentence.textContent = "Nothing has combined yet."; reading.textContent = "young z has drawn a door in the margin and refuses to explain why that helps."; status.textContent = "The current dream closed without being lost."; render(); }); render(); } function dreamPhrase(symbols) { const key = symbols.slice().sort().join("|"); const table = { "clock|ring": ["A future kept warm", "future z recognizes the ring as a promise that learned to wait.", "promise and return"], "crayon sun|door": ["Safety drawn before it was understood", "young z makes a door because a page can become a room.", "play and shelter"], "console|tea": ["Logic learned to bow", "aphy accepts the cup from sensei chi and stops explaining for one line.", "systems softened by patience"], "kitchen light|ring": ["Home on purpose", "lima turns the symbol back toward the ordinary room where it belongs.", "warmth and vow"], "mirror|ring": ["The promise has a reflection", "the observatory shows the same memory from the side that was quiet.", "echo and vow"] }; const picked = table[key] || (symbols.length ? [`${symbols.join(" / ")}`, "sensei chi says the meaning is not late. It is arriving by a slower path.", "private weather"] : ["Nothing has combined yet.", "young z has drawn a door in the margin and refuses to explain why that helps.", "waiting"]); return { title: picked[0], reading: picked[1], meaning: picked[2] }; } function bumpTrust(current, characters) { const next = { ...(current || {}) }; characters.forEach((name) => { if (["lima", "aphy", "sensei chi", "young z", "future z"].includes(name)) next[name] = Number(next[name] || 0) + 1; }); return next; } function escapeHtml(value) { return String(value || "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" })[char]); } function escapeAttr(value) { return escapeHtml(value).replace(/`/g, "`"); } function setupSortable(container, _render, after) { let dragged = null; container.addEventListener("dragstart", (event) => { dragged = event.target.closest("[draggable='true']"); if (dragged) event.dataTransfer.effectAllowed = "move"; }); container.addEventListener("dragover", (event) => { event.preventDefault(); const target = event.target.closest("[draggable='true']"); if (!dragged || !target || target === dragged) return; const rect = target.getBoundingClientRect(); const before = event.clientY < rect.top + rect.height / 2 || event.clientX < rect.left + rect.width / 2; container.insertBefore(dragged, before ? target : target.nextSibling); after(); }); container.addEventListener("dragend", () => { dragged = null; after(); }); } function pick(items) { return items[Math.floor(Math.random() * items.length)]; } })();