refactor: stream simulation state over WebSockets

This commit is contained in:
Steve Biedermann 2026-10-07 20:00:39 +02:00
parent e4a4cb0190
commit ef47209c79
7 changed files with 676 additions and 175 deletions

455
Cargo.lock generated
View File

@ -40,12 +40,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "async-lock"
version = "3.4.2"
@ -74,6 +68,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "attribute-derive"
version = "0.11.0"
@ -109,6 +109,61 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"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.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base16"
version = "0.2.1"
@ -121,6 +176,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -164,12 +225,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "codee"
version = "0.3.5"
@ -305,6 +360,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "derive-where"
version = "1.7.0"
@ -519,6 +580,18 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@ -528,7 +601,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasm-bindgen",
]
@ -606,6 +679,41 @@ dependencies = [
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[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"
@ -625,6 +733,40 @@ dependencies = [
"throw_error",
]
[[package]]
name = "hyper"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
"atomic-waker",
"bytes",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@ -802,7 +944,7 @@ dependencies = [
"cfg-if",
"either_of",
"futures",
"getrandom",
"getrandom 0.4.3",
"hydration_context",
"leptos_config",
"leptos_dom",
@ -984,12 +1126,45 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "musli"
version = "0.0.124"
@ -1200,6 +1375,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
@ -1273,12 +1457,47 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "reactive_graph"
version = "0.2.15"
@ -1568,6 +1787,17 @@ dependencies = [
"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_qs"
version = "0.15.0"
@ -1588,6 +1818,18 @@ dependencies = [
"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 = "server_fn"
version = "0.8.13"
@ -1646,6 +1888,17 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -1676,8 +1929,10 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
name = "sim"
version = "0.1.0"
dependencies = [
"axum",
"rune",
"tiny_http",
"tokio",
"tower-http",
]
[[package]]
@ -1707,6 +1962,16 @@ version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@ -1735,6 +2000,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
version = "0.14.0"
@ -1851,18 +2122,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@ -1879,7 +2138,49 @@ version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
@ -1921,12 +2222,66 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[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-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
[[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",
]
@ -1970,6 +2325,22 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "twox-hash"
version = "2.1.5"
@ -2012,11 +2383,18 @@ dependencies = [
"js-sys",
"leptos",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "unicase"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-ident"
version = "1.0.26"
@ -2065,7 +2443,7 @@ version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom",
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
@ -2092,6 +2470,21 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@ -2235,6 +2628,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.4"

View File

@ -8,4 +8,6 @@ members = ["ui"]
[dependencies]
rune = "0.14.2"
tiny_http = "0.12"
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
tower-http = { version = "0.6", features = ["fs"] }

View File

@ -1,12 +1,16 @@
mod world;
use std::io::Read;
use std::path::Path;
use std::sync::mpsc::{self, Sender};
use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{DefaultBodyLimit, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tiny_http::{Header, Method, Request, Response, Server};
use tokio::sync::{mpsc, oneshot, watch};
use tower_http::services::ServeDir;
use world::{DEFAULT_SCRIPT, World};
// Minimal shell; the whole UI is the Leptos app in ui/ compiled to wasm.
@ -17,144 +21,181 @@ try { const m = await import('/pkg/ui.js'); await m.default(); }
catch (e) { document.body.textContent = 'Failed to load the UI (did you run ui/build.sh?): ' + e; }
</script></body></html>"#;
fn serve_static(req: Request, dir: &Path, rel: &str) {
let rel = rel.split('?').next().unwrap_or("");
let safe = !rel.is_empty()
&& rel
.split('/')
.all(|c| !c.is_empty() && c != ".." && c != "." && !c.contains('\\'));
let ctype = match rel.rsplit('.').next() {
Some("js") => "application/javascript",
Some("wasm") => "application/wasm",
Some("css") => "text/css",
_ => "application/octet-stream",
};
match safe
.then(|| std::fs::read(dir.join(rel)))
.and_then(Result::ok)
{
Some(bytes) => {
let h = Header::from_bytes("Content-Type", ctype).unwrap();
let _ = req.respond(Response::from_data(bytes).with_header(h));
enum Msg {
Edit(String),
Script(String, oneshot::Sender<Result<(), String>>),
}
#[derive(Clone)]
struct App {
terrain: Arc<Mutex<String>>,
script: Arc<Mutex<String>>,
state: watch::Receiver<Arc<str>>,
tx: mpsc::UnboundedSender<Msg>,
}
/// The Rune VM is not Send, so the world lives on this thread and publishes JSON snapshots.
fn run_sim(
seed: u64,
mut rx: mpsc::UnboundedReceiver<Msg>,
terrain: Arc<Mutex<String>>,
script: Arc<Mutex<String>>,
state_tx: watch::Sender<Arc<str>>,
) {
let mut world = World::new(seed);
let mut rev = u32::MAX;
loop {
while let Ok(msg) = rx.try_recv() {
match msg {
Msg::Edit(cmd) => world.apply(&cmd),
Msg::Script(src, reply) => {
let r = world.set_script(&src);
if r.is_ok() {
*script.lock().unwrap() = src;
}
let _ = reply.send(r);
}
}
}
None => {
let _ = req.respond(
Response::from_string("not found (build the UI: see ui/build.sh)")
.with_status_code(404),
);
if !world.paused {
for _ in 0..world.speed {
world.step();
}
}
if world.terrain_rev != rev {
rev = world.terrain_rev;
*terrain.lock().unwrap() = world.terrain_json();
}
state_tx.send_replace(Arc::from(world.state_json()));
std::thread::sleep(Duration::from_millis(100));
}
}
/// Rejects cross-site requests (including WebSocket upgrades), which could otherwise edit the world.
async fn same_origin(req: Request, next: Next) -> Response {
let h = req.headers();
if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
let host = h
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let authority = origin.split_once("://").map_or(origin, |(_, rest)| rest);
if authority != host {
return StatusCode::FORBIDDEN.into_response();
}
}
next.run(req).await
}
fn json(body: String) -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/json")], body)
}
async fn index() -> Html<&'static str> {
Html(INDEX)
}
async fn terrain(State(app): State<App>) -> impl IntoResponse {
json(app.terrain.lock().unwrap().clone())
}
async fn state(State(app): State<App>) -> impl IntoResponse {
json(app.state.borrow().to_string())
}
async fn get_script(State(app): State<App>) -> String {
app.script.lock().unwrap().clone()
}
async fn post_script(State(app): State<App>, body: String) -> Response {
let (rtx, rrx) = oneshot::channel();
let _ = app.tx.send(Msg::Script(body, rtx));
match tokio::time::timeout(Duration::from_secs(5), rrx).await {
Ok(Ok(Ok(()))) => "ok".into_response(),
Ok(Ok(Err(e))) => (StatusCode::BAD_REQUEST, e).into_response(),
_ => (StatusCode::SERVICE_UNAVAILABLE, "timed out").into_response(),
}
}
async fn post_edit(State(app): State<App>, body: String) -> StatusCode {
if body.len() > 256 {
return StatusCode::PAYLOAD_TOO_LARGE;
}
let _ = app.tx.send(Msg::Edit(body));
StatusCode::OK
}
async fn ws(upgrade: WebSocketUpgrade, State(app): State<App>) -> Response {
upgrade.on_upgrade(move |socket| client(socket, app))
}
/// Pushes every new snapshot to the client; text frames from the client are edit commands.
async fn client(mut socket: WebSocket, app: App) {
let mut rx = app.state.clone();
loop {
let snapshot = rx.borrow_and_update().to_string();
if socket.send(Message::Text(snapshot.into())).await.is_err() {
return;
}
loop {
tokio::select! {
changed = rx.changed() => {
if changed.is_err() {
return;
}
break;
}
msg = socket.recv() => match msg {
Some(Ok(Message::Text(t))) if t.len() <= 256 => {
let _ = app.tx.send(Msg::Edit(t.as_str().to_owned()));
}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => return,
_ => {}
},
}
}
}
}
enum Msg {
Edit(String),
Script(String, Sender<Result<(), String>>),
}
fn main() {
#[tokio::main]
async fn main() {
let seed = std::env::args()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(42u64);
let addr = std::env::var("SIM_ADDR").unwrap_or_else(|_| "127.0.0.1:7878".into());
// The Rune VM is not Send, so the world lives on the sim thread and publishes JSON snapshots.
let terrain = Arc::new(Mutex::new(String::new()));
let state = Arc::new(Mutex::new(String::new()));
let ui_dir = std::env::var("SIM_UI_DIR").unwrap_or_else(|_| "ui/pkg".into());
let terrain_buf = Arc::new(Mutex::new(String::new()));
let script = Arc::new(Mutex::new(DEFAULT_SCRIPT.to_string()));
let (tx, rx) = mpsc::channel::<Msg>();
let (tx, rx) = mpsc::unbounded_channel();
let (state_tx, state_rx) = watch::channel(Arc::<str>::from("{}"));
{
let (terrain, state, script) = (
Arc::clone(&terrain),
Arc::clone(&state),
Arc::clone(&script),
);
thread::spawn(move || {
let mut world = World::new(seed);
let mut rev = u32::MAX;
loop {
while let Ok(msg) = rx.try_recv() {
match msg {
Msg::Edit(cmd) => world.apply(&cmd),
Msg::Script(src, reply) => {
let r = world.set_script(&src);
if r.is_ok() {
*script.lock().unwrap() = src;
}
let _ = reply.send(r);
}
}
}
if !world.paused {
for _ in 0..world.speed {
world.step();
}
}
if world.terrain_rev != rev {
rev = world.terrain_rev;
*terrain.lock().unwrap() = world.terrain_json();
}
*state.lock().unwrap() = world.state_json();
thread::sleep(Duration::from_millis(100));
}
});
let (terrain_buf, script) = (Arc::clone(&terrain_buf), Arc::clone(&script));
std::thread::spawn(move || run_sim(seed, rx, terrain_buf, script, state_tx));
}
let server = Server::http(&addr).expect("failed to bind");
let ui_dir = Arc::new(std::path::PathBuf::from(
std::env::var("SIM_UI_DIR").unwrap_or_else(|_| "ui/pkg".into()),
));
let app = App {
terrain: terrain_buf,
script,
state: state_rx,
tx,
};
let router = Router::new()
.route("/", get(index))
.route("/api/terrain", get(terrain))
.route("/api/state", get(state))
.route("/api/script", get(get_script).post(post_script))
.route("/api/edit", post(post_edit))
.route("/ws", get(ws))
.nest_service("/pkg", ServeDir::new(ui_dir))
.layer(DefaultBodyLimit::max(65536))
.layer(middleware::from_fn(same_origin))
.with_state(app);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("failed to bind");
println!("Simulation running at http://{addr}");
for mut req in server.incoming_requests() {
let (terrain, state, script) = (
Arc::clone(&terrain),
Arc::clone(&state),
Arc::clone(&script),
);
let tx = tx.clone();
let ui_dir = Arc::clone(&ui_dir);
thread::spawn(move || {
let post = *req.method() == Method::Post;
if !post {
if let Some(rel) = req.url().strip_prefix("/pkg/").map(str::to_owned) {
return serve_static(req, &ui_dir, &rel);
}
}
let mut input = String::new();
if post {
let _ = req.as_reader().take(65536).read_to_string(&mut input);
}
let (status, body, ctype) = match (post, req.url()) {
(false, "/") => (200, INDEX.to_string(), "text/html; charset=utf-8"),
(false, "/api/terrain") => {
(200, terrain.lock().unwrap().clone(), "application/json")
}
(false, "/api/state") => (200, state.lock().unwrap().clone(), "application/json"),
(false, "/api/script") => (
200,
script.lock().unwrap().clone(),
"text/plain; charset=utf-8",
),
(true, "/api/edit") if input.len() <= 256 => {
let _ = tx.send(Msg::Edit(input));
(200, "ok".to_string(), "text/plain")
}
(true, "/api/script") => {
let (rtx, rrx) = mpsc::channel();
let _ = tx.send(Msg::Script(input, rtx));
match rrx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(())) => (200, "ok".to_string(), "text/plain"),
Ok(Err(e)) => (400, e, "text/plain; charset=utf-8"),
Err(_) => (503, "timed out".to_string(), "text/plain"),
}
}
_ => (404, "not found".to_string(), "text/plain"),
};
let header = Header::from_bytes("Content-Type", ctype).unwrap();
let _ = req.respond(
Response::from_string(body)
.with_status_code(status)
.with_header(header),
);
});
}
axum::serve(listener, router).await.unwrap();
}

View File

@ -11,8 +11,9 @@ leptos = { version = "0.8", features = ["csr"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["CanvasRenderingContext2d", "HtmlCanvasElement", "HtmlElement", "MouseEvent", "Element", "DomRect", "HtmlDivElement", "Window", "Storage"] }
web-sys = { version = "0.3", features = ["CanvasRenderingContext2d", "HtmlCanvasElement", "HtmlElement", "MouseEvent", "WebSocket", "MessageEvent", "Location", "Element", "DomRect", "HtmlDivElement", "Window", "Storage"] }
gloo-net = { version = "0.6", default-features = false, features = ["http", "json"] }
gloo-timers = { version = "0.3", features = ["futures"] }
serde = { version = "1", features = ["derive"] }
console_error_panic_hook = "0.1"
serde_json = "1"

0
ui/build.sh Normal file → Executable file
View File

View File

@ -1,6 +1,55 @@
use gloo_net::http::Request;
use gloo_timers::future::TimeoutFuture;
use leptos::task::spawn_local;
use serde::de::DeserializeOwned;
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::Closure;
use web_sys::{MessageEvent, WebSocket};
thread_local! {
static SOCKET: RefCell<Option<WebSocket>> = const { RefCell::new(None) };
}
/// Opens the live socket and calls `on_state` with each pushed snapshot; reconnects after a drop.
pub fn connect(on_state: impl Fn(String) + 'static) {
let Some(loc) = web_sys::window().map(|w| w.location()) else {
return;
};
let proto = if loc.protocol().is_ok_and(|p| p == "https:") {
"wss"
} else {
"ws"
};
let Ok(host) = loc.host() else { return };
open(format!("{proto}://{host}/ws"), Rc::new(on_state));
}
fn open(url: String, on_state: Rc<dyn Fn(String)>) {
let Ok(ws) = WebSocket::new(&url) else { return };
let cb = on_state.clone();
let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() {
cb(text);
}
});
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onmessage.forget();
let url2 = url.clone();
let onclose = Closure::<dyn FnMut()>::new(move || {
SOCKET.with(|s| *s.borrow_mut() = None);
let (url, on_state) = (url2.clone(), on_state.clone());
spawn_local(async move {
TimeoutFuture::new(1000).await;
open(url, on_state);
});
});
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
onclose.forget();
SOCKET.with(|s| *s.borrow_mut() = Some(ws));
}
pub async fn get_json<T: DeserializeOwned>(url: &str) -> Option<T> {
Request::get(url).send().await.ok()?.json().await.ok()
@ -17,9 +66,17 @@ pub async fn post(url: &str, body: String) -> Option<(bool, String)> {
}
pub fn edit(cmd: String) {
spawn_local(async move {
let _ = post("/api/edit", cmd).await;
let sent = SOCKET.with(|s| {
s.borrow()
.as_ref()
.filter(|w| w.ready_state() == WebSocket::OPEN)
.is_some_and(|w| w.send_with_str(&cmd).is_ok())
});
if !sent {
spawn_local(async move {
let _ = post("/api/edit", cmd).await;
});
}
}
fn storage() -> Option<web_sys::Storage> {

View File

@ -3,7 +3,6 @@ mod canvas;
mod editor;
mod model;
use gloo_timers::future::TimeoutFuture;
use leptos::mount::mount_to_body;
use leptos::prelude::*;
use leptos::task::spawn_local;
@ -28,20 +27,22 @@ fn App() -> impl IntoView {
// (selection counter, people at the time of selection); the counter forces editor rows to rebuild.
let editing = RwSignal::new((0u64, Vec::<Person>::new()));
spawn_local(async move {
let mut rev = None;
loop {
if let Some(s) = api::get_json::<State>("/api/state").await {
if rev != Some(s.rev) || terrain.with_untracked(|t| t.is_none()) {
if let Some(t) = api::get_json::<Terrain>("/api/terrain").await {
terrain.set(Some(t));
rev = Some(s.rev);
}
// The server pushes a snapshot per tick; terrain is refetched only when its revision changes.
let last_rev = StoredValue::new(None::<u32>);
api::connect(move |text| {
let Ok(s) = serde_json::from_str::<State>(&text) else {
return;
};
if last_rev.get_value() != Some(s.rev) {
last_rev.set_value(Some(s.rev));
spawn_local(async move {
match api::get_json::<Terrain>("/api/terrain").await {
Some(t) => terrain.set(Some(t)),
None => last_rev.set_value(None),
}
state.set(Some(s));
}
TimeoutFuture::new(200).await;
});
}
state.set(Some(s));
});
let canvas = NodeRef::<leptos::html::Canvas>::new();