This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
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 };
|
||||
const inits = { hub, memory, constellation, poem, bookshelf, recipe, timeline, ink, terminal, study, sigil, rpg, gentleConstellarium, futureZReturnLog, subconsciousIndex };
|
||||
if (inits[page]) inits[page](root);
|
||||
});
|
||||
|
||||
@@ -839,6 +839,314 @@
|
||||
load();
|
||||
}
|
||||
|
||||
const observatoryKey = "zxh_observatory_play_v1";
|
||||
|
||||
function readObservatoryState() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(observatoryKey) || "{}");
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeObservatoryState(next) {
|
||||
localStorage.setItem(observatoryKey, JSON.stringify(next));
|
||||
}
|
||||
|
||||
async function loadObservatory() {
|
||||
try {
|
||||
const response = await fetch("/assets/content/hidden-details.json", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("hidden details unavailable");
|
||||
const data = await response.json();
|
||||
return (data.entries || []).filter((entry) => entry.enabled !== false);
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function entryDepth(entry) {
|
||||
const layer = Number(entry.familyLayer);
|
||||
if (Number.isFinite(layer)) return Math.max(0, Math.min(5, layer));
|
||||
if ((entry.characters || []).includes("future z")) return 4;
|
||||
if ((entry.characters || []).includes("sensei chi")) return 3;
|
||||
if ((entry.characters || []).includes("young z") || (entry.characters || []).includes("lima")) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function characterLine(entry, fallback) {
|
||||
const chars = entry.characters || [];
|
||||
if (chars.includes("lima")) return "lima steadies this without making it smaller.";
|
||||
if (chars.includes("aphy")) return "aphy logs the relationship, then softens the label.";
|
||||
if (chars.includes("sensei chi")) return "sensei chi waits until the pattern stops trying to impress you.";
|
||||
if (chars.includes("young z")) return "young z thinks this memory needs a door and a sun.";
|
||||
if (chars.includes("future z")) return "future z has seen this thread from farther down the path.";
|
||||
return fallback || "the observatory accepts the shape for now.";
|
||||
}
|
||||
|
||||
function relationScore(a, b) {
|
||||
const shared = (left, right) => left.filter((item) => right.includes(item)).length;
|
||||
let score = 0;
|
||||
score += shared(a.characters || [], b.characters || []) * 3;
|
||||
score += shared(a.symbols || [], b.symbols || []) * 4;
|
||||
score += shared(a.narrativeArcs || [], b.narrativeArcs || []) * 3;
|
||||
score += a.emotionalTone && a.emotionalTone === b.emotionalTone ? 2 : 0;
|
||||
score += Math.max(0, 3 - Math.abs(entryDepth(a) - entryDepth(b)));
|
||||
["continuationLinks", "chainReferences", "echoes", "thematicLinks", "symbolicLinks", "triggerLinks", "parentLinks", "childLinks", "mirroredEntries"].forEach((key) => {
|
||||
if ((a[key] || []).includes(b.id) || (b[key] || []).includes(a.id)) score += 5;
|
||||
});
|
||||
return score;
|
||||
}
|
||||
|
||||
function importantEntries(entries, wantedCharacters) {
|
||||
return entries
|
||||
.filter((entry) => entry.title && entry.content)
|
||||
.filter((entry) => !wantedCharacters || (entry.characters || []).some((name) => wantedCharacters.includes(name)))
|
||||
.sort((a, b) => Number(b.resonanceScore || 0) - Number(a.resonanceScore || 0) || entryDepth(b) - entryDepth(a))
|
||||
.slice(0, 18);
|
||||
}
|
||||
|
||||
async function gentleConstellarium(root) {
|
||||
const entries = importantEntries(await loadObservatory(), ["lima", "aphy", "sensei chi", "young z", "future z", "z"]);
|
||||
const sky = $("[data-constellarium-sky]", root);
|
||||
const status = $("[data-constellarium-status]", root);
|
||||
const title = $("[data-constellarium-title]", root);
|
||||
const reading = $("[data-constellarium-reading]", root);
|
||||
const log = $("[data-constellarium-log]", root);
|
||||
let state = readObservatoryState();
|
||||
let selected = [];
|
||||
let relation = "continuationLinks";
|
||||
|
||||
function saveThread(a, b) {
|
||||
const score = relationScore(a, b);
|
||||
const thread = {
|
||||
id: `${a.id}:${b.id}:${relation}`,
|
||||
source: a.id,
|
||||
target: b.id,
|
||||
relation,
|
||||
score,
|
||||
title: `${a.title} / ${b.title}`,
|
||||
restoredAt: new Date().toISOString()
|
||||
};
|
||||
const restoredThreads = [...(state.restoredThreads || []).filter((item) => item.id !== thread.id), thread].slice(-24);
|
||||
state = { ...state, restoredThreads, lastRestoredThread: thread, characterTrust: bumpTrust(state.characterTrust, [...(a.characters || []), ...(b.characters || [])]) };
|
||||
if (score >= 8) state.crossGameFlags = { ...(state.crossGameFlags || {}), "thread:strong-restored": true };
|
||||
writeObservatoryState(state);
|
||||
status.textContent = score >= 8 ? "The thread holds. The sky remembers that shape." : "The thread is gentle but uncertain. Lima keeps it from falling.";
|
||||
reading.textContent = score >= 8 ? characterLine(b, "two memories agree without needing to be identical.") : "aphy marks this as possible. sensei chi says possible is a real room.";
|
||||
renderLog();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!entries.length) {
|
||||
sky.innerHTML = "<p class=\"play-live-note\">The observatory data is resting. Try again after the hidden details are available.</p>";
|
||||
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);
|
||||
return `<button type="button" class="observatory-star${active ? " is-active" : ""}" data-id="${escapeAttr(entry.id)}" style="--x:${50 + Math.cos(angle) * radius}%;--y:${50 + Math.sin(angle) * radius * 0.74}%;--size:${18 + Number(entry.resonanceScore || 3) * 2}px">
|
||||
<span>${escapeHtml((entry.characters || ["z"])[0].slice(0, 1))}</span>
|
||||
</button>`;
|
||||
}).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);
|
||||
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) => `<p><strong>${escapeHtml(thread.relation.replace("Links", ""))}</strong>: ${escapeHtml(thread.title)}</p>`).join("")
|
||||
: "<p>No thread has been restored here yet.</p>";
|
||||
}
|
||||
|
||||
$$("[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] || {};
|
||||
return `<button type="button" class="return-memory" data-id="${escapeAttr(entry.id)}">
|
||||
<strong>${escapeHtml(entry.title)}</strong>
|
||||
<span>${seen.count ? `observed ${seen.count} time${seen.count === 1 ? "" : "s"}` : "not observed here yet"}</span>
|
||||
</button>`;
|
||||
}).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]) => `<button type="button" class="symbol-chip${selected.includes(symbol) ? " is-active" : ""}" data-symbol="${escapeAttr(symbol)}">
|
||||
<strong>${escapeHtml(symbol)}</strong><span>${count}</span>
|
||||
</button>`).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]) => `<p><strong>${escapeHtml(symbol)}</strong>: ${escapeHtml(info.meaning)}</p>`).join("")
|
||||
: "<p>No object has admitted its meaning yet.</p>";
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user