Refactor org web content and simplify shared UI
All checks were successful
Build Org Website / build (push) Successful in 44s

This commit is contained in:
gitea-actions
2026-07-29 15:17:17 +01:00
parent db90e9f452
commit 7afc554f5d
31 changed files with 1156 additions and 2198 deletions

View File

@@ -274,7 +274,7 @@ The published HTML is static, but some browser modules rely on same-origin APIs:
| Notes board | `pages/notes.js` | `/api/notes` |
| Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` |
| Competency board | `pages/competency-status-board.js` | `/api/competencies/items...` |
| RPG saves | `pages/play.js`, `pages/ash-below-lake.js` | `/api/play/rpg/save/:slot`, with local-storage fallback |
| Archive World progress | `pages/archive-world.js` | Device-local `localStorage` state |
These APIs are not implemented here. Production routing must serve `output/` and proxy `/api/` to the appropriate backend. When changing an endpoint contract, coordinate the static client and its external service.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -1,7 +0,0 @@
(function () {
"use strict";
var script = document.createElement("script");
script.src = "/assets/scripts/pages/ash-below-lake.js";
script.defer = true;
document.head.appendChild(script);
}());

View File

@@ -0,0 +1,114 @@
(function (root, factory) {
"use strict";
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
root.ArchiveWorldState = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const VERSION = 1;
const STORAGE_KEY = "zxh_archive_world_v1";
const PALETTES = Object.freeze(["brass", "moss", "berry"]);
const LANDMARK_IDS = Object.freeze([
"gate",
"library",
"study",
"inn",
"workshop",
"playroom",
"museum",
"garden"
]);
const SPAWN = Object.freeze({ x: 640, y: 830 });
function validateName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
return name.length >= 1 && name.length <= 20 ? name : null;
}
function validatePalette(value) {
return PALETTES.includes(value) ? value : null;
}
function fresh(name, palette) {
return {
version: VERSION,
name: validateName(name) || "",
palette: validatePalette(palette) || "brass",
discovered: [],
complete: false,
position: { x: SPAWN.x, y: SPAWN.y },
soundEnabled: false
};
}
function clamp(value, min, max, fallback) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
function normalize(candidate) {
if (!candidate || candidate.version !== VERSION) return null;
const name = validateName(candidate.name);
const palette = validatePalette(candidate.palette);
if (!name || !palette) return null;
const discovered = Array.isArray(candidate.discovered)
? Array.from(new Set(candidate.discovered.filter((id) => LANDMARK_IDS.includes(id))))
: [];
return {
version: VERSION,
name,
palette,
discovered,
complete: LANDMARK_IDS.every((id) => discovered.includes(id)),
position: {
x: clamp(candidate.position && candidate.position.x, 36, 1244, SPAWN.x),
y: clamp(candidate.position && candidate.position.y, 36, 924, SPAWN.y)
},
soundEnabled: candidate.soundEnabled === true
};
}
function parse(raw) {
if (!raw) return null;
try {
return normalize(JSON.parse(raw));
} catch (_error) {
return null;
}
}
function discover(state, landmarkId) {
const current = normalize(state);
if (!current || !LANDMARK_IDS.includes(landmarkId)) return current;
if (!current.discovered.includes(landmarkId)) current.discovered.push(landmarkId);
current.complete = LANDMARK_IDS.every((id) => current.discovered.includes(id));
return current;
}
function withPosition(state, x, y) {
const current = normalize(state);
if (!current) return null;
current.position.x = clamp(x, 36, 1244, SPAWN.x);
current.position.y = clamp(y, 36, 924, SPAWN.y);
return current;
}
return Object.freeze({
VERSION,
STORAGE_KEY,
PALETTES,
LANDMARK_IDS,
SPAWN,
validateName,
validatePalette,
fresh,
normalize,
parse,
discover,
withPosition
});
}));

View File

@@ -0,0 +1,420 @@
(function () {
"use strict";
const State = window.ArchiveWorldState;
const LANDMARKS = Object.freeze({
gate: landmark("Town Gate", "Arrivals", 640, 830,
"The old gate remembers every arrival. Begin at the dashboard or follow the newest paths.",
[["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]]),
library: landmark("Grand Library", "Knowledge", 260, 285,
"Long-lived explanations, categories, and learning notes fill the blue-roofed library.",
[["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]]),
study: landmark("Guild Study", "Work", 640, 250,
"Engineering notes, professional lessons, and active competencies cover every desk.",
[["Career", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"]]),
inn: landmark("Kitchen Inn", "Daily life", 1005, 270,
"Weekly reviews and ordinary days stay warm beside the inn's oven.",
[["Blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blogs introduction", "/blogs/blogs-intro.html"]]),
workshop: landmark("Workshop", "Living systems", 1030, 535,
"Trackers, services, plans, and practical machinery keep the town moving.",
[["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"]]),
playroom: landmark("Playroom", "Experiments", 1005, 765,
"Games, generators, and stranger little mechanisms glow behind the bright windows.",
[["Play hub", "/play/play.html"], ["The Rain Index", "/play/the-rain-index.html"], ["House of Pages", "/play/house.html"]]),
museum: landmark("Lima Museum", "Keepsakes", 275, 765,
"Personal fragments, older writing, and small memories rest beneath the glass roof.",
[["Lima archive", "/lima/index.html"], ["Older writing", "/blogs/2025/2025-list.html"], ["Memory Cabinet", "/play/memory.html"]]),
garden: landmark("Notes Garden", "Connections", 220, 515,
"Loose notes, paths between topics, and recently tended pages grow beyond the trellis.",
[["Notes wall", "/home/notes.html"], ["Categories", "/home/categories.html"], ["Recently updated", "/recently-updated.html"], ["Sitemap", "/sitemap.html"]])
});
const PALETTE_TINTS = Object.freeze({
brass: 0xf1c46f,
moss: 0x91c788,
berry: 0xd894ad
});
const controller = {
state: null,
scene: null,
lock: false,
move: { up: false, down: false, left: false, right: false },
reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
saveTimer: 0
};
function landmark(title, eyebrow, x, y, description, links) {
return Object.freeze({ title, eyebrow, x, y, description, links: Object.freeze(links) });
}
document.addEventListener("DOMContentLoaded", init);
function init() {
const root = document.querySelector('.archive-world[data-play-page="archive-world"]');
if (!root || !State) return;
const stored = State.parse(localStorage.getItem(State.STORAGE_KEY));
controller.state = stored;
bindInterface(root);
if (!window.Phaser) {
setStatus(root, "The world engine could not load. Every destination remains available in the plain directory.");
return;
}
if (stored) {
startGame(root);
} else {
openSetup(root);
}
}
function bindInterface(root) {
const setup = root.querySelector("[data-world-setup]");
const setupForm = root.querySelector("[data-world-setup-form]");
const preview = root.querySelector("[data-world-preview]");
const journal = root.querySelector("[data-world-journal]");
setup.addEventListener("cancel", (event) => event.preventDefault());
setupForm.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(setupForm);
const name = State.validateName(data.get("name"));
const palette = State.validatePalette(data.get("palette"));
const error = root.querySelector("[data-world-setup-error]");
if (!name || !palette) {
error.textContent = "Choose a palette and enter a name between 1 and 20 characters.";
return;
}
controller.state = State.fresh(name, palette);
persist();
setup.close();
startGame(root);
});
root.querySelector("[data-world-preview-close]").addEventListener("click", () => preview.close());
preview.addEventListener("close", () => {
controller.lock = false;
root.querySelector("#archive-world-game").focus();
});
root.querySelector("[data-world-journal-open]").addEventListener("click", () => {
renderJournal(root);
controller.lock = true;
journal.showModal();
});
root.querySelector("[data-world-journal-close]").addEventListener("click", () => journal.close());
journal.addEventListener("close", () => {
controller.lock = false;
root.querySelector("[data-world-journal-open]").focus();
});
root.querySelector("[data-world-sound]").addEventListener("click", () => toggleSound(root));
root.querySelector("[data-world-reset]").addEventListener("click", () => {
if (!window.confirm("Reset your traveler, discoveries, and saved position?")) return;
localStorage.removeItem(State.STORAGE_KEY);
window.location.reload();
});
root.querySelectorAll("[data-world-move]").forEach((button) => {
const direction = button.dataset.worldMove;
const down = (event) => {
event.preventDefault();
controller.move[direction] = true;
};
const up = (event) => {
event.preventDefault();
controller.move[direction] = false;
};
button.addEventListener("pointerdown", down);
button.addEventListener("pointerup", up);
button.addEventListener("pointercancel", up);
button.addEventListener("pointerleave", up);
});
root.querySelector("[data-world-interact]").addEventListener("click", () => {
if (controller.scene) controller.scene.interact();
});
window.addEventListener("pagehide", savePosition);
document.addEventListener("visibilitychange", () => {
if (document.hidden) savePosition();
});
}
function openSetup(root) {
const dialog = root.querySelector("[data-world-setup]");
window.setTimeout(() => {
dialog.showModal();
dialog.querySelector("input[name=name]").focus();
}, 0);
}
function startGame(root) {
renderHud(root);
const game = new Phaser.Game({
type: Phaser.AUTO,
parent: "archive-world-game",
width: 960,
height: 640,
backgroundColor: "#17251e",
pixelArt: true,
roundPixels: true,
physics: {
default: "arcade",
arcade: { debug: false }
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
scene: ArchiveTownScene,
input: {
keyboard: true,
mouse: true,
touch: true
}
});
root.dataset.gameReady = "true";
root.querySelector("#archive-world-game").focus();
window.archiveWorldGame = game;
}
class ArchiveTownScene extends Phaser.Scene {
constructor() {
super("ArchiveTown");
this.nearest = null;
this.lastStepAt = 0;
this.wasMoving = false;
}
preload() {
this.load.image("archive-town", "/assets/images/play/archive-world/archive-town.webp");
this.load.image("traveler", "/assets/images/play/archive-world/traveler.png");
this.load.audio("ambient", "/assets/audio/archive-world/ambient.ogg");
this.load.audio("step", "/assets/audio/archive-world/step.wav");
this.load.audio("discover", "/assets/audio/archive-world/discover.wav");
this.load.audio("portal", "/assets/audio/archive-world/portal.wav");
}
create() {
controller.scene = this;
this.add.image(0, 0, "archive-town").setOrigin(0).setDepth(0);
this.physics.world.setBounds(0, 0, 1280, 960);
this.markers = {};
Object.entries(LANDMARKS).forEach(([id, item]) => {
const marker = this.add.circle(item.x, item.y, 22, 0xf4d98a, 0.16)
.setStrokeStyle(3, 0xf8e6a7, 0.9)
.setDepth(2);
marker.setData("landmarkId", id);
this.markers[id] = marker;
if (!controller.reducedMotion) {
this.tweens.add({
targets: marker,
scale: 1.18,
alpha: 0.58,
duration: 900,
yoyo: true,
repeat: -1,
delay: Object.keys(LANDMARKS).indexOf(id) * 90
});
}
});
this.player = this.physics.add.sprite(
controller.state.position.x,
controller.state.position.y,
"traveler"
).setDepth(4).setTint(PALETTE_TINTS[controller.state.palette]);
this.player.setCollideWorldBounds(true);
this.player.body.setSize(28, 34).setOffset(8, 58);
this.cameras.main.setBounds(0, 0, 1280, 960);
this.cameras.main.startFollow(this.player, true, controller.reducedMotion ? 1 : 0.12, controller.reducedMotion ? 1 : 0.12);
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
up: Phaser.Input.Keyboard.KeyCodes.W,
down: Phaser.Input.Keyboard.KeyCodes.S,
left: Phaser.Input.Keyboard.KeyCodes.A,
right: Phaser.Input.Keyboard.KeyCodes.D,
interact: Phaser.Input.Keyboard.KeyCodes.E,
enter: Phaser.Input.Keyboard.KeyCodes.ENTER
});
this.ambient = this.sound.add("ambient", { loop: true, volume: 0.24 });
this.stepSound = this.sound.add("step", { volume: 0.22 });
this.discoverSound = this.sound.add("discover", { volume: 0.35 });
this.portalSound = this.sound.add("portal", { volume: 0.3 });
if (controller.state.soundEnabled) this.enableSound();
setStatus(document.querySelector(".archive-world"), `Welcome, ${controller.state.name}. Walk near a glowing landmark and press E, Space, or Enter.`);
this.updateNearest();
}
update(time) {
if (!this.player) return;
let dx = 0;
let dy = 0;
if (!controller.lock) {
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown || controller.move.right) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown || controller.move.up) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1;
}
const moving = dx !== 0 || dy !== 0;
if (moving) {
const vector = new Phaser.Math.Vector2(dx, dy).normalize().scale(175);
this.player.setVelocity(vector.x, vector.y);
if (dx) this.player.setFlipX(dx < 0);
if (!controller.reducedMotion) {
this.player.rotation = Math.sin(time / 80) * 0.025;
this.player.setScale(1, 0.98 + Math.abs(Math.sin(time / 95)) * 0.04);
}
if (controller.state.soundEnabled && time - this.lastStepAt > 340) {
this.stepSound.play();
this.lastStepAt = time;
}
if (time - controller.saveTimer > 700) {
controller.saveTimer = time;
savePosition();
}
} else {
this.player.setVelocity(0, 0);
this.player.rotation = 0;
this.player.setScale(1);
}
this.wasMoving = moving;
this.updateNearest();
if (!controller.lock && (
Phaser.Input.Keyboard.JustDown(this.keys.interact) ||
Phaser.Input.Keyboard.JustDown(this.keys.enter) ||
Phaser.Input.Keyboard.JustDown(this.cursors.space)
)) this.interact();
}
updateNearest() {
let nearest = null;
let distance = Infinity;
Object.entries(LANDMARKS).forEach(([id, item]) => {
const nextDistance = Phaser.Math.Distance.Between(this.player.x, this.player.y, item.x, item.y);
if (nextDistance < distance) {
nearest = id;
distance = nextDistance;
}
this.markers[id].setStrokeStyle(nextDistance < 95 ? 5 : 3, nextDistance < 95 ? 0xffffff : 0xf8e6a7, 0.95);
});
const next = distance < 95 ? nearest : null;
if (next !== this.nearest) {
this.nearest = next;
const root = document.querySelector(".archive-world");
setStatus(root, next
? `${LANDMARKS[next].title} is nearby. Press E, Space, Enter, or Explore.`
: "Follow the stone paths toward a glowing landmark.");
}
}
interact() {
if (controller.lock) return;
if (!this.nearest) {
setStatus(document.querySelector(".archive-world"), "Move closer to one of the glowing landmark circles.");
return;
}
openPreview(document.querySelector(".archive-world"), this.nearest);
}
enableSound() {
if (this.sound.locked && this.sound.unlock) this.sound.unlock();
this.sound.mute = false;
if (!this.ambient.isPlaying) this.ambient.play();
}
disableSound() {
this.sound.mute = true;
if (this.ambient.isPlaying) this.ambient.pause();
}
}
function openPreview(root, id) {
const item = LANDMARKS[id];
if (!item) return;
const wasDiscovered = controller.state.discovered.includes(id);
controller.state = State.discover(controller.state, id);
persist();
controller.lock = true;
root.querySelector("[data-world-preview-eyebrow]").textContent = item.eyebrow;
root.querySelector("[data-world-preview-title]").textContent = item.title;
root.querySelector("[data-world-preview-description]").textContent = item.description;
const links = root.querySelector("[data-world-preview-links]");
links.replaceChildren();
item.links.forEach(([label, href]) => {
const anchor = document.createElement("a");
anchor.href = href;
anchor.textContent = label;
anchor.addEventListener("click", () => {
if (controller.scene && controller.state.soundEnabled) controller.scene.portalSound.play();
savePosition();
});
links.appendChild(anchor);
});
renderHud(root);
if (!wasDiscovered && controller.scene && controller.state.soundEnabled) controller.scene.discoverSound.play();
root.querySelector("[data-world-preview]").showModal();
setStatus(root, wasDiscovered
? `${item.title} is already recorded in your journal.`
: `${item.title} was added to your discovery journal.`);
}
function toggleSound(root) {
if (!controller.state || !controller.scene) return;
controller.state.soundEnabled = !controller.state.soundEnabled;
if (controller.state.soundEnabled) controller.scene.enableSound();
else controller.scene.disableSound();
persist();
renderHud(root);
setStatus(root, controller.state.soundEnabled ? "Ambient sound is on." : "Sound is muted.");
}
function savePosition() {
if (!controller.state || !controller.scene || !controller.scene.player) return;
controller.state = State.withPosition(controller.state, controller.scene.player.x, controller.scene.player.y);
persist();
}
function persist() {
if (controller.state) localStorage.setItem(State.STORAGE_KEY, JSON.stringify(controller.state));
}
function renderHud(root) {
if (!controller.state) return;
root.querySelector("[data-world-player]").textContent = controller.state.name;
root.querySelector("[data-world-discovery-count]").textContent = `${controller.state.discovered.length} / ${State.LANDMARK_IDS.length}`;
root.querySelector("[data-world-sound]").textContent = controller.state.soundEnabled ? "Sound: on" : "Sound: muted";
const badge = root.querySelector("[data-world-badge]");
badge.hidden = !controller.state.complete;
renderJournal(root);
}
function renderJournal(root) {
if (!controller.state) return;
const list = root.querySelector("[data-world-journal-list]");
list.replaceChildren();
State.LANDMARK_IDS.forEach((id) => {
const row = document.createElement("li");
const found = controller.state.discovered.includes(id);
row.className = found ? "is-discovered" : "";
row.textContent = `${found ? "Discovered" : "Unexplored"}${LANDMARKS[id].title}`;
list.appendChild(row);
});
}
function setStatus(root, message) {
const status = root && root.querySelector("[data-world-status]");
if (status) status.textContent = message;
}
}());

File diff suppressed because it is too large Load Diff

View File

@@ -236,5 +236,7 @@ async function loadBoard() {
populateMobileControls(items);
}
if (document.getElementById("kanban-board")) {
renderLevelTabs();
loadBoard();
}

View File

@@ -76,7 +76,7 @@
caption: "A wooden moon rolls beneath the cabinet whenever nobody is looking.",
curated: [
["Play hub", "/play/play.html"],
["Ash Below the Lake", "/play/rpg.html"],
["The Archive World", "/play/rpg.html"],
["The Rain Index", "/play/the-rain-index.html"]
],
include: ["/play/"],

View File

@@ -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, ink, terminal, study, sigil };
if (inits[page]) inits[page](root);
});
@@ -256,63 +256,6 @@
}
}
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) => `<li>${pick(group)}</li>`).join("") + (feast % 4 === 0 ? "<li>Bonus: someone gets the last crispy bit.</li>" : "");
});
wheel.click();
}
function timeline(root) {
const items = [
{ year: "2022", text: "The personal web habit begins." },
{ year: "2025", text: "Org publishing becomes the main site engine." },
{ year: "2025", text: "Weekly reviews and career notes grow into a library." },
{ year: "2026", text: "Dashboards, services, and authoring tools join the site." },
{ year: "2026", text: "The play wing opens." }
];
const list = $("[data-timeline-list]", root);
const status = $("[data-timeline-status]", root);
const paradox = document.createElement("button");
paradox.type = "button";
paradox.textContent = "Paradox";
$("[data-timeline-shuffle]", root).insertAdjacentElement("afterend", paradox);
setupSortable(list, render, check);
$("[data-timeline-shuffle]", root).addEventListener("click", () => render(shuffle(items)));
paradox.addEventListener("click", () => render([...items].reverse()));
render(shuffle(items));
function render(source) {
list.innerHTML = "";
source.forEach((item, index) => {
const card = document.createElement("div");
card.className = "timeline-card";
card.draggable = true;
card.dataset.value = String(items.indexOf(item));
card.innerHTML = `<span>${item.year}</span><strong>${item.text}</strong>`;
list.appendChild(card);
});
check();
}
function check() {
const current = $$(".timeline-card", list).map((card) => Number(card.dataset.value));
status.textContent = current.every((value, index) => value === index) ? "Timeline restored" : "Arrange the cards";
}
}
function ink(root) {
const canvas = $("[data-ink-canvas]", root);
const ctx = canvas.getContext("2d");
@@ -492,353 +435,6 @@
draw();
}
function rpg(root) {
if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
const canvas = $("[data-rpg-canvas]", root);
const ctx = canvas.getContext("2d");
const els = {
speaker: $("[data-rpg-speaker]", root),
line: $("[data-rpg-line]", root),
room: $("[data-rpg-room]", root),
route: $("[data-rpg-route]", root),
hearts: $("[data-rpg-hearts]", root),
quests: $("[data-rpg-quests]", root),
inventory: $("[data-rpg-inventory]", root),
status: $("[data-rpg-status]", root)
};
const tile = 32;
const saveSlot = "hollow-archive";
const fallbackKey = "play:rpg:hollow-archive";
const items = {
lamp: { label: "Desk Lamp", room: "entrance", x: 10, y: 8, color: "#f2c94c" },
page: { label: "Loose Page", room: "stacks", x: 18, y: 5, color: "#f7efe0" },
key: { label: "Basement Key", room: "garden", x: 4, y: 11, color: "#d59b45" }
};
const npcs = {
archivist: { name: "Archivist", room: "entrance", x: 6, y: 6, color: "#b98bff" },
shade: { name: "Shy Shade", room: "stacks", x: 17, y: 9, color: "#6ed0d4" },
gate: { name: "Iron Door", room: "garden", x: 19, y: 10, color: "#8a8f98" }
};
const rooms = {
entrance: {
name: "Entrance",
floor: "#353029",
exits: [{ x: 22, y: 7, to: "stacks", px: 1, py: 7 }],
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 16], [23, 0, 1, 6], [23, 9, 1, 7], [8, 3, 1, 8], [14, 7, 5, 1]])
},
stacks: {
name: "Stacks",
floor: "#242d35",
exits: [{ x: 0, y: 7, to: "entrance", px: 22, py: 7 }, { x: 23, y: 12, to: "garden", px: 1, py: 12 }],
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 6], [0, 9, 1, 7], [23, 0, 1, 11], [23, 14, 1, 2], [4, 3, 2, 10], [10, 2, 2, 11], [16, 3, 2, 5]])
},
garden: {
name: "Moon Garden",
floor: "#21362e",
exits: [{ x: 0, y: 12, to: "stacks", px: 22, py: 12 }],
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 11], [0, 14, 1, 2], [23, 0, 1, 16], [7, 4, 10, 1], [7, 10, 1, 4], [13, 10, 1, 4]])
}
};
let state = freshState();
let running = false;
function freshState() {
return {
room: "entrance",
player: { x: 3, y: 7, facing: "down" },
inventory: [],
flags: {},
route: "Undecided",
hearts: 3,
ending: null,
message: { speaker: "Archivist", line: "The archive waits. Find the lamp, help the shade, then decide what to do with the locked door." }
};
}
function rects(sources) {
const set = new Set();
sources.forEach(([x, y, w, h]) => {
for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`);
});
return set;
}
function draw() {
const room = rooms[state.room];
ctx.fillStyle = "#14110e";
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < 16; y += 1) {
for (let x = 0; x < 24; x += 1) {
ctx.fillStyle = room.walls.has(`${x},${y}`) ? "#181716" : room.floor;
ctx.fillRect(x * tile, y * tile, tile, tile);
ctx.strokeStyle = "rgba(255,255,255,0.035)";
ctx.strokeRect(x * tile, y * tile, tile, tile);
}
}
room.exits.forEach((exit) => drawGlyph(exit.x, exit.y, "#c48a41", "door"));
Object.entries(items).forEach(([id, item]) => {
if (item.room === state.room && !state.inventory.includes(id)) drawGlyph(item.x, item.y, item.color, "item");
});
Object.values(npcs).forEach((npc) => {
if (npc.room === state.room) drawGlyph(npc.x, npc.y, npc.color, npc.name === "Iron Door" ? "doorNpc" : "npc");
});
drawPlayer();
renderHud();
requestAnimationFrame(draw);
}
function drawGlyph(x, y, color, kind) {
const px = x * tile;
const py = y * tile;
ctx.fillStyle = color;
if (kind === "item") {
ctx.fillRect(px + 10, py + 10, 12, 12);
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fillRect(px + 13, py + 7, 6, 6);
} else if (kind === "door" || kind === "doorNpc") {
ctx.fillRect(px + 7, py + 4, 18, 25);
ctx.fillStyle = "#211812";
ctx.fillRect(px + 20, py + 16, 3, 3);
} else {
ctx.beginPath();
ctx.arc(px + 16, py + 12, 9, 0, Math.PI * 2);
ctx.fill();
ctx.fillRect(px + 8, py + 20, 16, 8);
}
}
function drawPlayer() {
const px = state.player.x * tile;
const py = state.player.y * tile;
ctx.fillStyle = "#f35f5f";
ctx.beginPath();
ctx.moveTo(px + 16, py + 7);
ctx.bezierCurveTo(px + 2, py + 2, px + 1, py + 22, px + 16, py + 28);
ctx.bezierCurveTo(px + 31, py + 22, px + 30, py + 2, px + 16, py + 7);
ctx.fill();
ctx.fillStyle = "#fff8e8";
ctx.fillRect(px + 11, py + 13, 4, 4);
ctx.fillRect(px + 18, py + 13, 4, 4);
}
function renderHud() {
els.speaker.textContent = state.message.speaker;
els.line.textContent = state.ending ? endingLine() : state.message.line;
els.room.textContent = rooms[state.room].name;
els.route.textContent = state.route;
els.hearts.textContent = String(state.hearts);
els.inventory.innerHTML = state.inventory.length
? state.inventory.map((id) => `<li>${items[id].label}</li>`).join("")
: "<li>Empty</li>";
const questRows = [
["Find a light", state.inventory.includes("lamp")],
["Return the loose page to the shade", state.flags.shadeHelped],
["Open, force, or leave the iron door", Boolean(state.ending)]
];
els.quests.innerHTML = questRows.map(([text, done]) => `<li class="${done ? "is-done" : ""}">${done ? "Done: " : ""}${text}</li>`).join("");
}
function move(dx, dy, facing) {
if (!running || state.ending) return;
state.player.facing = facing;
const nx = state.player.x + dx;
const ny = state.player.y + dy;
const room = rooms[state.room];
const exit = room.exits.find((candidate) => candidate.x === nx && candidate.y === ny);
if (exit) {
state.room = exit.to;
state.player.x = exit.px;
state.player.y = exit.py;
say("Narrator", `You enter ${rooms[state.room].name}.`);
autosave();
return;
}
if (nx < 0 || ny < 0 || nx > 23 || ny > 15 || room.walls.has(`${nx},${ny}`) || npcAt(nx, ny)) return;
state.player.x = nx;
state.player.y = ny;
const item = itemAt(nx, ny);
if (item) take(item);
}
function act() {
if (!running) return start();
if (state.ending) return;
const front = inFront();
const npc = npcAt(front.x, front.y);
if (npc) talk(npc);
else {
const here = itemAt(state.player.x, state.player.y);
if (here) take(here);
else say("Narrator", "Dust moves in the light. Nothing asks to be changed here.");
}
autosave();
}
function inFront() {
const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1];
return { x: state.player.x + delta[0], y: state.player.y + delta[1] };
}
function itemAt(x, y) {
return Object.keys(items).find((id) => {
const item = items[id];
return item.room === state.room && item.x === x && item.y === y && !state.inventory.includes(id);
});
}
function npcAt(x, y) {
return Object.keys(npcs).find((id) => {
const npc = npcs[id];
return npc.room === state.room && npc.x === x && npc.y === y;
});
}
function take(id) {
state.inventory.push(id);
if (id === "lamp") state.route = "Gentle";
if (id === "key" && !state.flags.shadeHelped) state.route = "Power";
say("Found", `${items[id].label} joined your inventory.`);
}
function talk(id) {
if (id === "archivist") {
if (!state.inventory.includes("lamp")) say("Archivist", "Take the lamp from the lower desk. The stacks dislike being crossed in the dark.");
else if (!state.flags.shadeHelped) say("Archivist", "A loose page has gone missing. The quiet reader in the stacks knows where it belongs.");
else say("Archivist", "You have been kind to a forgotten page. The garden door will remember that.");
}
if (id === "shade") {
if (!state.inventory.includes("page")) say("Shy Shade", "I lost the page with my name on it. It fell somewhere nearby.");
else {
state.flags.shadeHelped = true;
state.inventory = state.inventory.filter((item) => item !== "page");
state.route = "Mercy";
say("Shy Shade", "You returned my page instead of keeping it. Take the honest route through the garden.");
}
}
if (id === "gate") {
if (state.flags.shadeHelped) end("mercy");
else if (state.inventory.includes("key")) end("power");
else end("quiet");
}
}
function end(kind) {
state.ending = kind;
state.route = kind === "mercy" ? "Mercy" : kind === "power" ? "Power" : "Quiet";
say("Ending", endingLine());
save();
}
function endingLine() {
if (state.ending === "mercy") return "Mercy ending: the iron door opens without a sound, and every returned page remembers your name.";
if (state.ending === "power") return "Power ending: the key turns, but the archive grows colder around the missing page.";
if (state.ending === "quiet") return "Quiet ending: you leave the locked door alone. Some mysteries stay intact.";
return state.message.line;
}
function say(speaker, line) {
state.message = { speaker, line };
}
function start() {
running = true;
say("Archivist", "Walk the archive. Speak gently, or take what you need. The route will notice.");
els.status.textContent = "Started. Progress autosaves after room changes and actions.";
}
async function save() {
localStorage.setItem(fallbackKey, JSON.stringify(state));
try {
const response = await fetch(`/api/play/rpg/save/${saveSlot}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: state })
});
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
els.status.textContent = "Saved to backend.";
} catch (_error) {
els.status.textContent = "Saved locally. Backend save API was not reachable.";
}
}
async function load() {
try {
const response = await fetch(`/api/play/rpg/save/${saveSlot}`);
if (response.ok) {
const data = await response.json();
state = normalizeState(data.payload);
running = true;
els.status.textContent = "Loaded from backend.";
return;
}
} catch (_error) {
/* Fall through to local save. */
}
const local = localStorage.getItem(fallbackKey);
if (local) {
state = normalizeState(JSON.parse(local));
running = true;
els.status.textContent = "Loaded local save.";
} else {
els.status.textContent = "No save found.";
}
}
function normalizeState(candidate) {
return Object.assign(freshState(), candidate || {}, {
player: Object.assign(freshState().player, (candidate && candidate.player) || {}),
inventory: Array.isArray(candidate && candidate.inventory) ? candidate.inventory.filter((id) => items[id]) : [],
flags: Object.assign({}, (candidate && candidate.flags) || {}),
message: Object.assign(freshState().message, (candidate && candidate.message) || {})
});
}
function autosave() {
save();
}
$("[data-rpg-start]", root).addEventListener("click", start);
$("[data-rpg-save]", root).addEventListener("click", save);
$("[data-rpg-load]", root).addEventListener("click", load);
$("[data-rpg-reset]", root).addEventListener("click", async () => {
state = freshState();
running = false;
localStorage.removeItem(fallbackKey);
try {
await fetch(`/api/play/rpg/save/${saveSlot}`, { method: "DELETE" });
els.status.textContent = "Reset and cleared backend save.";
} catch (_error) {
els.status.textContent = "Reset locally. Backend save API was not reachable.";
}
});
$("[data-rpg-act]", root).addEventListener("click", act);
$$("[data-rpg-move]", root).forEach((button) => {
const moves = { up: [0, -1, "up"], down: [0, 1, "down"], left: [-1, 0, "left"], right: [1, 0, "right"] };
button.addEventListener("click", () => move(...moves[button.dataset.rpgMove]));
});
window.addEventListener("keydown", (event) => {
if (!root.isConnected) return;
const tag = event.target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const keys = {
ArrowUp: [0, -1, "up"], w: [0, -1, "up"],
ArrowDown: [0, 1, "down"], s: [0, 1, "down"],
ArrowLeft: [-1, 0, "left"], a: [-1, 0, "left"],
ArrowRight: [1, 0, "right"], d: [1, 0, "right"]
};
if (keys[event.key]) {
event.preventDefault();
move(...keys[event.key]);
}
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
act();
}
});
draw();
load();
}
function setupSortable(container, _render, after) {
let dragged = null;
container.addEventListener("dragstart", (event) => {

View File

@@ -5,6 +5,8 @@
(function () {
"use strict";
if (!document.getElementById("wird-app")) return;
const API = "/api/wird";
// type: "count" | "juz" | "min" | "rating" | "nafl" | "khatm"

View File

@@ -1,11 +1,11 @@
/* Event listener for scrolling and changing the active label on the TOC */
document.addEventListener("DOMContentLoaded", () => {
const toc = document.querySelector("#text-table-of-contents");
if (!toc) { console.warn("No #text-table-of-contents found"); return; }
if (!toc) return;
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
// <a href="#introduction">Intro</a> matches
if (!links.length) { console.warn("No ToC links found"); return; }
if (!links.length) return;
// Map: id -> link
const linkById = new Map();
@@ -14,7 +14,7 @@ document.addEventListener("DOMContentLoaded", () => {
const el = document.getElementById(id);
if (el) linkById.set(id, a);
});
if (!linkById.size) { console.warn("No matching headings with IDs"); return; }
if (!linkById.size) return;
// Headings to observe (h2h4 usually)
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]

File diff suppressed because one or more lines are too long

View File

@@ -307,16 +307,14 @@
transform: rotate(-4deg);
}
.bookshelf,
.timeline-list {
.bookshelf {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: stretch;
}
.book-spine,
.timeline-card {
.book-spine {
touch-action: none;
border: 1px solid var(--border);
border-radius: 7px;
@@ -350,54 +348,6 @@
font-weight: 700;
}
.timeline-list {
flex-direction: column;
}
.timeline-card {
display: grid;
grid-template-columns: 5rem 1fr;
gap: 1rem;
}
.timeline-card span {
color: var(--play-brass);
font-weight: 900;
}
.recipe-tool {
display: grid;
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr);
gap: 1rem;
align-items: center;
}
.recipe-wheel {
aspect-ratio: 1;
width: 100%;
border-radius: 50% !important;
background:
conic-gradient(from 0deg, var(--play-red), var(--play-brass), var(--play-green), var(--play-blue), var(--play-red));
color: #fff8e8 !important;
font-weight: 900;
transition: transform 900ms cubic-bezier(.18, .88, .2, 1.15);
}
.recipe-result {
display: grid;
gap: 0.7rem;
padding: 0;
margin: 0;
list-style: none;
}
.recipe-result li {
padding: 0.85rem;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface);
}
.terminal-tool {
background: #15120e;
color: #f2e3bd;
@@ -495,316 +445,358 @@
background: var(--surface);
}
.rpg-shell {
display: grid;
grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
.archive-world {
--world-ink: #17251e;
--world-panel: #f3ead2;
--world-paper: #fff9e9;
--world-line: #8b6841;
--world-brass: #d49a3a;
--world-moss: #527657;
--world-berry: #9d5268;
max-width: 1440px;
}
.ash-rpg-root {
--ash-black: #05070c;
--ash-ink: #0d1420;
--ash-panel: #121a27;
--ash-line: #344154;
--ash-gold: #e0a451;
--ash-cold: #8bd3ff;
--ash-red: #db4f62;
--ash-text: #f8efe0;
--ash-muted: #a8b4c2;
max-width: 1380px;
.archive-world__heading {
max-width: 900px;
}
.ash-rpg-root .play-page-head {
padding: 1rem 0;
border-bottom: 1px solid rgba(139, 211, 255, 0.18);
.archive-world__shell {
overflow: hidden;
border: 2px solid var(--world-line);
border-radius: 12px;
background: var(--world-ink);
box-shadow: 0 22px 70px rgba(23, 37, 30, 0.28);
}
.ash-rpg-root .play-page-head h1 {
color: var(--ash-text);
font-family: Georgia, "Times New Roman", serif;
text-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
}
.ash-rpg-root .play-page-head p,
.ash-rpg-root .play-back {
color: var(--ash-muted);
}
.ash-rpg-shell {
position: relative;
border-color: rgba(139, 211, 255, 0.22);
.archive-world__toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
padding: 0.75rem;
border-bottom: 2px solid #6e5738;
background:
linear-gradient(180deg, rgba(12, 20, 32, 0.96), rgba(4, 7, 12, 0.98)),
var(--ash-black);
box-shadow:
0 0 0 1px rgba(224, 164, 81, 0.14) inset,
0 28px 80px rgba(0, 0, 0, 0.42);
linear-gradient(rgba(255, 255, 255, 0.04), transparent),
#2d3f33;
color: #fff6dc;
}
.ash-rpg-shell::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 8px;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 4px);
mix-blend-mode: screen;
opacity: 0.35;
}
.rpg-stage {
.archive-world__toolbar > div {
display: grid;
gap: 0.75rem;
min-width: 110px;
}
.rpg-canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
image-rendering: pixelated;
border: 2px solid var(--ash-line);
border-radius: 8px;
background: var(--ash-black);
box-shadow:
0 0 0 4px #05070c,
0 0 34px rgba(139, 211, 255, 0.16);
}
.ash-rpg-canvas {
min-height: 360px;
}
.rpg-dialogue,
.rpg-panel {
border: 1px solid var(--ash-line);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(21, 31, 46, 0.95), rgba(8, 12, 18, 0.96)),
var(--ash-panel);
color: var(--ash-text);
}
.rpg-dialogue {
min-height: 130px;
padding: 1rem;
box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset;
}
.rpg-dialogue strong,
.rpg-list h2 {
color: var(--ash-gold);
}
.rpg-dialogue p,
.rpg-status {
margin: 0.35rem 0 0;
color: var(--ash-muted);
line-height: 1.55;
}
.rpg-panel {
display: grid;
gap: 1rem;
padding: 1rem;
align-content: start;
}
.rpg-actions,
.rpg-mobile-pad {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.ash-rpg-root button {
border-color: rgba(139, 211, 255, 0.28);
background: linear-gradient(180deg, #1a2638, #0c121c);
color: var(--ash-text);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-weight: 800;
.archive-world__toolbar span {
color: #d8c9a6;
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ash-rpg-root button:hover:not(:disabled),
.ash-rpg-root button:focus-visible {
background: linear-gradient(180deg, #31435f, #142236);
color: #ffffff;
box-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
.archive-world__toolbar button,
.archive-world__controls button,
.archive-world__dialog button {
border: 1px solid #d6b777;
border-radius: 6px;
background: #fff3d2;
color: #2b2116;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.ash-rpg-root button:disabled {
cursor: not-allowed;
opacity: 0.42;
.archive-world__toolbar button {
margin-left: auto;
padding: 0.55rem 0.75rem;
}
.rpg-mobile-pad {
grid-template-columns: repeat(3, minmax(0, 1fr));
.archive-world__toolbar button + button {
margin-left: 0;
}
.rpg-mobile-pad [data-rpg-move="up"],
.rpg-mobile-pad [data-rpg-move="down"] {
.archive-world__toolbar button:hover,
.archive-world__toolbar button:focus-visible,
.archive-world__controls button:hover,
.archive-world__controls button:focus-visible,
.archive-world__dialog button:hover,
.archive-world__dialog button:focus-visible {
border-color: #fff0b5;
background: #fffaf0;
box-shadow: 0 0 0 3px rgba(255, 236, 173, 0.24);
}
.archive-world__badge {
margin: 0;
padding: 0.4rem 0.65rem;
border: 1px solid #f2d483;
border-radius: 999px;
background: #604b1f;
color: #fff1b6;
font-size: 0.78rem;
font-weight: 900;
}
.archive-world__game {
position: relative;
width: 100%;
aspect-ratio: 3 / 2;
max-height: min(72vh, 760px);
overflow: hidden;
outline: none;
background: #17251e;
image-rendering: pixelated;
}
.archive-world__game:focus-visible {
box-shadow: inset 0 0 0 4px #fff0a9;
}
.archive-world__game canvas {
display: block;
width: 100% !important;
height: 100% !important;
image-rendering: pixelated;
}
.archive-world__loading {
position: absolute;
inset: 45% 0 auto;
margin: 0;
color: #f5e4b7;
text-align: center;
font-weight: 800;
}
.archive-world[data-game-ready="true"] .archive-world__loading {
display: none;
}
.archive-world__controls {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
padding: 0.8rem;
border-top: 2px solid #6e5738;
background: #2d3f33;
touch-action: none;
}
.archive-world__dpad {
display: grid;
grid-template-columns: repeat(3, 46px);
grid-template-rows: repeat(2, 42px);
gap: 0.3rem;
}
.archive-world__dpad [data-world-move="up"] {
grid-column: 2;
}
.rpg-mobile-pad [data-rpg-move="left"] {
.archive-world__dpad [data-world-move="left"] {
grid-column: 1;
}
.rpg-mobile-pad [data-rpg-act] {
.archive-world__dpad [data-world-move="down"] {
grid-column: 2;
}
.rpg-mobile-pad [data-rpg-move="right"] {
.archive-world__dpad [data-world-move="right"] {
grid-column: 3;
}
.rpg-stats {
display: grid;
gap: 0.55rem;
margin: 0;
.archive-world__controls button {
min-height: 42px;
padding: 0.5rem 0.8rem;
user-select: none;
-webkit-user-select: none;
}
.rpg-stats div {
.archive-world__interact {
min-width: 110px;
min-height: 64px !important;
}
.archive-world__status,
.archive-world__instructions {
margin: 0;
padding: 0.65rem 1rem;
color: #f5e4b7;
text-align: center;
}
.archive-world__status {
border-top: 1px solid rgba(245, 228, 183, 0.16);
font-weight: 800;
}
.archive-world__instructions {
padding-top: 0;
color: #cbbd9d;
font-size: 0.85rem;
}
.archive-world__dialog {
width: min(92vw, 620px);
max-height: 86vh;
overflow: auto;
border: 2px solid var(--world-line);
border-radius: 10px;
padding: 1.2rem;
background:
linear-gradient(rgba(255, 255, 255, 0.45), transparent 50%),
var(--world-panel);
color: #2b2116;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.46);
}
.archive-world__dialog::backdrop {
background: rgba(15, 24, 19, 0.78);
backdrop-filter: blur(3px);
}
.archive-world__dialog h2 {
margin-top: 0.25rem;
font-family: Georgia, "Times New Roman", serif;
}
.archive-world__dialog form,
.archive-world__dialog label,
.archive-world__dialog fieldset {
display: grid;
gap: 0.65rem;
}
.archive-world__dialog input[type="text"] {
width: 100%;
border: 1px solid var(--world-line);
border-radius: 6px;
padding: 0.7rem;
background: var(--world-paper);
color: #2b2116;
font: inherit;
}
.archive-world__dialog fieldset {
grid-template-columns: repeat(3, minmax(0, 1fr));
border: 1px solid #b79b70;
}
.archive-world__dialog fieldset label {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.45rem;
border-bottom: 1px solid rgba(139, 211, 255, 0.16);
gap: 0.4rem;
align-items: center;
}
.rpg-stats dt,
.rpg-list h2 {
margin: 0;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
.archive-world__dialog button {
padding: 0.65rem 0.85rem;
}
.rpg-stats dd {
.palette-swatch {
width: 18px;
height: 18px;
border: 2px solid #392e22;
border-radius: 3px;
}
.palette-swatch--brass { background: #f1c46f; }
.palette-swatch--moss { background: #91c788; }
.palette-swatch--berry { background: #d894ad; }
.archive-world__error {
min-height: 1.4em;
margin: 0;
color: #8b2635;
font-weight: 800;
}
.rpg-list ul {
.archive-world__preview-links {
display: grid;
gap: 0.45rem;
margin: 0.45rem 0 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin: 1rem 0;
}
.archive-world__preview-links a {
padding: 0.75rem;
border: 1px solid var(--world-line);
border-radius: 6px;
background: var(--world-paper);
color: #3a2b18;
font-weight: 800;
text-decoration: none;
}
.archive-world__preview-links a:hover,
.archive-world__preview-links a:focus-visible {
background: #fff;
box-shadow: 0 0 0 3px rgba(82, 118, 87, 0.25);
}
.archive-world__journal {
display: grid;
gap: 0.5rem;
padding: 0;
list-style: none;
}
.rpg-list li {
padding: 0.55rem;
border: 1px solid rgba(139, 211, 255, 0.16);
border-radius: 6px;
background: rgba(5, 7, 12, 0.62);
color: var(--ash-text);
font-size: 0.92rem;
}
.rpg-list li.is-done {
border-color: rgba(102, 199, 162, 0.42);
background: rgba(45, 91, 77, 0.34);
}
.rpg-list li.is-erased {
border-color: rgba(219, 79, 98, 0.32);
color: rgba(248, 239, 224, 0.42);
background: repeating-linear-gradient(90deg, rgba(219, 79, 98, 0.08), rgba(219, 79, 98, 0.08) 6px, rgba(5, 7, 12, 0.6) 6px, rgba(5, 7, 12, 0.6) 12px);
text-decoration: line-through;
}
.ash-choicebar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.ash-choicebar button {
min-height: 50px;
}
.ash-memory-meter {
position: relative;
display: grid;
gap: 0.45rem;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.ash-memory-meter::after {
content: "";
display: block;
height: 10px;
border: 1px solid rgba(139, 211, 255, 0.24);
border-radius: 999px;
background: #05070c;
}
.ash-memory-meter i {
position: absolute;
left: 1px;
right: auto;
bottom: 1px;
width: 0;
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, var(--ash-cold), var(--ash-red));
transition: width 220ms ease;
}
.ash-name-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: end;
}
.ash-name-form label {
display: grid;
gap: 0.35rem;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.ash-name-form input {
width: 100%;
border: 1px solid rgba(139, 211, 255, 0.24);
border-radius: 6px;
.archive-world__journal li {
padding: 0.65rem;
background: #05070c;
color: var(--ash-text);
font: inherit;
border: 1px dashed #aa977b;
border-radius: 5px;
color: #756956;
}
.ash-rpg-root.is-lake-awake .rpg-dialogue,
.ash-rpg-root.is-lake-awake .rpg-panel {
border-color: rgba(219, 79, 98, 0.36);
.archive-world__journal li.is-discovered {
border-style: solid;
border-color: #638568;
background: #e5efdc;
color: #29452e;
font-weight: 800;
}
.ash-rpg-root.is-lake-awake .ash-rpg-canvas {
box-shadow:
0 0 0 4px #05070c,
0 0 38px rgba(219, 79, 98, 0.22);
.archive-world__directory {
margin-top: 1.5rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
.ash-rpg-root.is-ending .rpg-panel {
box-shadow: 0 0 34px rgba(224, 164, 81, 0.16);
.archive-world__directory summary {
padding: 0.9rem 1rem;
font-weight: 900;
cursor: pointer;
}
.archive-world__directory-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
padding: 0 1rem 1rem;
}
.archive-world__directory-grid section {
padding: 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
}
.archive-world__directory-grid h2 {
margin: 0 0 0.45rem;
font-size: 1rem;
}
.archive-world__noscript {
padding: 0.8rem;
border-left: 4px solid var(--world-brass);
background: var(--surface);
}
@media (max-width: 760px) {
.play-hero,
.play-console,
.recipe-tool,
.sigil-tool,
.rpg-shell {
.sigil-tool {
grid-template-columns: 1fr;
}
@@ -821,12 +813,48 @@
min-height: 160px;
}
.ash-choicebar,
.ash-name-form {
.archive-world__toolbar > div {
min-width: 82px;
}
.archive-world__toolbar button {
margin-left: 0;
}
.archive-world__game {
min-height: 360px;
max-height: 62vh;
}
.archive-world__dialog fieldset,
.archive-world__preview-links,
.archive-world__directory-grid {
grid-template-columns: 1fr;
}
.ash-rpg-canvas {
min-height: auto;
}
@media (max-width: 480px) {
.archive-world__game {
min-height: 300px;
}
.archive-world__controls {
justify-content: space-between;
}
.archive-world__dpad {
grid-template-columns: repeat(3, 42px);
}
}
@media (prefers-reduced-motion: reduce) {
.archive-world *,
.archive-world *::before,
.archive-world *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@@ -701,3 +701,62 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/gitea-build-mon
2026-07-21T01:00:59.4587832+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-21T01:00:59.5350433+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-21T01:00:59.9886800+01:00 [INFO] Sent authoring server test notification.
2026-07-21T12:09:03.6175899+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-21T12:09:03.6252048+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-21T12:09:04.4499267+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-21T12:09:04.4697653+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-21T12:09:04.5483233+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-21T12:09:04.8706027+01:00 [INFO] Sent authoring server test notification.
2026-07-22T01:01:24.7920497+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-22T01:01:24.8001389+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-22T01:01:26.7297805+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-22T01:01:26.7559529+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-22T01:01:26.9028910+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-22T01:01:27.2365854+01:00 [INFO] Sent authoring server test notification.
2026-07-23T01:01:18.8035310+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-23T01:01:18.8120210+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-23T01:01:20.0831461+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-23T01:01:20.1060772+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-23T01:01:20.1887487+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-23T01:01:20.4711305+01:00 [INFO] Sent authoring server test notification.
2026-07-24T01:01:27.2870874+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-24T01:01:27.2947406+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-24T01:01:28.1121881+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-24T01:01:28.1314531+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-24T01:01:28.2017989+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-24T01:01:29.3171443+01:00 [INFO] Sent authoring server test notification.
2026-07-25T01:01:33.9981810+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-25T01:01:34.0073830+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-25T01:01:35.2771459+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-25T01:01:35.3069751+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-25T01:01:35.3949964+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-25T01:01:35.7310924+01:00 [INFO] Sent authoring server test notification.
2026-07-26T01:01:30.0185478+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-26T01:01:30.0259875+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-26T01:01:31.0690727+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-26T01:01:31.0895824+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-26T01:01:31.1699179+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-26T01:01:31.7304381+01:00 [INFO] Sent authoring server test notification.
2026-07-27T01:01:33.9108228+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-27T01:01:33.9181628+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-27T01:01:35.1163859+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-27T01:01:35.1367747+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-27T01:01:35.2133300+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-27T01:01:35.5108202+01:00 [INFO] Sent authoring server test notification.
2026-07-28T01:01:29.1770646+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-28T01:01:29.1847179+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-28T01:01:30.7324900+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-28T01:01:30.7520630+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-28T01:01:30.8305107+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-28T01:01:31.2158991+01:00 [INFO] Sent authoring server test notification.
2026-07-29T01:02:37.1096593+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-29T01:02:37.1171545+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-29T01:02:53.6950255+01:00 [WARN] Discord webhook failed, attempt 1/3: Resource temporarily unavailable (discord.com:443)
2026-07-29T01:03:06.9463075+01:00 [WARN] Discord webhook failed, attempt 2/3: Resource temporarily unavailable (discord.com:443)
2026-07-29T01:03:22.3057775+01:00 [WARN] Discord webhook failed, attempt 3/3: Resource temporarily unavailable (discord.com:443)
2026-07-29T01:03:22.3313219+01:00 [ERROR] Build monitor failed: Resource temporarily unavailable (discord.com:443)
2026-07-29T01:03:22.3415198+01:00 [ERROR] Exception type: System.Net.Http.HttpRequestException
2026-07-29T01:03:22.3472157+01:00 [ERROR] Stack trace: at Invoke-DiscordWebhook, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 179
at Send-BuildStatusNotification, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 985
at Start-BuildMonitor, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 1262
at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 1287

View File

@@ -1,320 +0,0 @@
## Title: **Ash Below the Lake**
### Premise
A quiet town sits beside a black lake that never reflects the sky.
Every year, one person disappears into the water.
Nobody talks about it.
You play as a teenager named **Ilyas**, who arrives in the isolated town of **Morrows End** after receiving a letter from a sibling who vanished there years ago. The letter contains only one sentence:
> “Dont let the lake remember your name.”
The game begins normally — odd townsfolk, quirky humor, strange shops — but slowly becomes darker as the player realizes the town itself is alive in a subtle, dreamlike way.
---
# Core Themes
* Memory vs identity
* Grief and denial
* The fear of being forgotten
* Whether pain should be erased or accepted
Like *Undertale*, the game balances:
* humor
* emotional character writing
* eerie atmosphere
* choices that genuinely matter
---
# The World
## Morrows End
A sleepy lakeside town with:
* crooked houses
* underground tunnels
* abandoned train stations
* old flood barriers
* glowing flowers near the shore
The town is split into districts:
1. **Harbor Row** fishermen, markets, friendly NPCs
2. **The Red Streets** entertainment district with unsettling performers
3. **The Roots** underground caverns beneath the town
4. **The Drowned Chapel** sunken cathedral inside the lake
5. **The Hollow Shore** final area where reality begins collapsing
---
# Main Gameplay Twist
The lake “remembers” people.
If someone is forgotten by everybody, they slowly become erased from reality.
Not dead.
Erased.
Photos change.
Dialogue changes.
Rooms disappear.
Music distorts.
As the player progresses, NPCs begin forgetting characters the player met earlier.
Eventually the player questions:
* Is the town cursed?
* Or is the lake protecting people from painful memories?
---
# Main Characters
## Ilyas (Player)
Quiet protagonist searching for their sibling.
The player can shape Ilyas:
* compassionate
* detached
* manipulative
* fearful
Choices affect dialogue, endings, and combat resolution.
---
## Mira
A sarcastic runaway girl who joins you early.
She uses humor to hide panic and becomes emotionally attached to the player.
She remembers things others forget.
That should be impossible.
---
## The Ferryman
A masked boatman who appears throughout the game.
He speaks in riddles and always knows where the player has been.
Sometimes he appears before important choices.
He may not be human.
---
## Brother Cinder
Leader of the Drowned Chapel.
Believes forgetting pain is mercy.
Wants to feed memories to the lake so humanity can become “peaceful.”
Not purely evil — deeply tragic.
---
## Your Sibling
You spend the whole game searching for them.
Late-game reveal:
They willingly entered the lake.
And they may no longer fully exist.
---
# Combat System
Similar vibe to *Undertale*:
* turn-based encounters
* bullet-hell dodging
* dialogue options during fights
But instead of MERCY/FIGHT:
## Three Main Actions
* **Confront** — direct resistance
* **Listen** — understand enemies emotionally
* **Forget** — erase enemies from memory
Using “Forget” makes encounters easier…
…but causes side effects in the world.
NPCs disappear.
Music tracks vanish.
Areas become inaccessible.
The player slowly realizes:
They are becoming part of the lake.
---
# Major Story Beats
## Act 1 — Strange Town
* Meet Mira
* Discover townsfolk avoid speaking certain names
* Encounter first erased person
* Learn your sibling stayed at the old chapel
Tone:
cozy + unsettling
---
## Act 2 — The Roots
You descend beneath the town.
Find:
* memory archives
* abandoned homes nobody remembers
* creatures made from forgotten people
The player discovers:
The lake feeds on emotional suffering.
---
## Act 3 — The Drowned Chapel
Brother Cinder reveals the truth:
The lake was created long ago to contain humanitys collective grief.
Without it:
people would drown in their pain.
But now it has grown hungry.
---
## Final Act — The Hollow Shore
Reality breaks down:
* menus glitch
* save points speak
* NPC dialogue rewrites itself
* your own name occasionally changes
The player must choose:
### Ending 1 — “Silence”
Join the lake.
All pain disappears.
The town becomes peaceful… empty.
---
### Ending 2 — “Carry”
Destroy the lake.
Everyone regains painful memories.
The town survives but suffers.
Hopeful but bittersweet.
---
### Ending 3 — “Forgotten”
Use “Forget” too often.
Eventually:
the protagonist themselves is erased.
The final scene shows the game continuing without you.
No one remembers the player existed.
---
# Tone & Style
Visual Style:
* pixel art
* heavy shadows
* warm interiors vs cold outdoor areas
* subtle environmental animations
Music:
* soft piano
* detuned music-box melodies
* distorted ambient tracks late-game
Humor:
* weird NPC dialogue
* absurd side quests
* fake advertisements
* awkward interactions
The humor makes the emotional moments hit harder.
---
# Extra Mechanics
## Memory Journal
Instead of a quest log, the player keeps memories.
Entries can:
* change
* disappear
* rewrite themselves
The player cannot always trust it.
---
## Name System
NPCs react differently depending on whether you tell them your real name.
Some characters literally gain power if they know it.
---
# Optional Secret Route
If the player never uses “Forget” and resolves conflicts peacefully:
* hidden areas appear
* erased characters return temporarily
* Mira reveals she died years ago and is only remembered because of you
This leads to the true ending:
The lake is not destroyed.
It is forgiven.
And finally allowed to rest.

View File

@@ -61,7 +61,6 @@
"pages/home-dashboard.js"
"pages/play.js"
"pages/house.js"
"pages/ash-below-lake.js"
"features/hidden-details.js")
"\n")
(format "<script src=\"%s\"></script>"

View File

@@ -31,7 +31,7 @@
("org-assets"
:base-directory ,(site-path "assets/")
:base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\|pdf\\|mp4\\|webm\\|mov\\|woff\\|woff2\\|ttf"
:base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\|pdf\\|mp4\\|webm\\|mov\\|ogg\\|wav\\|woff\\|woff2\\|ttf"
:publishing-directory ,(output-path "assets/")
:recursive t
:publishing-function org-publish-attachment)

View File

@@ -114,7 +114,7 @@
<section><h2>Study</h2><a href="/posts/career/career-list.html">Career</a> · <a href="/home/status.html">Competency status</a></section>
<section><h2>Kitchen</h2><a href="/blogs/blogs-list.html">Blogs</a> · <a href="/tags/review.html">Weekly reviews</a></section>
<section><h2>Workshop</h2><a href="/home/services.html">Services</a> · <a href="/home/wird-tracker.html">Wird tracker</a> · <a href="/home/backlog.html">Backlog</a></section>
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/rpg.html">Ash Below the Lake</a></section>
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/rpg.html">The Archive World</a></section>
<section><h2>Attic</h2><a href="/lima/index.html">Lima archive</a> · <a href="/blogs/blogs-list.html">Older writing</a></section>
<section><h2>Garden</h2><a href="/home/notes.html">Notes wall</a> · <a href="/home/categories.html">Categories</a> · <a href="/sitemap.html">Sitemap</a></section>
</div>

View File

@@ -23,8 +23,6 @@
<button class="play-orbit__node" type="button" data-index="8">9</button>
<button class="play-orbit__node" type="button" data-index="9">10</button>
<button class="play-orbit__node" type="button" data-index="10">11</button>
<button class="play-orbit__node" type="button" data-index="11">12</button>
<button class="play-orbit__node" type="button" data-index="12">13</button>
</div>
</section>
@@ -47,15 +45,13 @@
<a href="/play/constellation.html" data-kind="Sketch" data-desc="Draw a star map by connecting points, then name the constellation."><span>02</span><strong>Constellation Desk</strong></a>
<a href="/play/poem.html" data-kind="Generator" data-desc="Compose tiny academic marginalia with a hand-cranked phrase machine."><span>03</span><strong>Marginalia Machine</strong></a>
<a href="/play/bookshelf.html" data-kind="Puzzle" data-desc="Sort a shelf of invented books into a pleasing order before the bell."><span>04</span><strong>Bookshelf Sort</strong></a>
<a href="/play/recipe.html" data-kind="Spinner" data-desc="Spin a family supper wheel and collect a playful menu."><span>05</span><strong>Supper Wheel</strong></a>
<a href="/play/timeline.html" data-kind="Puzzle" data-desc="Drag family-site milestones into chronological order."><span>06</span><strong>Timeline Tangle</strong></a>
<a href="/play/ink.html" data-kind="Canvas" data-desc="Make a living ink pond with ripples, trails, and quiet motion."><span>07</span><strong>Ink Pond</strong></a>
<a href="/play/terminal.html" data-kind="Story" data-desc="Explore a tiny command-line adventure hidden in the archive."><span>08</span><strong>Archive Terminal</strong></a>
<a href="/play/study.html" data-kind="Timer" data-desc="Run a focus timer that grows a little desk scene as time passes."><span>09</span><strong>Study Lamp</strong></a>
<a href="/play/sigil.html" data-kind="Maker" data-desc="Generate a small personal sigil from initials, colors, and motto."><span>10</span><strong>Sigil Press</strong></a>
<a href="/play/rpg.html" data-kind="RPG" data-desc="Enter Morrow's End, survive memory battles, choose whether to confront, listen, or forget, and decide the lake's fate."><span>11</span><strong>Ash Below the Lake</strong></a>
<a href="/play/the-rain-index.html" data-kind="Artifact" data-desc="Handle impossible paper rooms, type forgotten words, and let a rainy archive file you back."><span>12</span><strong>The Rain Index</strong></a>
<a href="/play/house.html" data-kind="Living archive" data-desc="Wander through a lived-in house where every room opens another part of the archive."><span>13</span><strong>The House of Pages</strong></a>
<a href="/play/ink.html" data-kind="Canvas" data-desc="Make a living ink pond with ripples, trails, and quiet motion."><span>05</span><strong>Ink Pond</strong></a>
<a href="/play/terminal.html" data-kind="Story" data-desc="Explore a tiny command-line adventure hidden in the archive."><span>06</span><strong>Archive Terminal</strong></a>
<a href="/play/study.html" data-kind="Timer" data-desc="Run a focus timer that grows a little desk scene as time passes."><span>07</span><strong>Study Lamp</strong></a>
<a href="/play/sigil.html" data-kind="Maker" data-desc="Generate a small personal sigil from initials, colors, and motto."><span>08</span><strong>Sigil Press</strong></a>
<a href="/play/rpg.html" data-kind="RPG world" data-desc="Walk through the website as Archive Town, discover eight landmarks, and travel through its living sections."><span>09</span><strong>The Archive World</strong></a>
<a href="/play/the-rain-index.html" data-kind="Artifact" data-desc="Handle impossible paper rooms, type forgotten words, and let a rainy archive file you back."><span>10</span><strong>The Rain Index</strong></a>
<a href="/play/house.html" data-kind="Living archive" data-desc="Wander through a lived-in house where every room opens another part of the archive."><span>11</span><strong>The House of Pages</strong></a>
</nav>
</main>
#+END_EXPORT

View File

@@ -1,19 +0,0 @@
#+TITLE: Supper Wheel
#+OPTIONS: num:nil title:nil toc:nil
#+NO_SIDENOTES: t
#+DATE: <2026-05-09 Sat>
#+BEGIN_EXPORT html
<main class="play-root play-page" data-play-page="recipe">
<a class="play-back" href="/play/play.html">Back to Play</a>
<header class="play-page-head">
<p class="play-kicker">05 / Spinner</p>
<h1>Supper Wheel</h1>
<p>Spin for a cheerful menu: base, main, side, and table note.</p>
</header>
<section class="play-tool recipe-tool">
<button class="recipe-wheel" type="button" data-recipe-spin aria-label="Spin supper wheel">Spin</button>
<ul class="recipe-result" data-recipe-result></ul>
</section>
</main>
#+END_EXPORT

139
play/rpg.org Executable file → Normal file
View File

@@ -1,61 +1,116 @@
#+TITLE: Ash Below the Lake
#+TITLE: The Archive World
#+OPTIONS: num:nil title:nil toc:nil
#+NO_SIDENOTES: t
#+DATE: <2026-05-09 Sat>
#+DATE: <2026-07-29 Wed>
#+BEGIN_EXPORT html
<main class="play-root play-page rpg-root ash-rpg-root" data-play-page="rpg">
<main class="play-root play-page archive-world" data-play-page="archive-world">
<a class="play-back" href="/play/play.html">Back to Play</a>
<header class="play-page-head">
<p class="play-kicker">11 / Role Playing Game</p>
<h1>Ash Below the Lake</h1>
<p>A 2D story RPG set beside a black lake that remembers names, grief, and everyone the world tries to forget.</p>
<header class="play-page-head archive-world__heading">
<p class="play-kicker">09 / Role Playing World</p>
<h1>The Archive World</h1>
<p>Walk through the website as a living town. Every lit doorway opens another part of the archive.</p>
</header>
<section class="play-tool rpg-shell ash-rpg-shell" aria-label="Ash Below the Lake game">
<div class="rpg-stage">
<canvas class="rpg-canvas ash-rpg-canvas" width="960" height="540" data-rpg-canvas></canvas>
<div class="rpg-dialogue" data-rpg-dialogue aria-live="polite">
<strong data-rpg-speaker>The Letter</strong>
<p data-rpg-line>Don't let the lake remember your name.</p>
<section class="archive-world__shell" aria-labelledby="archive-world-title">
<div class="archive-world__toolbar">
<div>
<span>Traveler</span>
<strong data-world-player>New arrival</strong>
</div>
<div>
<span>Landmarks</span>
<strong data-world-discovery-count>0 / 8</strong>
</div>
<p class="archive-world__badge" data-world-badge hidden>Archive Cartographer</p>
<button type="button" data-world-journal-open>Discovery journal</button>
<button type="button" data-world-sound>Sound: muted</button>
<button type="button" data-world-reset>Reset traveler</button>
</div>
<aside class="rpg-panel" aria-label="Game controls and progress">
<div class="rpg-actions">
<button type="button" data-rpg-start>Start</button>
<button type="button" data-rpg-save>Save</button>
<button type="button" data-rpg-load>Load</button>
<button type="button" data-rpg-reset>New Game</button>
<button type="button" data-rpg-sound>Sound</button>
<h2 id="archive-world-title" class="visually-hidden">Archive Town game</h2>
<div
id="archive-world-game"
class="archive-world__game"
tabindex="0"
role="application"
aria-label="The Archive World. Move with arrow keys or WASD and interact with E, Space, or Enter."
>
<p class="archive-world__loading">Preparing Archive Town…</p>
</div>
<div class="rpg-mobile-pad" aria-label="Movement controls">
<button type="button" data-rpg-move="up">Up</button>
<button type="button" data-rpg-move="left">Left</button>
<button type="button" data-rpg-act>Act</button>
<button type="button" data-rpg-move="right">Right</button>
<button type="button" data-rpg-move="down">Down</button>
<div class="archive-world__controls" aria-label="Touch game controls">
<div class="archive-world__dpad">
<button type="button" data-world-move="up" aria-label="Move up"></button>
<button type="button" data-world-move="left" aria-label="Move left"></button>
<button type="button" data-world-move="down" aria-label="Move down"></button>
<button type="button" data-world-move="right" aria-label="Move right"></button>
</div>
<button class="archive-world__interact" type="button" data-world-interact>Explore</button>
</div>
<dl class="rpg-stats">
<div><dt>District</dt><dd data-rpg-room>Harbor Row</dd></div>
<div><dt>Route</dt><dd data-rpg-route>Undecided</dd></div>
<div><dt>Resolve</dt><dd data-rpg-hearts>20</dd></div>
</dl>
<section class="rpg-list">
<h2>Memory Journal</h2>
<ul data-rpg-quests></ul>
<p class="archive-world__status" data-world-status aria-live="polite">
Choose your traveler to enter Archive Town.
</p>
<p class="archive-world__instructions">
Move with arrow keys or WASD. Press E, Space, Enter, or Explore near a glowing landmark.
Sound remains muted until you enable it.
</p>
</section>
<section class="rpg-list">
<h2>Inventory</h2>
<ul data-rpg-inventory></ul>
</section>
<dialog class="archive-world__dialog archive-world__setup" data-world-setup>
<form data-world-setup-form>
<p class="play-kicker">New traveler</p>
<h2>Who has arrived?</h2>
<label>
Traveler name
<input name="name" type="text" minlength="1" maxlength="20" autocomplete="nickname" required />
</label>
<fieldset>
<legend>Coat palette</legend>
<label><input type="radio" name="palette" value="brass" checked /> <span class="palette-swatch palette-swatch--brass"></span> Brass</label>
<label><input type="radio" name="palette" value="moss" /> <span class="palette-swatch palette-swatch--moss"></span> Moss</label>
<label><input type="radio" name="palette" value="berry" /> <span class="palette-swatch palette-swatch--berry"></span> Berry</label>
</fieldset>
<p class="archive-world__error" data-world-setup-error aria-live="polite"></p>
<button type="submit">Enter the world</button>
</form>
</dialog>
<p class="rpg-status" data-rpg-status>Move with arrows or WASD. Act with Space or Enter. Sound starts after your first input.</p>
</aside>
</section>
<dialog class="archive-world__dialog" data-world-preview>
<p class="play-kicker" data-world-preview-eyebrow>Landmark</p>
<h2 data-world-preview-title>Archive Town</h2>
<p data-world-preview-description></p>
<nav class="archive-world__preview-links" data-world-preview-links aria-label="Landmark destinations"></nav>
<button type="button" data-world-preview-close>Return to town</button>
</dialog>
<dialog class="archive-world__dialog" data-world-journal>
<p class="play-kicker">Discovery journal</p>
<h2>Archive Town landmarks</h2>
<ul class="archive-world__journal" data-world-journal-list></ul>
<button type="button" data-world-journal-close>Close journal</button>
</dialog>
<details class="archive-world__directory">
<summary>Plain directory of Archive Town</summary>
<div class="archive-world__directory-grid">
<section><h2>Town Gate</h2><a href="/">Home</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/home/contact.html">Contact</a></section>
<section><h2>Grand Library</h2><a href="/posts/posts-list.html">Posts</a> · <a href="/home/categories.html">Categories</a> · <a href="/posts/posts-intro.html">Introduction</a></section>
<section><h2>Guild Study</h2><a href="/posts/career/career-list.html">Career</a> · <a href="/home/status.html">Competency status</a> · <a href="/posts/career/probation-objectives.html">Objectives</a></section>
<section><h2>Kitchen Inn</h2><a href="/blogs/blogs-list.html">Blogs</a> · <a href="/tags/review.html">Weekly reviews</a> · <a href="/blogs/blogs-intro.html">Introduction</a></section>
<section><h2>Workshop</h2><a href="/home/services.html">Services</a> · <a href="/home/wird-tracker.html">Wird tracker</a> · <a href="/home/countdown.html">Countdowns</a> · <a href="/home/backlog.html">Backlog</a></section>
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/the-rain-index.html">Rain Index</a> · <a href="/play/house.html">House of Pages</a></section>
<section><h2>Lima Museum</h2><a href="/lima/index.html">Lima</a> · <a href="/blogs/2025/2025-list.html">Older writing</a> · <a href="/play/memory.html">Memory Cabinet</a></section>
<section><h2>Notes Garden</h2><a href="/home/notes.html">Notes wall</a> · <a href="/home/categories.html">Categories</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/sitemap.html">Sitemap</a></section>
</div>
</details>
<noscript><p class="archive-world__noscript">The game needs JavaScript, but every destination remains available in the plain directory above.</p></noscript>
</main>
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js" defer></script>
<script src="/assets/scripts/pages/archive-world-state.js" defer></script>
<script src="/assets/scripts/pages/archive-world.js" defer></script>
#+END_EXPORT

View File

@@ -1,22 +0,0 @@
#+TITLE: Timeline Tangle
#+OPTIONS: num:nil title:nil toc:nil
#+NO_SIDENOTES: t
#+DATE: <2026-05-09 Sat>
#+BEGIN_EXPORT html
<main class="play-root play-page" data-play-page="timeline">
<a class="play-back" href="/play/play.html">Back to Play</a>
<header class="play-page-head">
<p class="play-kicker">06 / Puzzle</p>
<h1>Timeline Tangle</h1>
<p>Drag the cards from earliest to latest.</p>
</header>
<section class="play-tool">
<div class="play-scorebar">
<span data-timeline-status>Arrange the cards</span>
<button type="button" data-timeline-shuffle>Shuffle</button>
</div>
<div class="timeline-list" data-timeline-list></div>
</section>
</main>
#+END_EXPORT

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">20-07-2026 01:00</span>@@
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">28-07-2026 01:01</span>@@
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@

View File

@@ -2,6 +2,7 @@
#+OPTIONS: toc:nil num:nil
* Recently Updated (top 26 files)
- [[file:play/rpg.org][The Archive World]] @@html:<span class="post-date">2026-07-29 00:00</span>@@
- [[file:play/house.org][The House of Pages]] @@html:<span class="post-date">2026-07-15 00:00</span>@@
- [[file:blogs/2026/07-july/12-07-week-review.org][[12-07-2026] - Weekly Review]] @@html:<span class="post-date">2026-07-12 12:00</span>@@
- [[file:home/wird-tracker.org][Wird Tracker]] @@html:<span class="post-date">2026-07-08 22:36</span>@@
@@ -27,4 +28,3 @@
- [[file:blogs/2026/05-may/poppadoms-num-13-05.org][Poppadoms num]] @@html:<span class="post-date">2026-05-13 23:29</span>@@
- [[file:blogs/2026/05-may/using-gifs-13-05.org][Sans spins for us!]] @@html:<span class="post-date">2026-05-13 13:43</span>@@
- [[file:blogs/2026/05-may/back-to-back-meetings-13-05.org][Back to back meetings]] @@html:<span class="post-date">2026-05-13 13:18</span>@@
- [[file:blogs/2026/05-may/memory-observatory-12-05.org][Memory Observatory]] @@html:<span class="post-date">2026-05-12 16:30</span>@@

View File

@@ -110,13 +110,13 @@ flowchart TD
n46 --> n52
n53["Tag: website"]
n46 --> n53
n54["Tag: life"]
n54["Tag: update"]
n46 --> n54
n55["Tag: update"]
n55["Tag: life"]
n46 --> n55
n56["Tag: insights"]
n56["Tag: education"]
n46 --> n56
n57["Tag: education"]
n57["Tag: insights"]
n46 --> n57
n58["Tag: reading"]
n46 --> n58
@@ -128,32 +128,28 @@ flowchart TD
root --> n61
n62["Sigil Press"]
n61 --> n62
n63["Ash Below the Lake"]
n63["Ink Pond"]
n61 --> n63
n64["Timeline Tangle"]
n64["Bookshelf Sort"]
n61 --> n64
n65["Ink Pond"]
n65["Constellation Desk"]
n61 --> n65
n66["Supper Wheel"]
n66["Memory Cabinet"]
n61 --> n66
n67["Bookshelf Sort"]
n67["Play"]
n61 --> n67
n68["Constellation Desk"]
n68["Archive Terminal"]
n61 --> n68
n69["Memory Cabinet"]
n69["Study Lamp"]
n61 --> n69
n70["Play"]
n70["Marginalia Machine"]
n61 --> n70
n71["Archive Terminal"]
n71["The Rain Index"]
n61 --> n71
n72["Study Lamp"]
n72["The House of Pages"]
n61 --> n72
n73["Marginalia Machine"]
n73["The Archive World"]
n61 --> n73
n74["The Rain Index"]
n61 --> n74
n75["The House of Pages"]
n61 --> n75
click n1 "index.html" "Home"
click n2 "recently-updated.html" "Recently Updated"
click n4 "blogs/blogs-intro.html" "Blogs Introduction"
@@ -200,27 +196,25 @@ flowchart TD
click n51 "tags/notes.html" "Tag: notes"
click n52 "tags/review.html" "Tag: review"
click n53 "tags/website.html" "Tag: website"
click n54 "tags/life.html" "Tag: life"
click n55 "tags/update.html" "Tag: update"
click n56 "tags/insights.html" "Tag: insights"
click n57 "tags/education.html" "Tag: education"
click n54 "tags/update.html" "Tag: update"
click n55 "tags/life.html" "Tag: life"
click n56 "tags/education.html" "Tag: education"
click n57 "tags/insights.html" "Tag: insights"
click n58 "tags/reading.html" "Tag: reading"
click n59 "tags/emacs.html" "Tag: emacs"
click n60 "tags/maths.html" "Tag: maths"
click n62 "play/sigil.html" "Sigil Press"
click n63 "play/rpg.html" "Ash Below the Lake"
click n64 "play/timeline.html" "Timeline Tangle"
click n65 "play/ink.html" "Ink Pond"
click n66 "play/recipe.html" "Supper Wheel"
click n67 "play/bookshelf.html" "Bookshelf Sort"
click n68 "play/constellation.html" "Constellation Desk"
click n69 "play/memory.html" "Memory Cabinet"
click n70 "play/play.html" "Play"
click n71 "play/terminal.html" "Archive Terminal"
click n72 "play/study.html" "Study Lamp"
click n73 "play/poem.html" "Marginalia Machine"
click n74 "play/the-rain-index.html" "The Rain Index"
click n75 "play/house.html" "The House of Pages"
click n63 "play/ink.html" "Ink Pond"
click n64 "play/bookshelf.html" "Bookshelf Sort"
click n65 "play/constellation.html" "Constellation Desk"
click n66 "play/memory.html" "Memory Cabinet"
click n67 "play/play.html" "Play"
click n68 "play/terminal.html" "Archive Terminal"
click n69 "play/study.html" "Study Lamp"
click n70 "play/poem.html" "Marginalia Machine"
click n71 "play/the-rain-index.html" "The Rain Index"
click n72 "play/house.html" "The House of Pages"
click n73 "play/rpg.html" "The Archive World"
#+end_src
* Pages
@@ -277,19 +271,16 @@ flowchart TD
- [[file:tags/notes.org][Tag: notes]]
- [[file:tags/review.org][Tag: review]]
- [[file:tags/website.org][Tag: website]]
- [[file:tags/life.org][Tag: life]]
- [[file:tags/update.org][Tag: update]]
- [[file:tags/insights.org][Tag: insights]]
- [[file:tags/life.org][Tag: life]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/insights.org][Tag: insights]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/maths.org][Tag: maths]]
- play
- [[file:play/sigil.org][Sigil Press]]
- [[file:play/rpg.org][Ash Below the Lake]]
- [[file:play/timeline.org][Timeline Tangle]]
- [[file:play/ink.org][Ink Pond]]
- [[file:play/recipe.org][Supper Wheel]]
- [[file:play/bookshelf.org][Bookshelf Sort]]
- [[file:play/constellation.org][Constellation Desk]]
- [[file:play/memory.org][Memory Cabinet]]
@@ -299,3 +290,4 @@ flowchart TD
- [[file:play/poem.org][Marginalia Machine]]
- [[file:play/the-rain-index.org][The Rain Index]]
- [[file:play/house.org][The House of Pages]]
- [[file:play/rpg.org][The Archive World]]

View File

@@ -0,0 +1,70 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const state = require("../assets/scripts/pages/archive-world-state.js");
test("fresh state applies defaults", () => {
assert.deepEqual(state.fresh(" Ada ", "moss"), {
version: 1,
name: "Ada",
palette: "moss",
discovered: [],
complete: false,
position: { x: 640, y: 830 },
soundEnabled: false
});
});
test("names are trimmed, collapsed, and limited to twenty characters", () => {
assert.equal(state.validateName(" Archive Guest "), "Archive Guest");
assert.equal(state.validateName(""), null);
assert.equal(state.validateName("x".repeat(21)), null);
});
test("only declared palettes are accepted", () => {
assert.equal(state.validatePalette("berry"), "berry");
assert.equal(state.validatePalette("ultraviolet"), null);
});
test("discoveries are deduplicated and completion requires all landmarks", () => {
let current = state.fresh("Ada", "brass");
current = state.discover(current, "gate");
current = state.discover(current, "gate");
assert.deepEqual(current.discovered, ["gate"]);
assert.equal(current.complete, false);
for (const id of state.LANDMARK_IDS) current = state.discover(current, id);
assert.equal(current.complete, true);
});
test("normalization filters unknown discoveries and clamps position", () => {
const candidate = {
version: 1,
name: "Zaine",
palette: "brass",
discovered: ["gate", "gate", "missing"],
complete: true,
position: { x: -20, y: 5000 },
soundEnabled: true
};
assert.deepEqual(state.normalize(candidate), {
version: 1,
name: "Zaine",
palette: "brass",
discovered: ["gate"],
complete: false,
position: { x: 36, y: 924 },
soundEnabled: true
});
});
test("parse rejects malformed, unsupported, and incomplete saves", () => {
assert.equal(state.parse("{"), null);
assert.equal(state.parse(JSON.stringify({ version: 2 })), null);
assert.equal(state.parse(JSON.stringify(state.fresh("", "brass"))), null);
});
test("a valid save round-trips and reset creates an empty state", () => {
const saved = state.discover(state.fresh("Mira", "berry"), "garden");
assert.deepEqual(state.parse(JSON.stringify(saved)), saved);
assert.deepEqual(state.fresh("Mira", "berry").discovered, []);
});