observer effect content

This commit is contained in:
Ellie 2026-06-15 21:22:41 -07:00
parent 625a847a81
commit e4ee25295c
23 changed files with 2982 additions and 0 deletions

View file

@ -0,0 +1,86 @@
{ config, pkgs, ... }:
let
# Tiny Rust WebSocket relay. cargoLock.lockFile pins deps from the committed
# Cargo.lock, so there's no vendor hash to chase on dependency bumps.
relay = pkgs.rustPlatform.buildRustPackage {
pname = "observer-relay";
version = "0.1.0";
src = ./observer-effect/relay;
cargoLock.lockFile = ./observer-effect/relay/Cargo.lock;
};
# The two static pages (player chronometer + Handler panel) and their assets.
site = pkgs.copyPathToStore ./observer-effect/site;
domain = "observer.ellie.town";
port = 8770;
in
{
# Shared control key. Decrypts to a file the relay reads at startup, so it
# never lands in /nix/store.
sops.secrets."observer/token" = {
sopsFile = ./secrets/observer_vps.yaml;
owner = "observer";
group = "observer";
mode = "0400";
};
users.users.observer = {
isSystemUser = true;
group = "observer";
};
users.groups.observer = { };
systemd.services.observer-relay = {
description = "Observer Effect doomsday-clock relay";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
OBSERVER_ADDR = "127.0.0.1:${toString port}";
OBSERVER_TOKEN_FILE = config.sops.secrets."observer/token".path;
};
serviceConfig = {
ExecStart = "${relay}/bin/observer-relay";
User = "observer";
Group = "observer";
Restart = "on-failure";
RestartSec = "5s";
# Hardening — it only needs a loopback socket and to read one secret.
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
SystemCallFilter = [ "@system-service" ];
};
};
services.nginx.virtualHosts.${domain} = {
enableACME = true;
forceSSL = true;
root = site;
locations."/" = {
index = "clock.html";
};
# Tidy URL for the Handler's panel.
locations."= /control".return = "302 /control.html";
# Relay socket: upgrade to WebSocket and hand off to the local service.
locations."/ws" = {
proxyPass = "http://127.0.0.1:${toString port}";
proxyWebsockets = true;
};
};
}

View file

