observer effect content
This commit is contained in:
parent
625a847a81
commit
e4ee25295c
23 changed files with 2982 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
docs/
|
||||
games/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
services/observer-effect.nix
Normal file
86
services/observer-effect.nix
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
66
services/observer-effect/README.md
Normal file
66
services/observer-effect/README.md
Normal 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.
|
||||
67
services/observer-effect/devserver.mjs
Normal file
67
services/observer-effect/devserver.mjs
Normal 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}`);
|
||||
});
|
||||
1
services/observer-effect/relay/.gitignore
vendored
Normal file
1
services/observer-effect/relay/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
772
services/observer-effect/relay/Cargo.lock
generated
Normal file
772
services/observer-effect/relay/Cargo.lock
generated
Normal 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"
|
||||
15
services/observer-effect/relay/Cargo.toml
Normal file
15
services/observer-effect/relay/Cargo.toml
Normal 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
|
||||
251
services/observer-effect/relay/src/main.rs
Normal file
251
services/observer-effect/relay/src/main.rs
Normal 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());
|
||||
}
|
||||
438
services/observer-effect/site/clock.css
Normal file
438
services/observer-effect/site/clock.css
Normal 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;
|
||||
}
|
||||
}
|
||||
46
services/observer-effect/site/clock.html
Normal file
46
services/observer-effect/site/clock.html
Normal 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>
|
||||
215
services/observer-effect/site/clock.js
Normal file
215
services/observer-effect/site/clock.js
Normal 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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
134
services/observer-effect/site/common.js
Normal file
134
services/observer-effect/site/common.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
260
services/observer-effect/site/control.css
Normal file
260
services/observer-effect/site/control.css
Normal 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);
|
||||
}
|
||||
101
services/observer-effect/site/control.html
Normal file
101
services/observer-effect/site/control.html
Normal 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>
|
||||
120
services/observer-effect/site/control.js
Normal file
120
services/observer-effect/site/control.js
Normal 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);
|
||||
})();
|
||||
BIN
services/observer-effect/site/sfx/communion.mp3
Normal file
BIN
services/observer-effect/site/sfx/communion.mp3
Normal file
Binary file not shown.
65
services/observer-effect/site/sfx/generate.sh
Executable file
65
services/observer-effect/site/sfx/generate.sh
Executable 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
|
||||
BIN
services/observer-effect/site/sfx/pulse.mp3
Normal file
BIN
services/observer-effect/site/sfx/pulse.mp3
Normal file
Binary file not shown.
BIN
services/observer-effect/site/sfx/relay.wav
Normal file
BIN
services/observer-effect/site/sfx/relay.wav
Normal file
Binary file not shown.
BIN
services/observer-effect/site/sfx/whisper.mp3
Normal file
BIN
services/observer-effect/site/sfx/whisper.mp3
Normal file
Binary file not shown.
326
services/observer-effect/site/sound.js
Normal file
326
services/observer-effect/site/sound.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
17
services/secrets/observer_vps.yaml
Normal file
17
services/secrets/observer_vps.yaml
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue