diff --git a/.gitignore b/.gitignore index 77f12ae..5d612f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ docs/ +games/ diff --git a/flake.nix b/flake.nix index 0f1e00c..35566b8 100644 --- a/flake.nix +++ b/flake.nix @@ -105,6 +105,7 @@ ./hosts/vps/disko-config.nix ./services/nginx.nix ./services/blog.nix + ./services/observer-effect.nix ./services/phanpy.nix ./services/coturn.nix ./services/livekit.nix diff --git a/services/observer-effect.nix b/services/observer-effect.nix new file mode 100644 index 0000000..a92f931 --- /dev/null +++ b/services/observer-effect.nix @@ -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; + }; + }; +} diff --git a/services/observer-effect/README.md b/services/observer-effect/README.md new file mode 100644 index 0000000..bc6ea4a --- /dev/null +++ b/services/observer-effect/README.md @@ -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?:///ws`, so behind nginx everything is same-origin. diff --git a/services/observer-effect/devserver.mjs b/services/observer-effect/devserver.mjs new file mode 100644 index 0000000..934db8b --- /dev/null +++ b/services/observer-effect/devserver.mjs @@ -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}`); +}); diff --git a/services/observer-effect/relay/.gitignore b/services/observer-effect/relay/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/services/observer-effect/relay/.gitignore @@ -0,0 +1 @@ +/target diff --git a/services/observer-effect/relay/Cargo.lock b/services/observer-effect/relay/Cargo.lock new file mode 100644 index 0000000..9425f2a --- /dev/null +++ b/services/observer-effect/relay/Cargo.lock @@ -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" diff --git a/services/observer-effect/relay/Cargo.toml b/services/observer-effect/relay/Cargo.toml new file mode 100644 index 0000000..c01a268 --- /dev/null +++ b/services/observer-effect/relay/Cargo.toml @@ -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 diff --git a/services/observer-effect/relay/src/main.rs b/services/observer-effect/relay/src/main.rs new file mode 100644 index 0000000..6fc7768 --- /dev/null +++ b/services/observer-effect/relay/src/main.rs @@ -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, + tx: broadcast::Sender, +} + +#[derive(Deserialize)] +struct Command { + token: Option, + cmd: String, + #[serde(default)] + rate: Option, + #[serde(default)] + inworld: Option, + #[serde(default)] + n: Option, + #[serde(default)] + text: Option, +} + +#[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>) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, app)) +} + +async fn handle_socket(mut socket: WebSocket, app: Arc) { + // 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()); +} diff --git a/services/observer-effect/site/clock.css b/services/observer-effect/site/clock.css new file mode 100644 index 0000000..f831817 --- /dev/null +++ b/services/observer-effect/site/clock.css @@ -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; + } +} diff --git a/services/observer-effect/site/clock.html b/services/observer-effect/site/clock.html new file mode 100644 index 0000000..2c4ab8d --- /dev/null +++ b/services/observer-effect/site/clock.html @@ -0,0 +1,46 @@ + + + + + + + Warner Center Radio Array — Master Chronometer + + + +
+
+
+ WARNER CENTER RADIO ARRAY + REC +
+
MASTER CHRONOMETER
+ + +

17:00:00 local time

+ +
+
+ ARRAY +
+
+ SIGNAL +
+
+ +
+ PROPERTY OF THE U.S. DEPARTMENT OF ENERGY · MCMLXIV · AUTHORIZED PERSONNEL ONLY +
+
+
+ + + + + + + + + + + diff --git a/services/observer-effect/site/clock.js b/services/observer-effect/site/clock.js new file mode 100644 index 0000000..384cac8 --- /dev/null +++ b/services/observer-effect/site/clock.js @@ -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 = ""; + 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; + } + }); +})(); diff --git a/services/observer-effect/site/common.js b/services/observer-effect/site/common.js new file mode 100644 index 0000000..0e40fa9 --- /dev/null +++ b/services/observer-effect/site/common.js @@ -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, + }; +})(); diff --git a/services/observer-effect/site/control.css b/services/observer-effect/site/control.css new file mode 100644 index 0000000..3af6bc0 --- /dev/null +++ b/services/observer-effect/site/control.css @@ -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); +} diff --git a/services/observer-effect/site/control.html b/services/observer-effect/site/control.html new file mode 100644 index 0000000..e312a02 --- /dev/null +++ b/services/observer-effect/site/control.html @@ -0,0 +1,101 @@ + + + + + + + Array Control + + + +
+
+ ARRAY CONTROL · HANDLER ONLY + LINK +
+ + +
+
+ 17:00:00 + PAUSED +
+
+ COMMUNION IN —:—:— + ITERATION I + NEXT +
+
+ + +
+ +
+ + + +
+
+ + +
+ TRANSPORT +
+ + +
+
+ RATE + + + + + +
+
+ + +
+ JUMP TO BEAT +
+
+ SET + + +
+
+ + +
+ ITERATION — resets reality to its wake point +
+ + + + +
+
+ + +
+ EFFECTS — fire on the players' screen +
+ + +
+
+ + + +
+
+ + +
+ + + + + diff --git a/services/observer-effect/site/control.js b/services/observer-effect/site/control.js new file mode 100644 index 0000000..58ddede --- /dev/null +++ b/services/observer-effect/site/control.js @@ -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 = "" + ev.time + "" + ev.label + ""; + 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); +})(); diff --git a/services/observer-effect/site/sfx/communion.mp3 b/services/observer-effect/site/sfx/communion.mp3 new file mode 100644 index 0000000..6ff0786 Binary files /dev/null and b/services/observer-effect/site/sfx/communion.mp3 differ diff --git a/services/observer-effect/site/sfx/generate.sh b/services/observer-effect/site/sfx/generate.sh new file mode 100755 index 0000000..79f4ba2 --- /dev/null +++ b/services/observer-effect/site/sfx/generate.sh @@ -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 diff --git a/services/observer-effect/site/sfx/pulse.mp3 b/services/observer-effect/site/sfx/pulse.mp3 new file mode 100644 index 0000000..202c982 Binary files /dev/null and b/services/observer-effect/site/sfx/pulse.mp3 differ diff --git a/services/observer-effect/site/sfx/relay.wav b/services/observer-effect/site/sfx/relay.wav new file mode 100644 index 0000000..a9b3fca Binary files /dev/null and b/services/observer-effect/site/sfx/relay.wav differ diff --git a/services/observer-effect/site/sfx/whisper.mp3 b/services/observer-effect/site/sfx/whisper.mp3 new file mode 100644 index 0000000..46ed919 Binary files /dev/null and b/services/observer-effect/site/sfx/whisper.mp3 differ diff --git a/services/observer-effect/site/sound.js b/services/observer-effect/site/sound.js new file mode 100644 index 0000000..aee0ada --- /dev/null +++ b/services/observer-effect/site/sound.js @@ -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, + }; +})(); diff --git a/services/secrets/observer_vps.yaml b/services/secrets/observer_vps.yaml new file mode 100644 index 0000000..2c2ab9f --- /dev/null +++ b/services/secrets/observer_vps.yaml @@ -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