@ -0,0 +1,66 @@
# Observer Effect — doomsday clock
A period-styled (1964) clock for running Delta Green: *Observer Effect* online.
- **Player screen**`observer.ellie.town/` (`clock.html`): an Olympian Holobeam
Array master chronometer. Nixie tubes counting toward 22:03:37, warning lamps
that flash on each pulse, a red wash and dissonant flutes/drums as communion
nears. Read-only; safe to share with the players.
- **Handler panel**`observer.ellie.town/control` (`control.html`): run/hold,
scrub the clock, jump to any scenario beat, set the iteration (which resets
reality to its wake point), and fire pulse / communion / text effects on the
players' screen.
## How it syncs
A tiny Rust WebSocket relay (`relay/`) holds the one authoritative clock. The
panel sends authenticated commands; every connected screen gets the new state
and free-runs the digits locally between actions. Late joiners get the current
state immediately. Served behind nginx with TLS; the relay only listens on
loopback.
## Deploy
1. DNS: point `observer.ellie.town` at the VPS (A/AAAA). ACME does the cert.
2. It's already wired into `flake.nix` (vps modules) as
`./services/observer-effect.nix`. Rebuild the VPS:
`nixos-rebuild switch --flake .#vps` (however you normally deploy).
## The control key
Commands are authenticated with a shared token in `services/secrets/observer_vps.yaml`
(sops). Enter it once in the panel's **CONTROL KEY** field (saved in that
browser). Rotate any time with `sops services/secrets/observer_vps.yaml` then
rebuild.
## Running a session
The clock starts paused at iteration I, 17:00:00 (the Agents' arrival). Drive it
by hand to match table pacing — **JUMP TO BEAT** for the scripted moments, or
**RATE ×N** + **RUN** to let it tick. Fire **PULSE** on the live shudders and
**COMMUNION** at 22:03:37; then hit **ITERATION II/III/IV** to reset reality
nearer the end as the loop tightens. **SHOW** broadcasts a line of text over the
players' screen (e.g. *"I see the throne of God."*).
## Sound effects
The player screen's pulse / communion / broadcast-text sounds are pre-rendered
mp3s in `site/sfx/`, synthesized from pure ffmpeg `lavfi` filtergraphs (no
samples). Regenerate with:
```sh
cd site/sfx && nix-shell -p ffmpeg --run ./generate.sh
```
If a file fails to load the screen falls back to an equivalent WebAudio synth.
The ambient dread drone is always synth (it's driven by the tension level).
## Local dev (no Nix)
```sh
cd relay
OBSERVER_TOKEN=test cargo run # relay on 127.0.0.1:8770
# serve ./site on :8080 and proxy /ws -> 127.0.0.1:8770, or just open
# site/clock.html and append ?, then point common.js' WS at ws://localhost:8770/ws
```
The pages connect to `wss?://<host>/ws`, so behind nginx everything is same-origin.

View file

@ -0,0 +1,67 @@
/* Local dev server: serves ./site and proxies /ws to the relay, so the pages
run same-origin exactly like they do behind nginx in production.
Usage:
OBSERVER_TOKEN=test cargo run # in relay/, terminal 1
node devserver.mjs # terminal 2 -> http://localhost:8080
Env: PORT (default 8080), RELAY (default 127.0.0.1:8770). */
import { createServer } from "node:http";
import { connect } from "node:net";
import { readFile } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
const PORT = Number(process.env.PORT || 8080);
const [RELAY_HOST, RELAY_PORT] = (process.env.RELAY || "127.0.0.1:8770").split(":");
const SITE = join(fileURLToPath(new URL(".", import.meta.url)), "site");
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mp3": "audio/mpeg",
".wav": "audio/wav",
};
const server = createServer(async (req, res) => {
let path = decodeURIComponent(req.url.split("?")[0]);
if (path === "/") path = "/clock.html";
if (path === "/control") path = "/control.html";
// keep it inside SITE
const file = join(SITE, normalize(path));
if (!file.startsWith(SITE)) {
res.writeHead(403).end("forbidden");
return;
}
try {
const body = await readFile(file);
res.writeHead(200, { "content-type": MIME[extname(file)] || "application/octet-stream" });
res.end(body);
} catch {
res.writeHead(404).end("not found");
}
});
// Proxy the WebSocket upgrade straight through to the relay.
server.on("upgrade", (req, socket, head) => {
const upstream = connect(Number(RELAY_PORT), RELAY_HOST, () => {
const lines = [`${req.method} ${req.url} HTTP/1.1`];
for (let i = 0; i < req.rawHeaders.length; i += 2) {
lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
}
upstream.write(lines.join("\r\n") + "\r\n\r\n");
if (head && head.length) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
});
const bail = () => socket.destroy();
upstream.on("error", bail);
socket.on("error", () => upstream.destroy());
});
server.listen(PORT, () => {
console.log(`observer-effect dev server: http://localhost:${PORT}/ (control: /control)`);
console.log(`proxying /ws -> ${RELAY_HOST}:${RELAY_PORT}`);
});

View file

@ -0,0 +1 @@
/target

772
services/observer-effect/relay/Cargo.lock generated Normal file
View file

@ -0,0 +1,772 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"base64",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "observer-relay"
version = "0.1.0"
dependencies = [
"axum",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -0,0 +1,15 @@
[package]
name = "observer-relay"
version = "0.1.0"
edition = "2021"
description = "Tiny WebSocket relay that syncs the Observer Effect doomsday clock from the Handler's control panel to every player's screen."
[dependencies]
axum = { version = "0.7", features = ["ws"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
lto = true
strip = true

View file

@ -0,0 +1,251 @@
//! Observer Effect — doomsday clock relay.
//!
//! A single authoritative clock lives here. The Handler's control panel sends
//! commands (authenticated with a shared token); every connected screen — the
//! Handler's and every player's — receives the resulting state and reacts.
//!
//! The clock is deterministic: we broadcast a snapshot (in-world seconds + a
//! server timestamp + running/rate) and let each browser free-run from it, so
//! the wire stays quiet between the Handler's deliberate actions.
use std::{
net::SocketAddr,
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use axum::{
extract::{
ws::{Message, WebSocket},
State, WebSocketUpgrade,
},
response::IntoResponse,
routing::get,
Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::broadcast;
/// In-world second-of-day at which Azathoth achieves communion (22:03:37).
const COMMUNION: f64 = 79_417.0;
/// Where each iteration's reality "resets" to, in second-of-day. Iteration 1
/// starts when the Agents reach the Array (17:00); later iterations wake nearer
/// the end, as the loop tightens toward the singularity.
fn reset_point(iteration: u64) -> f64 {
match iteration {
1 => 61_200.0, // 17:00:00 — arrival
2 => 67_576.0, // 18:46:16 — Takagawa awakens
3 => 75_470.0, // 20:57:50 — Klinger's rampage
_ => COMMUNION, // 4 — singularity
}
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
/// The persistent clock. `inworld`/`updated_ms` form an anchor: while running,
/// the live time is `inworld + (now - updated_ms) * rate`.
#[derive(Clone)]
struct Clock {
iteration: u64,
running: bool,
rate: f64,
inworld: f64,
updated_ms: u128,
}
impl Clock {
fn current(&self, now: u128) -> f64 {
if self.running {
let elapsed = (now.saturating_sub(self.updated_ms)) as f64 / 1000.0;
self.inworld + elapsed * self.rate
} else {
self.inworld
}
}
/// Fold elapsed time into the anchor so we can mutate from a clean baseline.
fn settle(&mut self, now: u128) {
self.inworld = self.current(now);
self.updated_ms = now;
}
fn snapshot(&self, now: u128) -> String {
json!({
"type": "state",
"iteration": self.iteration,
"running": self.running,
"rate": self.rate,
"inworld": self.current(now),
"communion": COMMUNION,
"serverMs": now,
})
.to_string()
}
}
struct App {
token: String,
clock: Mutex<Clock>,
tx: broadcast::Sender<String>,
}
#[derive(Deserialize)]
struct Command {
token: Option<String>,
cmd: String,
#[serde(default)]
rate: Option<f64>,
#[serde(default)]
inworld: Option<f64>,
#[serde(default)]
n: Option<u64>,
#[serde(default)]
text: Option<String>,
}
#[tokio::main]
async fn main() {
let token = std::env::var("OBSERVER_TOKEN")
.or_else(|_| {
std::env::var("OBSERVER_TOKEN_FILE")
.and_then(|p| std::fs::read_to_string(p).map_err(|_| std::env::VarError::NotPresent))
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| {
eprintln!("warning: no OBSERVER_TOKEN(_FILE) set; control is locked out");
String::new()
});
let addr: SocketAddr = std::env::var("OBSERVER_ADDR")
.unwrap_or_else(|_| "127.0.0.1:8770".to_string())
.parse()
.expect("OBSERVER_ADDR must be host:port");
let (tx, _rx) = broadcast::channel(64);
let app = Arc::new(App {
token,
clock: Mutex::new(Clock {
iteration: 1,
running: false,
rate: 1.0,
inworld: reset_point(1),
updated_ms: now_ms(),
}),
tx,
});
let router = Router::new()
.route("/ws", get(ws_handler))
.route("/healthz", get(|| async { "ok" }))
.with_state(app);
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");
eprintln!("observer-relay listening on {addr}");
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await
.expect("serve");
}
async fn ws_handler(ws: WebSocketUpgrade, State(app): State<Arc<App>>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, app))
}
async fn handle_socket(mut socket: WebSocket, app: Arc<App>) {
// Greet the newcomer with the world as it stands right now.
let hello = app.clock.lock().unwrap().snapshot(now_ms());
if socket.send(Message::Text(hello)).await.is_err() {
return;
}
let mut rx = app.tx.subscribe();
loop {
tokio::select! {
// Fan broadcasts out to this client.
msg = rx.recv() => match msg {
Ok(text) => {
if socket.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
},
// Read commands from this client (only the Handler's pass auth).
incoming = socket.recv() => match incoming {
Some(Ok(Message::Text(text))) => handle_command(&app, &text),
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => {} // ping/pong/binary — ignore
Some(Err(_)) => break,
},
}
}
}
fn handle_command(app: &App, text: &str) {
let cmd: Command = match serde_json::from_str(text) {
Ok(c) => c,
Err(_) => return,
};
// Constant-ish check; empty server token rejects everyone.
if app.token.is_empty() || cmd.token.as_deref() != Some(app.token.as_str()) {
return;
}
let now = now_ms();
// Transient effects don't touch the clock — just relay them.
match cmd.cmd.as_str() {
"pulse" => return broadcast_fx(app, json!({ "type": "fx", "fx": "pulse" })),
"communion" => return broadcast_fx(app, json!({ "type": "fx", "fx": "communion" })),
"msg" => {
return broadcast_fx(app, json!({ "type": "fx", "fx": "msg", "text": cmd.text }))
}
_ => {}
}
let snapshot = {
let mut clock = app.clock.lock().unwrap();
clock.settle(now);
match cmd.cmd.as_str() {
"play" => clock.running = true,
"pause" => clock.running = false,
"rate" => {
if let Some(r) = cmd.rate {
clock.rate = r.clamp(0.0, 3600.0);
}
}
"set" => {
if let Some(s) = cmd.inworld {
clock.inworld = s.clamp(0.0, 86_400.0);
}
}
"iteration" => {
if let Some(n) = cmd.n {
clock.iteration = n.clamp(1, 4);
clock.inworld = reset_point(clock.iteration);
clock.running = false;
}
}
_ => return,
}
clock.snapshot(now)
};
let _ = app.tx.send(snapshot);
}
fn broadcast_fx(app: &App, value: Value) {
let _ = app.tx.send(value.to_string());
}

View file

@ -0,0 +1,438 @@
/* Olympian Holobeam Array Master Chronometer.
Atomic-age console: brushed steel, nixie tubes, warning lamps. 1964. */
:root {
--bg: #0a0b0c;
--steel-hi: #4c5054;
--steel: #2c2f32;
--steel-lo: #16181a;
--engrave: #0c0d0e;
--label: #c9cdd1;
--nixie: #ff7a1e;
--nixie-core: #ffe2bc;
--nixie-glow: #ff6a00;
--amber: #ffb347;
--red: #ff3b30;
--green: #46e06a;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body.screen {
display: grid;
place-items: center;
min-height: 100%;
padding: clamp(0.5rem, 3vw, 3rem);
background:
radial-gradient(120% 120% at 50% 30%, #15171a 0%, var(--bg) 70%);
color: var(--label);
font-family: "Helvetica Neue", Arial, system-ui, sans-serif;
overflow: hidden;
}
/* --- the console body ----------------------------------------------------- */
.console {
width: min(92vw, 880px);
}
.bezel {
position: relative;
padding: clamp(1rem, 3.5vw, 2.4rem);
border-radius: 14px;
background:
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.035) 0 1px,
transparent 1px 3px
),
linear-gradient(180deg, var(--steel-hi) -20%, var(--steel) 45%, var(--steel-lo));
border: 2px solid #0d0e0f;
box-shadow:
inset 0 2px 0 rgba(255, 255, 255, 0.12),
inset 0 -3px 8px rgba(0, 0, 0, 0.6),
0 22px 60px rgba(0, 0, 0, 0.7);
}
/* corner screws */
.bezel::before {
content: "";
position: absolute;
inset: 10px;
border-radius: 8px;
pointer-events: none;
background:
radial-gradient(circle at 0 0, #555 1.5px, #1a1a1a 3px, transparent 4px) 6px 6px / 100% 100% no-repeat,
radial-gradient(circle at 100% 0, #555 1.5px, #1a1a1a 3px, transparent 4px) -6px 6px / 100% 100% no-repeat,
radial-gradient(circle at 0 100%, #555 1.5px, #1a1a1a 3px, transparent 4px) 6px -6px / 100% 100% no-repeat,
radial-gradient(circle at 100% 100%, #555 1.5px, #1a1a1a 3px, transparent 4px) -6px -6px / 100% 100% no-repeat;
}
/* --- engraved plates ------------------------------------------------------ */
.plate {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 700;
letter-spacing: 0.18em;
font-size: clamp(0.6rem, 1.6vw, 0.9rem);
color: var(--label);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8), 0 -1px 0 rgba(255, 255, 255, 0.06);
}
.stencil {
font-weight: 800;
}
.subplate {
margin: 0.35rem 0 1.1rem;
text-align: center;
letter-spacing: 0.3em;
font-size: clamp(0.5rem, 1.3vw, 0.72rem);
color: #8b9094;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
}
.plate--bottom {
margin-top: 1.2rem;
justify-content: center;
letter-spacing: 0.22em;
font-size: clamp(0.45rem, 1.2vw, 0.66rem);
color: #7d8286;
}
.rec {
display: inline-flex;
align-items: center;
gap: 0.4em;
color: #9aa0a4;
}
/* --- nixie readout -------------------------------------------------------- */
.readout {
display: flex;
align-items: stretch;
justify-content: center;
gap: clamp(0.15rem, 0.8vw, 0.5rem);
padding: clamp(0.6rem, 2vw, 1.2rem) clamp(0.4rem, 2vw, 1rem);
border-radius: 10px;
background:
radial-gradient(120% 160% at 50% 0%, #141312 0%, #070605 100%);
border: 1px solid #000;
box-shadow: inset 0 6px 18px rgba(0, 0, 0, 0.9), inset 0 -1px 0 rgba(255, 255, 255, 0.04);
}
.tube {
position: relative;
display: grid;
place-items: center;
width: clamp(2.6rem, 9vw, 5.2rem);
padding: clamp(0.3rem, 1.5vw, 0.9rem) 0;
border-radius: 40% 40% 38% 38% / 14% 14% 12% 12%;
background:
linear-gradient(180deg, rgba(80, 70, 55, 0.16), rgba(20, 16, 10, 0.05) 30%, rgba(0, 0, 0, 0.25));
box-shadow:
inset 0 2px 6px rgba(255, 200, 140, 0.06),
inset 0 -8px 14px rgba(0, 0, 0, 0.6);
}
/* curvature vignette behind the digit */
.tube::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120% 130% at 50% 45%, transparent 52%, rgba(0, 0, 0, 0.45) 100%);
}
/* curved-glass glare in front (offset to a corner so it doesn't wash the digit) */
.tube::after {
content: "";
position: absolute;
inset: 0;
z-index: 3;
border-radius: inherit;
pointer-events: none;
background:
radial-gradient(55% 32% at 32% 15%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.06) 45%, transparent 70%),
radial-gradient(38% 22% at 72% 82%, rgba(255, 255, 255, 0.06), transparent 60%);
}
.digit {
position: relative;
z-index: 2;
font-size: clamp(2.4rem, 9vw, 5.4rem);
font-weight: 400;
line-height: 1;
transform: scaleY(1.18);
color: var(--nixie-core);
text-shadow:
0 0 4px var(--nixie),
0 0 14px var(--nixie-glow),
0 0 34px var(--nixie-glow),
0 0 60px rgba(255, 90, 0, 0.55);
/* per-tube flicker via vars, so the communion rupture can fully override it */
animation: tube-flicker var(--flick-dur, 6s) var(--flick-delay, 0s) infinite steps(40);
}
/* afterglow: the outgoing numeral lingers and fades (neon de-ionization) */
.ghost {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
opacity: 0;
font-size: clamp(2.4rem, 9vw, 5.4rem);
font-weight: 400;
line-height: 1;
transform: scaleY(1.18);
color: var(--nixie-core);
text-shadow: 0 0 4px var(--nixie), 0 0 16px var(--nixie-glow);
pointer-events: none;
}
@keyframes tube-flicker {
0%, 97%, 100% { opacity: 1; }
98% { opacity: 0.86; }
99% { opacity: 0.95; }
}
.colon {
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(0.5rem, 2vw, 1.2rem);
padding: 0 clamp(0.05rem, 0.5vw, 0.3rem);
}
.colon i {
width: clamp(0.3rem, 1vw, 0.6rem);
height: clamp(0.3rem, 1vw, 0.6rem);
border-radius: 50%;
background: var(--nixie-core);
box-shadow: 0 0 6px var(--nixie), 0 0 16px var(--nixie-glow);
}
.srtime {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
/* --- lamps ---------------------------------------------------------------- */
.lamps {
margin-top: 1.3rem;
display: flex;
align-items: center;
justify-content: center;
gap: clamp(1rem, 5vw, 3rem);
}
.lampcell {
display: flex;
align-items: center;
gap: 0.5em;
letter-spacing: 0.16em;
font-size: clamp(0.55rem, 1.4vw, 0.78rem);
color: #8b9094;
}
.lamp {
width: 0.85em;
height: 0.85em;
border-radius: 50%;
background: #2a2a2a;
box-shadow: inset 0 0 3px #000, 0 1px 0 rgba(255, 255, 255, 0.08);
transition: background 0.08s, box-shadow 0.08s;
}
.lamp--rec {
width: 0.7em;
height: 0.7em;
}
.lamp--on {
background: var(--green);
box-shadow: 0 0 8px var(--green), 0 0 16px rgba(70, 224, 106, 0.6);
}
/* REC indicator: steady recording lamp (flavor). */
.lamp--rec {
background: var(--red);
box-shadow: 0 0 5px var(--red);
}
/* --- pulse shudder --------------------------------------------------------- */
body.pulsing #cellPulse .lamp {
background: var(--amber);
box-shadow: 0 0 10px var(--amber), 0 0 24px rgba(255, 179, 71, 0.8);
}
body.pulsing .bezel {
animation: shudder 0.45s ease-out;
}
body.pulsing .digit {
text-shadow:
0 0 6px var(--nixie-core),
0 0 20px var(--nixie),
0 0 50px var(--nixie-glow),
0 0 90px rgba(255, 120, 0, 0.8);
}
@keyframes shudder {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-3px, 1px); }
40% { transform: translate(3px, -2px); }
60% { transform: translate(-2px, -1px); }
80% { transform: translate(2px, 1px); }
}
/* --- dread wash (driven by --tension, 0..1) -------------------------------- */
.screen::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: radial-gradient(120% 120% at 50% 50%, transparent 38%, rgba(120, 0, 0, 0.6) 100%);
opacity: calc(var(--tension, 0) * 0.95);
z-index: 5;
}
/* once the menace is high, the wash starts to crawl */
body.agitated::before {
animation: wash-flicker 0.4s infinite;
}
@keyframes wash-flicker {
0%, 100% { opacity: calc(var(--tension, 0) * 0.95); }
50% { opacity: calc(var(--tension, 0) * 0.7); }
}
/* --- communion: Azathoth breaks through ------------------------------------ */
/* A ~6s sequence over three layers: the void (roiling black), the flash (the
blue-white detonation), plus a console quake and digit rupture. */
.void {
position: fixed;
inset: 0;
z-index: 21;
pointer-events: none;
opacity: 0;
background: #000;
}
.flash {
position: fixed;
inset: 0;
z-index: 22;
pointer-events: none;
opacity: 0;
mix-blend-mode: screen;
background: radial-gradient(circle at 50% 45%, #fff 0%, #dce9ff 12%, #6fb0ff 34%, #163a8f 68%, transparent 100%);
}
body.communion .console { animation: quake 2.3s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
body.communion .void { animation: thevoid 6s ease-out forwards; }
body.communion .flash { animation: detonate 6s ease-out forwards; }
body.communion .digit { animation: digit-rupture 6s steps(60) forwards; }
body.communion .lamp { animation: panic-lamp 0.18s steps(1) 12; }
@keyframes quake {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
5% { transform: translate(-14px, 8px) rotate(-0.6deg); }
10% { transform: translate(13px, -10px) rotate(0.5deg); }
15% { transform: translate(-16px, -6px) rotate(0.4deg); }
21% { transform: translate(15px, 11px) rotate(-0.5deg); }
27% { transform: translate(-19px, 7px) rotate(0.7deg); }
33% { transform: translate(18px, -9px) rotate(-0.6deg); }
41% { transform: translate(-11px, 7px) rotate(0.3deg); }
52% { transform: translate(8px, -5px) rotate(-0.2deg); }
64% { transform: translate(-5px, 3px); }
78% { transform: translate(3px, -2px); }
}
@keyframes detonate {
0% { opacity: 0; }
18% { opacity: 0.12; }
22% { opacity: 0; }
26% { opacity: 1; }
30% { opacity: 0.7; }
34% { opacity: 1; }
44% { opacity: 0.2; }
60% { opacity: 0.06; }
78%, 100% { opacity: 0; }
}
@keyframes thevoid {
0%, 30% { opacity: 0; }
45% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes digit-rupture {
0% { color: var(--nixie-core); text-shadow: 0 0 4px var(--nixie), 0 0 14px var(--nixie-glow); }
10% { color: #fff; text-shadow: -3px 0 #ff003c, 3px 0 #00e5ff, 0 0 18px #fff; }
20% { text-shadow: 4px 0 #ff003c, -4px 0 #00e5ff, 0 0 22px #fff; }
26% { color: #eaf3ff; text-shadow: -6px 0 #ff003c, 6px 0 #00e5ff, 0 0 30px #fff; }
34% { opacity: 0.2; }
40% { opacity: 1; color: #b9c6ff; text-shadow: -5px 0 #ff003c, 5px 0 #00e5ff; }
55% { opacity: 0.5; text-shadow: -3px 0 #6a00ff, 3px 0 #00d0ff; }
70% { opacity: 0.12; }
82% { opacity: 0.04; }
100% { opacity: 1; color: var(--nixie-core); text-shadow: 0 0 4px var(--nixie), 0 0 14px var(--nixie-glow); }
}
@keyframes panic-lamp {
0% { background: var(--red); box-shadow: 0 0 10px var(--red); }
50% { background: #1a0000; box-shadow: inset 0 0 3px #000; }
}
/* --- message overlay ------------------------------------------------------- */
.message {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 2rem;
text-align: center;
font-weight: 800;
letter-spacing: 0.22em;
font-size: clamp(1.6rem, 7vw, 4.5rem);
color: #fff;
text-shadow: 0 0 12px #6fb0ff, 0 0 40px #2a6bff;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
visibility: hidden;
transition: opacity 0.4s, visibility 0.4s;
}
.message.show {
opacity: 1;
visibility: visible;
}
/* --- offline -------------------------------------------------------------- */
body.offline .bezel {
filter: saturate(0.5) brightness(0.85);
}
/* --- sound button --------------------------------------------------------- */
.sound {
position: fixed;
right: 0.8rem;
bottom: 0.8rem;
z-index: 40;
padding: 0.4rem 0.8rem;
font: inherit;
font-size: 0.7rem;
letter-spacing: 0.14em;
color: #9aa0a4;
background: #1b1d1f;
border: 1px solid #000;
border-radius: 6px;
cursor: pointer;
}
.sound.on {
color: var(--green);
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.digit,
body.agitated::before,
body.communion .console,
body.communion .digit,
body.communion .lamp {
animation: none !important;
}
}

View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Warner Center Radio Array — Master Chronometer</title>
<link rel="stylesheet" href="clock.css" />
</head>
<body class="screen">
<main class="console" id="console">
<div class="bezel">
<header class="plate plate--top">
<span class="stencil">WARNER CENTER RADIO ARRAY</span>
<span class="rec"><i class="lamp lamp--rec"></i> REC</span>
</header>
<div class="subplate">MASTER CHRONOMETER</div>
<div class="readout" id="tubes" aria-hidden="true"></div>
<p class="srtime" aria-live="off"><span id="srtime">17:00:00</span> local time</p>
<div class="lamps">
<div class="lampcell">
<i class="lamp lamp--green" id="lampLink"></i><span>ARRAY</span>
</div>
<div class="lampcell" id="cellPulse">
<i class="lamp lamp--amber"></i><span>SIGNAL</span>
</div>
</div>
<footer class="plate plate--bottom">
PROPERTY OF THE U.S. DEPARTMENT OF ENERGY · MCMLXIV · AUTHORIZED PERSONNEL ONLY
</footer>
</div>
</main>
<div class="void" id="void" aria-hidden="true"></div>
<div class="flash" id="flash" aria-hidden="true"></div>
<div class="message" id="message" aria-hidden="true"></div>
<button class="sound" id="soundBtn" type="button">◌ SOUND OFF</button>
<script src="common.js"></script>
<script src="sound.js"></script>
<script src="clock.js"></script>
</body>
</html>

View file

@ -0,0 +1,215 @@
/* Player screen. Read-only and deliberately ignorant of the scenario's
structure no countdown, no iteration counter, nothing the in-world DOE
inspectors couldn't see. It shows the Array's current time and reacts to
whatever the Handler chooses to push: signal pulses, the catastrophe, text.
Dread builds from the pulses the Handler fires, not from any visible clock. */
(function () {
"use strict";
const $ = (id) => document.getElementById(id);
const body = document.body;
const reduceMotion =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Build six nixie tubes as HH MM SS with two colons; keep digit + ghost refs.
const tubeWrap = $("tubes");
const digits = [];
const ghosts = [];
"HH:MM:SS".split("").forEach((ch) => {
if (ch === ":") {
const c = document.createElement("span");
c.className = "colon";
c.innerHTML = "<i></i><i></i>";
tubeWrap.appendChild(c);
} else {
const tube = document.createElement("span");
tube.className = "tube";
// afterglow layer: holds the outgoing numeral as it de-ionizes
const g = document.createElement("span");
g.className = "ghost";
const d = document.createElement("span");
d.className = "digit";
d.textContent = "0";
// each tube flickers on its own rhythm (via CSS vars, so the communion
// rupture animation can still fully override the flicker)
d.style.setProperty("--flick-dur", (5 + Math.random() * 4).toFixed(2) + "s");
d.style.setProperty("--flick-delay", (-Math.random() * 6).toFixed(2) + "s");
tube.appendChild(g);
tube.appendChild(d);
tubeWrap.appendChild(tube);
digits.push(d);
ghosts.push(g);
}
});
function renderDigits(str) {
const order = [0, 1, 3, 4, 6, 7]; // skip the colon positions
const glitching = body.classList.contains("communion");
order.forEach((pos, i) => {
const ch = str[pos];
if (digits[i].textContent !== ch) {
// leave a fading ghost of the outgoing numeral (neon persistence)
if (!glitching && !reduceMotion) {
ghosts[i].textContent = digits[i].textContent;
ghosts[i].animate([{ opacity: 0.7 }, { opacity: 0 }], {
duration: 160,
easing: "ease-out",
});
}
digits[i].textContent = ch;
}
});
}
// cathode-poisoning exercise: a rapid slot-machine cycle through 0-9,
// staggered per tube, run only while the clock sits idle
function renderSpin(now) {
for (let i = 0; i < digits.length; i++) {
digits[i].textContent = ((Math.floor(now / 45) + i * 2) % 10).toString();
}
}
// --- dread / tension -------------------------------------------------------
// 0..1, nudged up by each pulse and decaying slowly. Drives the red wash and
// the audio drone, so the Handler paces the menace by firing pulses.
let tension = 0;
function bumpTension(amount) {
tension = Math.min(1, tension + amount);
}
// --- effects ---------------------------------------------------------------
let pulseTimer = null;
function flashPulse() {
body.classList.add("pulsing");
clearTimeout(pulseTimer);
pulseTimer = setTimeout(() => body.classList.remove("pulsing"), 750);
bumpTension(0.2);
if (window.OESound) OESound.pulse();
}
let communionTimer = null;
let scrambleUntil = 0;
function communionSeq() {
body.classList.remove("communion");
void body.offsetWidth; // restart the whole sequence
body.classList.add("communion");
if (window.OESound && OESound.cancelTicks) OESound.cancelTicks();
tension = 1;
scrambleUntil = performance.now() + 2300; // digits possessed through the blast
clearTimeout(communionTimer);
communionTimer = setTimeout(() => body.classList.remove("communion"), 6200);
if (window.OESound) OESound.communion();
}
function renderScramble() {
for (const d of digits) d.textContent = ((Math.random() * 10) | 0).toString();
}
const msgEl = $("message");
function showMessage(text) {
msgEl.textContent = text;
msgEl.classList.add("show");
}
function hideMessage() {
msgEl.classList.remove("show");
}
// --- model + connection ----------------------------------------------------
const model = OE.ClockModel();
let nextTickTime = null; // next scheduled relay click, in AudioContext time
const conn = OE.connect(OE.defaultWsUrl(), {
onstatus(s) {
$("lampLink").classList.toggle("lamp--on", s === "open");
body.classList.toggle("offline", s !== "open");
},
onstate(state) {
model.apply(state);
// drop queued clicks and realign — so pausing stops ticks promptly and a
// jump/rate change re-aligns instead of replaying a backlog
if (window.OESound && OESound.cancelTicks) OESound.cancelTicks();
nextTickTime = null;
},
onfx(fx) {
if (fx.fx === "pulse") flashPulse();
else if (fx.fx === "communion") communionSeq();
else if (fx.fx === "msg") {
if (fx.text) {
showMessage(fx.text);
if (window.OESound && OESound.enabled()) OESound.message();
} else hideMessage();
}
},
});
window.addEventListener("beforeunload", () => conn.close());
// --- tick scheduler --------------------------------------------------------
// Schedule relay clicks ahead of time on the AudioContext clock, so they keep
// ticking precisely even when the tab is backgrounded (requestAnimationFrame
// is paused there, but Web Audio scheduling is not). Each click is aligned to
// the in-world second boundary — i.e. the digit changeover.
const TICK_LOOKAHEAD = 2.5; // seconds of clicks kept queued
function scheduleTicks() {
if (!window.OESound || !OESound.enabled()) return (nextTickTime = null);
const m = model.now();
if (!m.running || body.classList.contains("communion")) return (nextTickTime = null);
const aNow = OESound.now();
const rate = m.rate || 1;
if (nextTickTime === null) {
const nextSec = Math.floor(m.inworld) + 1;
nextTickTime = aNow + (nextSec - m.inworld) / rate;
}
const stepGap = Math.max(1 / rate, 0.07); // never buzz at high rates
while (nextTickTime < aNow + TICK_LOOKAHEAD) {
if (nextTickTime > aNow) OESound.step(nextTickTime);
nextTickTime += stepGap;
}
}
setInterval(scheduleTicks, 250);
// --- render loop -----------------------------------------------------------
let last = performance.now();
let spinUntil = 0;
const SPIN_IDLE = 90000; // exercise the tubes after this long sitting idle
let nextSpinAt = performance.now() + SPIN_IDLE;
function frame(now) {
const dt = (now - last) / 1000;
last = now;
const s = model.now();
// cathode-poisoning spin, but only while the clock sits idle (paused)
if (s.running || body.classList.contains("communion") || reduceMotion) {
nextSpinAt = now + SPIN_IDLE;
} else if (now >= nextSpinAt && now >= spinUntil) {
spinUntil = now + 1600;
nextSpinAt = now + SPIN_IDLE;
}
if (now < scrambleUntil) renderScramble();
else if (now < spinUntil) renderSpin(now);
else renderDigits(OE.clock(s.inworld));
$("srtime").textContent = OE.clock(s.inworld);
// (relay ticks are scheduled on the audio clock above, not here)
// decay tension and reflect it on the console — quick enough that the
// drone and red wash settle within several seconds, not minutes
tension = Math.max(0, tension - dt * 0.12);
body.style.setProperty("--tension", tension.toFixed(3));
body.classList.toggle("agitated", tension > 0.6);
if (window.OESound && OESound.enabled()) OESound.setDread(tension);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// --- sound toggle ----------------------------------------------------------
const soundBtn = $("soundBtn");
soundBtn.addEventListener("click", () => {
if (OESound.enable()) {
soundBtn.textContent = "◉ SOUND ON";
soundBtn.classList.add("on");
soundBtn.disabled = true;
}
});
})();

View file

@ -0,0 +1,134 @@
/* Observer Effect shared clock logic for the player screen and the Handler's
panel. Classic script (no modules) so the pages also open straight from disk
for a quick look. Everything hangs off the global `OE`. */
(function () {
"use strict";
// In-world second-of-day for the moment Azathoth achieves communion.
const COMMUNION = 79417; // 22:03:37
// The scenario's beats, in second-of-day. `pulse: true` marks the live
// on-site shudders the player screen reacts to; the rest are scene markers
// and useful jump targets for the Handler.
const EVENTS = [
{ t: 36000, label: "Array activated", time: "10:00:00" },
{ t: 55735, label: "Power surge", time: "15:28:55" },
{ t: 59682, label: "Phantom signal", time: "16:34:42" },
{ t: 61200, label: "Agents arrive", time: "17:00:00" },
{ t: 63629, label: "Pulse", time: "17:40:29", pulse: true },
{ t: 67576, label: "Takagawa awakens", time: "18:46:16", pulse: true },
{ t: 71523, label: "Klinger appears", time: "19:52:03", pulse: true },
{ t: 75470, label: "Klinger's rampage", time: "20:57:50", pulse: true },
{ t: COMMUNION, label: "COMMUNION", time: "22:03:37", communion: true },
];
const RESET_POINTS = { 1: 61200, 2: 67576, 3: 75470, 4: COMMUNION };
const ROMAN = { 1: "I", 2: "II", 3: "III", 4: "IV" };
function pad(n) {
return String(Math.floor(n)).padStart(2, "0");
}
// second-of-day (may exceed a day or go negative) -> "HH:MM:SS", clamped 0..24h.
function clock(sec) {
let s = Math.max(0, Math.min(86399, Math.floor(sec)));
return pad(s / 3600) + ":" + pad((s % 3600) / 60) + ":" + pad(s % 60);
}
// A signed duration in seconds -> "H:MM:SS".
function duration(sec) {
const neg = sec < 0;
let s = Math.abs(Math.floor(sec));
const h = Math.floor(s / 3600);
return (neg ? "-" : "") + h + ":" + pad((s % 3600) / 60) + ":" + pad(s % 60);
}
/* Holds the latest authoritative snapshot and free-runs from it locally, so
the digits keep ticking between the Handler's actions. */
function ClockModel() {
let base = RESET_POINTS[1];
let baseAt = performance.now();
let running = false;
let rate = 1;
let iteration = 1;
return {
apply(state) {
base = state.inworld;
baseAt = performance.now();
running = state.running;
rate = state.rate;
iteration = state.iteration;
},
now() {
const live = running
? base + ((performance.now() - baseAt) / 1000) * rate
: base;
return {
inworld: live,
remaining: COMMUNION - live,
running,
rate,
iteration,
};
},
};
}
/* Auto-reconnecting WebSocket. `onstate`/`onfx` get parsed messages;
`onstatus` gets "open"/"closed" for the link lamp. */
function connect(url, handlers) {
let ws;
let closed = false;
function open() {
ws = new WebSocket(url);
ws.onopen = () => handlers.onstatus && handlers.onstatus("open");
ws.onclose = () => {
handlers.onstatus && handlers.onstatus("closed");
if (!closed) setTimeout(open, 1000);
};
ws.onerror = () => ws.close();
ws.onmessage = (e) => {
let m;
try {
m = JSON.parse(e.data);
} catch (_) {
return;
}
if (m.type === "state") handlers.onstate && handlers.onstate(m);
else if (m.type === "fx") handlers.onfx && handlers.onfx(m);
};
}
open();
return {
send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
},
close() {
closed = true;
if (ws) ws.close();
},
};
}
// Default WS endpoint: same host, /ws, matching page's TLS.
function defaultWsUrl() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return proto + "//" + location.host + "/ws";
}
window.OE = {
COMMUNION,
EVENTS,
RESET_POINTS,
ROMAN,
clock,
duration,
pad,
ClockModel,
connect,
defaultWsUrl,
};
})();

View file

@ -0,0 +1,260 @@
/* Array Control the Handler's cockpit. Same steel as the chronometer,
but laid out as a dense bank of switches and push-buttons. */
:root {
--bg: #0a0b0c;
--steel-hi: #4c5054;
--steel: #2c2f32;
--steel-lo: #16181a;
--label: #c9cdd1;
--amber: #ffb347;
--red: #ff3b30;
--green: #46e06a;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body.panel {
min-height: 100vh;
padding: clamp(0.6rem, 2.5vw, 2rem);
background: radial-gradient(120% 120% at 50% 0%, #15171a, var(--bg) 70%);
color: var(--label);
font-family: "Helvetica Neue", Arial, system-ui, sans-serif;
-webkit-text-size-adjust: 100%;
}
.rig {
width: min(96vw, 760px);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
/* engraved header */
.plate {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 800;
letter-spacing: 0.16em;
font-size: clamp(0.7rem, 2vw, 0.95rem);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
}
.link {
display: inline-flex;
align-items: center;
gap: 0.4em;
color: #9aa0a4;
font-size: 0.78rem;
}
.lamp {
width: 0.8em;
height: 0.8em;
border-radius: 50%;
background: #2a2a2a;
box-shadow: inset 0 0 3px #000;
transition: background 0.1s;
}
.lamp--on {
background: var(--green);
box-shadow: 0 0 8px var(--green);
}
/* --- mirror --------------------------------------------------------------- */
.mirror {
padding: clamp(0.7rem, 2.5vw, 1.2rem);
border-radius: 12px;
background: radial-gradient(120% 160% at 50% 0%, #141312, #070605);
border: 1px solid #000;
box-shadow: inset 0 5px 16px rgba(0, 0, 0, 0.9);
}
.mirror-time {
display: flex;
align-items: baseline;
justify-content: center;
gap: 1rem;
}
.big {
font-variant-numeric: tabular-nums;
font-size: clamp(2.4rem, 12vw, 4.5rem);
font-weight: 400;
letter-spacing: 0.04em;
color: #ffe2bc;
text-shadow: 0 0 6px #ff7a1e, 0 0 22px #ff6a00, 0 0 50px rgba(255, 90, 0, 0.5);
}
.run {
letter-spacing: 0.2em;
font-size: 0.75rem;
color: #7d8286;
}
.run.live {
color: var(--green);
text-shadow: 0 0 8px rgba(70, 224, 106, 0.6);
}
.mirror-meta {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem 1.4rem;
font-size: clamp(0.6rem, 1.6vw, 0.8rem);
letter-spacing: 0.12em;
color: #8b9094;
}
.mirror-meta b {
color: var(--amber);
}
/* --- banks ---------------------------------------------------------------- */
.bank {
padding: 0.8rem clamp(0.7rem, 2.5vw, 1.1rem) 1rem;
border-radius: 12px;
background: linear-gradient(180deg, var(--steel-hi) -40%, var(--steel) 50%, var(--steel-lo));
border: 1px solid #0d0e0f;
box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.1), inset 0 -3px 8px rgba(0, 0, 0, 0.5);
}
.bank-label,
.sub {
display: block;
letter-spacing: 0.18em;
font-size: 0.62rem;
font-weight: 700;
color: #8b9094;
margin-bottom: 0.6rem;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7);
}
.sub {
display: inline;
margin: 0 0.4rem 0 0;
align-self: center;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.row + .row {
margin-top: 0.6rem;
}
/* --- buttons -------------------------------------------------------------- */
.btn {
font: inherit;
font-weight: 700;
letter-spacing: 0.08em;
font-size: 0.8rem;
color: #e7eaed;
padding: 0.55rem 0.9rem;
border-radius: 7px;
border: 1px solid #000;
background: linear-gradient(180deg, #3c4044, #23262a);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 2px 4px rgba(0, 0, 0, 0.5);
cursor: pointer;
transition: transform 0.05s, filter 0.1s;
}
.btn:hover {
filter: brightness(1.15);
}
.btn:active {
transform: translateY(1px);
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.6);
}
.btn--wide {
flex: 1;
}
.btn.active {
color: #0a0b0c;
background: linear-gradient(180deg, #ffd98a, var(--amber));
box-shadow: 0 0 10px rgba(255, 179, 71, 0.6);
}
.btn--amber {
color: #2a1c00;
background: linear-gradient(180deg, #ffd98a, var(--amber));
}
.btn--red {
color: #fff;
background: linear-gradient(180deg, #ff6a60, var(--red));
}
.btn--big {
flex: 1;
font-size: 1rem;
padding: 0.9rem;
letter-spacing: 0.16em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
margin-bottom: 0.7rem;
}
.jump {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-items: flex-start;
text-align: left;
padding: 0.5rem 0.7rem;
}
.jump b {
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.jump span {
font-weight: 400;
font-size: 0.62rem;
letter-spacing: 0.06em;
color: #aeb3b7;
}
.jump.btn--amber span,
.jump.btn--red span {
color: rgba(0, 0, 0, 0.65);
}
.iters .iter {
flex: 1;
min-width: 90px;
}
/* --- inputs --------------------------------------------------------------- */
input {
font: inherit;
font-size: 0.85rem;
color: #e7eaed;
background: #101214;
border: 1px solid #000;
border-radius: 6px;
padding: 0.5rem 0.6rem;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.6);
}
input:focus {
outline: 1px solid var(--amber);
}
#token,
#msg {
flex: 1;
min-width: 8rem;
}
.hint {
font-size: 0.7rem;
color: var(--amber);
letter-spacing: 0.08em;
}
.foot {
text-align: center;
font-size: 0.72rem;
letter-spacing: 0.1em;
color: #7d8286;
}
.foot a {
color: var(--amber);
}

View file

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Array Control</title>
<link rel="stylesheet" href="control.css" />
</head>
<body class="panel">
<main class="rig">
<header class="plate">
<span class="stencil">ARRAY CONTROL · HANDLER ONLY</span>
<span class="link"><i class="lamp" id="lampLink"></i> LINK</span>
</header>
<!-- live mirror of what the players see -->
<section class="mirror">
<div class="mirror-time">
<span class="big" id="mClock">17:00:00</span>
<span class="run" id="mRun">PAUSED</span>
</div>
<div class="mirror-meta">
<span>COMMUNION IN <b id="mCountdown">—:—:—</b></span>
<span>ITERATION <b id="mIter">I</b></span>
<span>NEXT <b id="mNext"></b></span>
</div>
</section>
<!-- token -->
<section class="bank">
<label class="bank-label" for="token">CONTROL KEY</label>
<div class="row">
<input id="token" type="password" autocomplete="off" placeholder="shared token" />
<button class="btn" id="saveToken" type="button">SET</button>
<span class="hint" id="tokenHint"></span>
</div>
</section>
<!-- transport -->
<section class="bank">
<span class="bank-label">TRANSPORT</span>
<div class="row">
<button class="btn btn--wide" data-cmd="play" type="button">▶ RUN</button>
<button class="btn btn--wide" data-cmd="pause" type="button">⏸ HOLD</button>
</div>
<div class="row rates" id="rates">
<span class="sub">RATE</span>
<button class="btn rate" data-rate="1" type="button">×1</button>
<button class="btn rate" data-rate="6" type="button">×6</button>
<button class="btn rate" data-rate="30" type="button">×30</button>
<button class="btn rate" data-rate="60" type="button">×60</button>
<button class="btn rate" data-rate="300" type="button">×300</button>
</div>
</section>
<!-- timeline jumps -->
<section class="bank">
<span class="bank-label">JUMP TO BEAT</span>
<div class="grid" id="jumps"></div>
<div class="row">
<span class="sub">SET</span>
<input id="manualTime" type="time" step="1" value="17:00:00" />
<button class="btn" id="manualJump" type="button">JUMP</button>
</div>
</section>
<!-- iterations -->
<section class="bank">
<span class="bank-label">ITERATION — resets reality to its wake point</span>
<div class="row iters" id="iters">
<button class="btn iter" data-iter="1" type="button">I · 17:00</button>
<button class="btn iter" data-iter="2" type="button">II · 18:46</button>
<button class="btn iter" data-iter="3" type="button">III · 20:57</button>
<button class="btn iter" data-iter="4" type="button">IV · SINGULARITY</button>
</div>
</section>
<!-- effects -->
<section class="bank">
<span class="bank-label">EFFECTS — fire on the players' screen</span>
<div class="row">
<button class="btn btn--amber btn--big" data-cmd="pulse" type="button">◉ PULSE</button>
<button class="btn btn--red btn--big" data-cmd="communion" type="button">☢ COMMUNION</button>
</div>
<div class="row">
<input id="msg" type="text" maxlength="60" placeholder="broadcast a line of text…" />
<button class="btn" id="sendMsg" type="button">SHOW</button>
<button class="btn" id="clearMsg" type="button">CLEAR</button>
</div>
</section>
<footer class="foot">
Player screen: <a id="clockLink" href="clock.html" target="_blank" rel="noopener">open the chronometer ↗</a>
</footer>
</main>
<script src="common.js"></script>
<script src="control.js"></script>
</body>
</html>

View file

@ -0,0 +1,120 @@
/* Handler's control panel. Sends authenticated commands to the relay and
mirrors the live clock so the Handler sees exactly what the players see. */
(function () {
"use strict";
const $ = (id) => document.getElementById(id);
// --- control key (persisted locally) --------------------------------------
const tokenInput = $("token");
tokenInput.value = localStorage.getItem("oe-token") || "";
function saveToken() {
localStorage.setItem("oe-token", tokenInput.value.trim());
flashHint("key set");
}
$("saveToken").addEventListener("click", saveToken);
tokenInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") saveToken();
});
let hintTimer = null;
function flashHint(text) {
const el = $("tokenHint");
el.textContent = text;
clearTimeout(hintTimer);
hintTimer = setTimeout(() => (el.textContent = ""), 1500);
}
// --- connection -----------------------------------------------------------
const model = OE.ClockModel();
const conn = OE.connect(OE.defaultWsUrl(), {
onstatus(s) {
$("lampLink").classList.toggle("lamp--on", s === "open");
},
onstate(state) {
model.apply(state);
syncRateButtons(state.rate);
},
});
window.addEventListener("beforeunload", () => conn.close());
function send(obj) {
const token = (localStorage.getItem("oe-token") || "").trim();
if (!token) {
flashHint("no control key set");
tokenInput.focus();
return;
}
conn.send(Object.assign({ token }, obj));
}
// --- wire fixed-command buttons (play/pause/pulse/communion) ---------------
document.querySelectorAll("[data-cmd]").forEach((btn) => {
btn.addEventListener("click", () => send({ cmd: btn.dataset.cmd }));
});
// --- rate -----------------------------------------------------------------
document.querySelectorAll(".rate").forEach((btn) => {
btn.addEventListener("click", () => send({ cmd: "rate", rate: Number(btn.dataset.rate) }));
});
function syncRateButtons(rate) {
document.querySelectorAll(".rate").forEach((b) => {
b.classList.toggle("active", Number(b.dataset.rate) === rate);
});
}
// --- iterations -----------------------------------------------------------
document.querySelectorAll(".iter").forEach((btn) => {
btn.addEventListener("click", () => send({ cmd: "iteration", n: Number(btn.dataset.iter) }));
});
// --- timeline jumps -------------------------------------------------------
const jumps = $("jumps");
OE.EVENTS.forEach((ev) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn jump" + (ev.communion ? " btn--red" : ev.pulse ? " btn--amber" : "");
b.innerHTML = "<b>" + ev.time + "</b><span>" + ev.label + "</span>";
b.addEventListener("click", () => send({ cmd: "set", inworld: ev.t }));
jumps.appendChild(b);
});
// manual time -> seconds-of-day
$("manualJump").addEventListener("click", () => {
const parts = $("manualTime").value.split(":").map(Number);
if (parts.length >= 2 && parts.every((n) => !Number.isNaN(n))) {
const sec = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0);
send({ cmd: "set", inworld: sec });
}
});
// --- broadcast message ----------------------------------------------------
$("sendMsg").addEventListener("click", () => {
const text = $("msg").value.trim();
if (text) send({ cmd: "msg", text });
});
$("clearMsg").addEventListener("click", () => send({ cmd: "msg", text: null }));
$("msg").addEventListener("keydown", (e) => {
if (e.key === "Enter") $("sendMsg").click();
});
// player-screen link points at clock.html next to this page
$("clockLink").href = new URL("clock.html", location.href).href;
// --- mirror loop ----------------------------------------------------------
function nextBeat(inworld) {
const upcoming = OE.EVENTS.filter((e) => e.t > inworld + 0.5);
return upcoming.length ? upcoming[0] : null;
}
function frame() {
const s = model.now();
$("mClock").textContent = OE.clock(s.inworld);
$("mIter").textContent = OE.ROMAN[s.iteration] || s.iteration;
$("mCountdown").textContent = s.remaining <= 0 ? "00:00:00" : OE.duration(s.remaining);
$("mRun").textContent = s.running ? "RUNNING" : "PAUSED";
$("mRun").classList.toggle("live", s.running);
const nb = nextBeat(s.inworld);
$("mNext").textContent = nb ? nb.time + " " + nb.label : "—";
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();

Binary file not shown.

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Synthesize the Observer Effect SFX with ffmpeg. Pure lavfi synthesis — no
# samples. Re-run to regenerate the .mp3s in this directory.
#
# nix-shell -p ffmpeg --run ./generate.sh
#
# Expressions are deliberately comma-free (logistic gates 1/(1+exp(-k*(t-T)))
# and exp() envelopes instead of between()/min()/max()) so nothing needs
# escaping inside the filtergraph.
set -euo pipefail
cd "$(dirname "$0")"
SR=48000
MP3=(-ac 1 -c:a libmp3lame -b:a 128k)
# Per-second clicks use WAV: mp3 adds a few ms of priming silence at the buffer
# start, which smears the transient against the digit changeover.
WAV=(-ac 1 -c:a pcm_s16le)
# --- pulse: a deep drum thump, a rising flute whistle, a metallic shimmer ----
PULSE='exp(-5*t)*0.9*sin(2*PI*(90*t-25*t*t))'
PULSE+=' + (1-exp(-25*t))*exp(-3*t)*0.16*sin(2*PI*(1500*t+850*t*t))'
PULSE+=' + exp(-2.2*t)*0.05*sin(2*PI*2700*t)*(1+0.5*sin(2*PI*7*t))'
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "aevalsrc=${PULSE}:s=${SR}:d=1.4" \
-af "aecho=0.8:0.7:45|110:0.35|0.2,alimiter=limit=0.95,aresample=44100" \
"${MP3[@]}" pulse.mp3
# --- communion: rising roar + noise swell -> sub-bass impact -> screaming
# flutes over a beating void drone -------------------------------------
ROAR='(1-exp(-2.5*t))*exp(-0.28*t)*0.28*(sin(2*PI*(60*t-2.5*t*t))+0.5*sin(4*PI*(60*t-2.5*t*t))+0.33*sin(6*PI*(60*t-2.5*t*t)))'
IMPACT='(1/(1+exp(-30*(t-1.6))))*exp(-3.2*(t-1.6))*0.95*sin(2*PI*(80*(t-1.6)-35*(t-1.6)*(t-1.6)))'
FLUTES='(1/(1+exp(-30*(t-1.7))))*exp(-0.9*(t-1.7))*0.07*(sin(2*PI*1700*t)+sin(2*PI*2050*t)+sin(2*PI*2390*t)+sin(2*PI*2900*t)+sin(2*PI*3550*t))*(1+0.4*sin(2*PI*5*t))'
DRONE='(1/(1+exp(-25*(t-1.7))))*exp(-0.5*(t-1.7))*0.22*(sin(2*PI*40*t)+sin(2*PI*40.5*t))'
TONAL="${ROAR} + ${IMPACT} + ${FLUTES} + ${DRONE}"
SWELL="volume=volume='0.4*(1-exp(-2.2*t))*(1/(1+exp(8*(t-1.95))))*(0.6+0.4*sin(2*PI*0.9*t))':eval=frame"
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "aevalsrc=${TONAL}:s=${SR}:d=6.5" \
-f lavfi -i "anoisesrc=color=white:amplitude=0.6:r=${SR}:d=6.5" \
-filter_complex "[1:a]highpass=f=3000,${SWELL}[n];[0:a][n]amix=inputs=2:normalize=0,aecho=0.8:0.85:55|150:0.35|0.2,alimiter=limit=0.96,aresample=44100[out]" \
-map "[out]" "${MP3[@]}" communion.mp3
# --- whisper: a brief eerie transmission for broadcast text ------------------
WHISP='(1-exp(-8*t))*exp(-2*t)*0.12*(sin(2*PI*620*t)+sin(2*PI*623*t))'
WHISP+=' + exp(-3*t)*0.04*sin(2*PI*(2200*t-300*t*t))'
WHISP+=' + 0.03*exp(-1.5*t)*sin(2*PI*1240*t)*(1+0.6*sin(2*PI*11*t))'
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "aevalsrc=${WHISP}:s=${SR}:d=1.6" \
-f lavfi -i "anoisesrc=color=white:amplitude=0.25:r=${SR}:d=1.6" \
-filter_complex "[1:a]bandpass=f=1500:width_type=h:w=900,volume=volume='0.18*exp(-2.5*t)*(0.5+0.5*sin(2*PI*13*t))':eval=frame[n];[0:a][n]amix=inputs=2:normalize=0,volume=12dB,aecho=0.8:0.6:70:0.3,alimiter=limit=0.9,aresample=44100[out]" \
-map "[out]" "${MP3[@]}" whisper.mp3
# --- relay: a dry electromechanical seconds step (armature snap + body) -------
# The seconds advance is a stepping relay, not a pendulum: a sharp broadband
# snap with a faint low "thunk" and almost no tonal tail.
RELAY='exp(-120*t)*0.3*sin(2*PI*430*t) + exp(-220*t)*0.32*sin(2*PI*1700*t)'
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "aevalsrc=${RELAY}:s=${SR}:d=0.09" \
-f lavfi -i "anoisesrc=color=white:amplitude=0.6:r=${SR}:d=0.09" \
-filter_complex "[1:a]bandpass=f=2400:width_type=h:w=2400,volume=volume='0.6*exp(-230*t)':eval=frame[n];[0:a][n]amix=inputs=2:normalize=0,alimiter=limit=0.95,aresample=44100[out]" \
-map "[out]" "${WAV[@]}" relay.wav
echo "generated:"
for f in pulse.mp3 communion.mp3 whisper.mp3 relay.wav; do
printf ' %-14s %s\n' "$f" "$(du -h "$f" | cut -f1)"
done

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,326 @@
/* Optional soundscape for the player screen the "flutes and drums" the
scenario keeps describing. Silent until the player clicks to enable it
(browsers block audio without a gesture). All synthesized; no asset files. */
(function () {
"use strict";
let ctx = null;
let master = null;
let droneGain = null;
// Pre-rendered ffmpeg SFX (see sfx/generate.sh). Decoded into buffers on
// enable; if a file fails to load we fall back to the synth below.
const FILES = {
pulse: "sfx/pulse.mp3",
communion: "sfx/communion.mp3",
whisper: "sfx/whisper.mp3",
relay: "sfx/relay.wav",
};
const buffers = {};
function loadBuffers() {
for (const [name, url] of Object.entries(FILES)) {
fetch(new URL(url, location.href))
.then((r) => (r.ok ? r.arrayBuffer() : Promise.reject()))
.then((data) => ctx.decodeAudioData(data))
.then((buf) => (buffers[name] = buf))
.catch(() => {});
}
}
function play(name) {
const buf = buffers[name];
if (!buf || !ctx) return false;
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(master);
src.start();
return true;
}
function enable() {
if (ctx) return true;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return false;
ctx = new AC();
master = ctx.createGain();
master.gain.value = 0.8;
master.connect(ctx.destination);
// A near-subaudible drone that swells as communion nears (see setDread).
droneGain = ctx.createGain();
droneGain.gain.value = 0;
droneGain.connect(master);
const drone = ctx.createOscillator();
drone.type = "sine";
drone.frequency.value = 33;
const drone2 = ctx.createOscillator();
drone2.type = "sine";
drone2.frequency.value = 33.4; // beat against the first for unease
drone.connect(droneGain);
drone2.connect(droneGain);
drone.start();
drone2.start();
// Mains hum: this is a high-voltage 1964 nixie device, so it's never truly
// silent. A 60 Hz transformer hum with harmonics plus a faint HV whine,
// synthesized live so it loops seamlessly. Constant while sound is on.
const humGain = ctx.createGain();
humGain.gain.value = 0.07;
humGain.connect(master);
const humLfo = ctx.createOscillator(); // slow amplitude waver
humLfo.frequency.value = 0.5;
const humLfoGain = ctx.createGain();
humLfoGain.gain.value = 0.012;
humLfo.connect(humLfoGain).connect(humGain.gain);
humLfo.start();
const humLp = ctx.createBiquadFilter();
humLp.type = "lowpass";
humLp.frequency.value = 500;
humLp.connect(humGain);
[[60, 0.5], [120, 0.32], [180, 0.12]].forEach(([f, g]) => {
const o = ctx.createOscillator();
o.type = "sine";
o.frequency.value = f;
const og = ctx.createGain();
og.gain.value = g;
o.connect(og).connect(humLp);
o.start();
});
const whine = ctx.createOscillator(); // high-voltage supply whine
whine.type = "sine";
whine.frequency.value = 15000;
const whineGain = ctx.createGain();
whineGain.gain.value = 0.015;
whine.connect(whineGain).connect(humGain);
whine.start();
loadBuffers();
return true;
}
function enabled() {
return !!ctx;
}
// Low drum thump + a thin flute whistle: one "pulse" of Azathoth's court.
function synthPulse() {
if (!ctx) return;
const t = ctx.currentTime;
const drum = ctx.createOscillator();
const dg = ctx.createGain();
drum.type = "sine";
drum.frequency.setValueAtTime(64, t);
drum.frequency.exponentialRampToValueAtTime(34, t + 0.5);
dg.gain.setValueAtTime(0.0001, t);
dg.gain.exponentialRampToValueAtTime(0.9, t + 0.02);
dg.gain.exponentialRampToValueAtTime(0.0001, t + 0.7);
drum.connect(dg).connect(master);
drum.start(t);
drum.stop(t + 0.75);
const flute = ctx.createOscillator();
const fg = ctx.createGain();
flute.type = "triangle";
flute.frequency.setValueAtTime(1900, t);
flute.frequency.exponentialRampToValueAtTime(3300, t + 0.6);
fg.gain.setValueAtTime(0.0001, t);
fg.gain.exponentialRampToValueAtTime(0.12, t + 0.05);
fg.gain.exponentialRampToValueAtTime(0.0001, t + 0.65);
flute.connect(fg).connect(master);
flute.start(t);
flute.stop(t + 0.7);
}
function noise(dur) {
const len = Math.floor(ctx.sampleRate * dur);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
return buf;
}
// The full extrusion: a rising roar and noise swell that detonate into a
// sub-bass impact, then a cluster of screaming flutes over a void drone.
// Timed so the impact lands with the visual blast (~1.6s in).
function synthCommunion() {
if (!ctx) return;
const t = ctx.currentTime;
const blast = t + 1.6;
// rising roar into the blast
const roar = ctx.createOscillator();
const rg = ctx.createGain();
roar.type = "sawtooth";
roar.frequency.setValueAtTime(26, t);
roar.frequency.exponentialRampToValueAtTime(64, blast);
roar.frequency.exponentialRampToValueAtTime(30, blast + 2.6);
rg.gain.setValueAtTime(0.0001, t);
rg.gain.exponentialRampToValueAtTime(0.5, blast);
rg.gain.exponentialRampToValueAtTime(0.0001, blast + 3.2);
roar.connect(rg).connect(master);
roar.start(t);
roar.stop(blast + 3.4);
// reverse-cymbal noise swell sucking up into the blast
const sw = ctx.createBufferSource();
sw.buffer = noise(2);
const swf = ctx.createBiquadFilter();
swf.type = "highpass";
swf.frequency.setValueAtTime(200, t);
swf.frequency.exponentialRampToValueAtTime(9000, blast);
const swg = ctx.createGain();
swg.gain.setValueAtTime(0.0001, t);
swg.gain.exponentialRampToValueAtTime(0.22, blast);
swg.gain.exponentialRampToValueAtTime(0.0001, blast + 0.4);
sw.connect(swf).connect(swg).connect(master);
sw.start(t);
sw.stop(blast + 0.5);
// THE IMPACT: a plunging sub-bass body + a broadband burst
const imp = ctx.createOscillator();
const ig = ctx.createGain();
imp.type = "sine";
imp.frequency.setValueAtTime(82, blast);
imp.frequency.exponentialRampToValueAtTime(18, blast + 0.8);
ig.gain.setValueAtTime(0.0001, blast);
ig.gain.exponentialRampToValueAtTime(0.95, blast + 0.03);
ig.gain.exponentialRampToValueAtTime(0.0001, blast + 1.4);
imp.connect(ig).connect(master);
imp.start(blast);
imp.stop(blast + 1.5);
const burst = ctx.createBufferSource();
burst.buffer = noise(1);
const bf = ctx.createBiquadFilter();
bf.type = "bandpass";
bf.frequency.value = 1100;
bf.Q.value = 0.4;
const bg = ctx.createGain();
bg.gain.setValueAtTime(0.55, blast);
bg.gain.exponentialRampToValueAtTime(0.0001, blast + 0.6);
burst.connect(bf).connect(bg).connect(master);
burst.start(blast);
burst.stop(blast + 0.7);
// screaming flute cluster after the rupture
[1700, 2050, 2390, 2900, 3550, 4200].forEach((f, i) => {
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "triangle";
o.frequency.setValueAtTime(f, blast);
o.frequency.linearRampToValueAtTime(f * 1.22, blast + 2.6);
g.gain.setValueAtTime(0.0001, blast + i * 0.06);
g.gain.exponentialRampToValueAtTime(0.06, blast + 0.3 + i * 0.06);
g.gain.exponentialRampToValueAtTime(0.0001, blast + 3);
o.connect(g).connect(master);
o.start(blast + i * 0.06);
o.stop(blast + 3.2);
});
// void drone tail (two detuned sines, beating)
const vg = ctx.createGain();
vg.gain.setValueAtTime(0.0001, blast + 0.2);
vg.gain.exponentialRampToValueAtTime(0.3, blast + 1);
vg.gain.exponentialRampToValueAtTime(0.0001, blast + 4);
vg.connect(master);
[40, 40.5].forEach((f) => {
const o = ctx.createOscillator();
o.type = "sine";
o.frequency.value = f;
o.connect(vg);
o.start(blast);
o.stop(blast + 4.2);
});
}
// level 0..1 — swells the drone with tension, but cuts cleanly to silence
// once tension is spent so the deep pulse doesn't hang around.
function setDread(level) {
if (!ctx || !droneGain) return;
const clamped = Math.max(0, Math.min(1, level));
const now = ctx.currentTime;
if (clamped <= 0.08) {
droneGain.gain.cancelScheduledValues(now);
droneGain.gain.setTargetAtTime(0, now, 0.3);
return;
}
droneGain.gain.setTargetAtTime(clamped * 0.22, now, 1.5);
}
// File first, synth as fallback.
function pulse() {
if (!play("pulse")) synthPulse();
}
function communion() {
if (!play("communion")) synthCommunion();
}
function message() {
play("whisper");
}
// Per-second stepping relay — consistent level, no variation. Optionally
// scheduled at a future AudioContext time (`when`) so ticks keep firing while
// the tab is backgrounded. Pending clicks are tracked so they can be canceled.
let pendingTicks = [];
function step(when) {
if (!ctx) return;
const at = when || ctx.currentTime;
const g = ctx.createGain();
g.gain.value = 0.4;
g.connect(master);
let node;
if (buffers.relay) {
node = ctx.createBufferSource();
node.buffer = buffers.relay;
node.connect(g);
node.start(at);
} else {
node = ctx.createOscillator();
node.type = "square";
node.frequency.value = 1800;
const e = ctx.createGain();
e.gain.setValueAtTime(0.0001, at);
e.gain.exponentialRampToValueAtTime(0.4, at + 0.002);
e.gain.exponentialRampToValueAtTime(0.0001, at + 0.03);
node.connect(e).connect(g);
node.start(at);
node.stop(at + 0.04);
}
pendingTicks.push({ node, at });
if (pendingTicks.length > 64) {
pendingTicks = pendingTicks.filter((p) => p.at > ctx.currentTime - 1);
}
}
// Stop any clicks scheduled for the future (e.g. on pause or a clock jump).
function cancelTicks() {
if (!ctx) return;
const t = ctx.currentTime;
for (const p of pendingTicks) {
if (p.at > t) {
try {
p.node.stop(t);
} catch (_) {}
}
}
pendingTicks = pendingTicks.filter((p) => p.at > t);
}
function audioNow() {
return ctx ? ctx.currentTime : 0;
}
window.OESound = {
enable,
enabled,
pulse,
communion,
message,
step,
cancelTicks,
now: audioNow,
setDread,
};
})();

View file

@ -0,0 +1,17 @@
observer:
token: ENC[AES256_GCM,data:0qpwXX4QtrQFkmCZJ1RnvIbI+1DN+lB9OAIvvLvh694=,iv:3T5KFJxrzYkOr+LwNmk+FeKoySub+BJ5brf4kJvfxAE=,tag:6gVWgBPD5KYMVYzhkN7Nyg==,type:str]
sops:
age:
- recipient: age1856wmagg3dz4j07alwqnn6d75655t6wcs8glklyjyezhu5p875fq9sez4p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIU3NCcHZvRFlvRmcvc1Yz
cDB6NUo2MkRsTVh5ekZic01oM0xQTXJFKzNRCjRsZFRRQnIrTjNzcFM0RzVFbVhV
NDBjVzhuTTVVOFp1dVJJL1hObDNVaHMKLS0tIDZxWFNtSWFxQ1hyV2tLTHNFZzJ5
YmRBT3d2d05XdURiMkU4dm5lc1h0MFkK0HyRftTHpoDCk0qxDydBc1cCORf4p7Ev
Cyqy+YKJXXI5HGlBG+pxVbpVPgKebY8WNt2891i/v9vgQkN1owk0iA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-06-15T06:20:11Z"
mac: ENC[AES256_GCM,data:uXv9/78DBOZxIS8nj+UK8OZzvRUCq1tmMSNV+WVZGUnEYcmYzQFT4ZAR2ojvk5uTjn1DQg9lppNH3WFEQc3LOuxFmbOBLWFIqsRHFfaek7xVbcWPibe9SGJ7opqbRWCTpC/yEC2Uf8/Zcjp5tkqI7+3FUunK0R1mYo2KBNxe+68=,iv:2T9PGvfB07+V5pCIq/InQpXNtVGwutA5HwfJ4L03Ymc=,tag:+Sm77auYcLyHYFIXDpBagQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.12.1