diff --git a/Cargo.lock b/Cargo.lock
index 26d8725..949ddf9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,37 +2,604 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anyhow"
+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 = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "cc"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+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 = "codespan-reporting"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+dependencies = [
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generator"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "log",
+ "rustversion",
+ "windows-link",
+ "windows-result",
+]
+
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+[[package]]
+name = "loom"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "musli"
+version = "0.0.124"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b310b280353d9e1c92861820321f8742b02666acaf984a29cd8946965444384"
+dependencies = [
+ "loom",
+ "musli-core",
+ "serde",
+ "simdutf8",
+]
+
+[[package]]
+name = "musli-core"
+version = "0.0.124"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d00e227a374e92550ce2eb5002ae116e02a43926d7243c95997138406ae4e157"
+dependencies = [
+ "musli-macros",
+]
+
+[[package]]
+name = "musli-macros"
+version = "0.0.124"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7427c9aa85c882cd4dbe712d2fcdc511db05d595f7787e6747c90cd7d67efc4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rune"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a577a0dc4b3e4cfe89247325b14b5f30096ddc82fa51fffb1d823fbc85b410"
+dependencies = [
+ "anyhow",
+ "codespan-reporting",
+ "futures-core",
+ "futures-util",
+ "itoa",
+ "memchr",
+ "musli",
+ "num",
+ "once_cell",
+ "pin-project",
+ "rune-alloc",
+ "rune-core",
+ "rune-macros",
+ "rune-tracing",
+ "ryu",
+ "serde",
+ "syntree",
+ "unicode-ident",
+]
+
+[[package]]
+name = "rune-alloc"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702abcb136e7e4ff61c29006f018ab535728388b950bfbd50a0a66d7a2db1ff6"
+dependencies = [
+ "ahash",
+ "pin-project",
+ "rune-alloc-macros",
+ "serde",
+]
+
+[[package]]
+name = "rune-alloc-macros"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98fa2ddc4ebb390a257f05659081e5f4a1419ca65f0c7a3f4b02547860dd93f4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "rune-core"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fab02420e2189ad2d8d9efceca5319782873eb547c61b68732746cc161547aa"
+dependencies = [
+ "musli",
+ "rune-alloc",
+ "serde",
+ "twox-hash",
+]
+
+[[package]]
+name = "rune-macros"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d314f34d8b30569189d90ac0f96eeedf629492c10d18146b40cfb76bd3fd2d52"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rune-core",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "rune-tracing"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5b903935dfcb0ab9eec395f4c2e04b174448bb8d1fd35ef5181ad67140239a9"
+dependencies = [
+ "rune-tracing-macros",
+]
+
+[[package]]
+name = "rune-tracing-macros"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75193c702bddd8a5b74b1f9a5a992cef3c6b2df2909d6f0bb0f4b0f3ec8a2f66"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
[[package]]
name = "sim"
version = "0.1.0"
dependencies = [
+ "rune",
"tiny_http",
]
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syntree"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00c99c9cda412afe293a6b962af651b4594161ba88c1affe7ef66459ea040a06"
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "tiny_http"
version = "0.12.0"
@@ -44,3 +611,135 @@ dependencies = [
"httpdate",
"log",
]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "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",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "twox-hash"
+version = "2.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[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.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 6f67156..311df0e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,4 +4,5 @@ version = "0.1.0"
edition = "2024"
[dependencies]
+rune = "0.14.2"
tiny_http = "0.12"
diff --git a/src/behavior.rn b/src/behavior.rn
new file mode 100644
index 0000000..d67ee71
--- /dev/null
+++ b/src/behavior.rn
@@ -0,0 +1,44 @@
+// Behaviour scripts. Each function receives what the entity senses and
+// returns the name of an action; the Rust side carries it out.
+
+// person: sleep | go_home | eat | haul | hunt | forage | chop | mine | wander
+pub fn person(c) {
+ if c.night {
+ return if c.at_home { "sleep" } else { "go_home" };
+ }
+ if c.hunger > 50.0 && c.carried_food == 0 && c.stock_food > 0 {
+ return "eat";
+ }
+ if c.carry >= 6 {
+ return "haul";
+ }
+ if c.stock_food < 12 || c.hunger > 50.0 {
+ if c.rabbit_near && (!c.berry_near || c.roll < 0.3) {
+ return "hunt";
+ }
+ if c.berry_near {
+ return "forage";
+ }
+ }
+ if c.stock_wood <= c.stock_stone * 2 + 8 {
+ if c.tree_near { "chop" } else if c.stone_near { "mine" } else { "wander" }
+ } else {
+ if c.stone_near { "mine" } else if c.tree_near { "chop" } else { "wander" }
+ }
+}
+
+// rabbit: flee | wander | stay
+pub fn rabbit(c) {
+ if c.wolf_near {
+ if c.roll < 0.8 { "flee" } else { "stay" }
+ } else if c.roll < 0.5 {
+ "wander"
+ } else {
+ "stay"
+ }
+}
+
+// wolf: hunt | wander
+pub fn wolf(c) {
+ if c.prey_near { "hunt" } else { "wander" }
+}
diff --git a/src/index.html b/src/index.html
index bea97a8..9667d11 100644
--- a/src/index.html
+++ b/src/index.html
@@ -14,11 +14,31 @@
}
#side {
- width: 280px;
+ width: 340px;
+ height: 100vh;
+ overflow-y: auto;
padding: 10px;
box-sizing: border-box;
}
+ #side input[type=number] {
+ width: 56px;
+ }
+
+ #script {
+ width: 100%;
+ height: 260px;
+ box-sizing: border-box;
+ background: #181818;
+ color: #ddd;
+ font: 12px monospace;
+ }
+
+ #scriptErr {
+ color: #f77;
+ white-space: pre-wrap;
+ }
+
canvas {
display: block;
}
@@ -40,8 +60,39 @@
World
+
+
+ Speed
+
+
Tool
+
+
+
+
+
+
Selected
click a tile or drag to select a region
+
+
Stockpile
+
+
Behaviour script (Rune)
+
+
+
Events
Legend
@@ -53,11 +104,12 @@
const COLORS = ['#10305e', '#2a62a8', '#e0d08a', '#6aaa4a', '#3f7d3a', '#8a8a6a', '#aaaaaa'];
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
- let terrain = null, off = null, state = null;
+ let terrain = null, off = null, state = null, terrainRev = -1;
async function loadTerrain() {
const t = await (await fetch('/api/terrain')).json();
terrain = t;
+ terrainRev = state ? state.rev : -1;
cv.width = t.w * T; cv.height = t.h * T;
off = document.createElement('canvas');
off.width = cv.width; off.height = cv.height;
@@ -105,6 +157,8 @@
async function poll() {
try {
state = await (await fetch('/api/state')).json();
+ if (terrain && state.rev !== terrainRev) await loadTerrain();
+ syncControls();
const s = state.stock, h = Math.floor(state.hour), m = Math.floor((state.hour % 1) * 60);
document.getElementById('hud').innerHTML =
`Day ${state.day}, ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}
` +
@@ -183,7 +237,92 @@
sel.x1 = t.x; sel.y1 = t.y;
draw(); showSelection();
});
- window.addEventListener('mouseup', () => { dragging = false; });
+ window.addEventListener('mouseup', () => {
+ if (!dragging) return;
+ dragging = false;
+ const cmd = toolCommand();
+ if (cmd) post('/api/edit', cmd);
+ buildEditor();
+ });
+
+ const $ = id => document.getElementById(id);
+ const post = (url, body) => fetch(url, { method: 'POST', body });
+
+ const TOOLS = {
+ inspect: { help: 'Click or drag to inspect and edit.', opts: null },
+ terrain: { help: 'Drag a region to paint it.', opts: TERRAIN.map((n, i) => [i, n]) },
+ res: { help: 'Drag a region; amount sets units per tile.', opts: [[1, 'Tree'], [2, 'Berry bush'], [3, 'Stone'], [0, 'Clear']] },
+ spawn: { help: 'Drag a region; one per walkable tile (max 25).', opts: [['person', 'Person'], ['rabbit', 'Rabbit'], ['wolf', 'Wolf']] },
+ remove: { help: 'Drag a region to remove things in it.', opts: [['all', 'Everything'], ['people', 'People'], ['rabbits', 'Rabbits'], ['wolves', 'Wolves'], ['houses', 'Houses'], ['res', 'Resources']] },
+ house: { help: 'Drag a region to add houses.', opts: null },
+ energy: { help: 'Drag a region; amount sets animal energy (1-100).', opts: null },
+ };
+
+ function toolCommand() {
+ const tool = $('tool').value;
+ if (tool === 'inspect' || !sel) return null;
+ const [x0, y0, x1, y1] = selRect();
+ const r = `${x0} ${y0} ${x1} ${y1}`, p = $('param').value, a = parseInt($('amount').value) || 1;
+ switch (tool) {
+ case 'terrain': return `terrain ${r} ${p}`;
+ case 'res': return `res ${r} ${p} ${a}`;
+ case 'spawn': return `spawn ${p} ${r}`;
+ case 'remove': return `remove ${r} ${p}`;
+ case 'house': return `house ${r}`;
+ case 'energy': return `energy ${r} ${a}`;
+ }
+ }
+
+ $('tool').addEventListener('change', () => {
+ const t = TOOLS[$('tool').value];
+ $('param').innerHTML = (t.opts || []).map(([v, n]) => `
`).join('');
+ $('param').style.display = t.opts ? '' : 'none';
+ $('toolHelp').textContent = t.help;
+ });
+ $('tool').dispatchEvent(new Event('change'));
+
+ $('pause').addEventListener('click', () => post('/api/edit', `pause ${state && state.paused ? 0 : 1}`));
+ $('speed').addEventListener('change', () => post('/api/edit', `speed ${$('speed').value}`));
+
+ let stockBuilt = false;
+ function syncControls() {
+ $('pause').textContent = state.paused ? 'Resume' : 'Pause';
+ if (document.activeElement !== $('speed')) $('speed').value = state.speed;
+ if (!stockBuilt) {
+ stockBuilt = true;
+ $('stockEdit').innerHTML = ['food', 'wood', 'stone', 'tools'].map(k =>
+ `${k}
`).join('') +
+ '
';
+ $('stockSet').addEventListener('click', () => {
+ for (const k of ['food', 'wood', 'stone', 'tools'])
+ post('/api/edit', `stock ${k} ${parseInt($('stock_' + k).value) || 0}`);
+ });
+ }
+ }
+
+ function buildEditor() {
+ const el = $('editor');
+ el.innerHTML = '';
+ if (!sel || !state) return;
+ const [x0, y0, x1, y1] = selRect();
+ const people = state.people.filter(p => p[1] >= x0 && p[1] <= x1 && p[2] >= y0 && p[2] <= y1).slice(0, 5);
+ for (const p of people) {
+ const row = document.createElement('div');
+ row.innerHTML = `Edit #${p[0]}: ` + [['hunger', p[4]], ['food', p[5]], ['wood', p[6]], ['stone', p[7]]].map(([k, v]) =>
+ `${k}
`).join(' ') + '
';
+ row.querySelector('button').addEventListener('click', () => {
+ for (const i of row.querySelectorAll('input'))
+ post('/api/edit', `person ${p[0]} ${i.dataset.k} ${parseInt(i.value) || 0}`);
+ });
+ el.appendChild(row);
+ }
+ }
+
+ fetch('/api/script').then(r => r.text()).then(t => { $('script').value = t; });
+ $('applyScript').addEventListener('click', async () => {
+ const r = await post('/api/script', $('script').value);
+ $('scriptErr').textContent = r.ok ? 'Script applied.' : await r.text();
+ });
loadTerrain().then(poll);
diff --git a/src/main.rs b/src/main.rs
index c11797c..c2f2b98 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,26 +1,63 @@
mod world;
+use std::io::Read;
+use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
-use tiny_http::{Header, Response, Server};
-use world::World;
+use tiny_http::{Header, Method, Response, Server};
+use world::{DEFAULT_SCRIPT, World};
const INDEX: &str = include_str!("index.html");
+enum Msg {
+ Edit(String),
+ Script(String, Sender
>),
+}
+
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());
- let world = Arc::new(Mutex::new(World::new(seed)));
-
+ // 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 script = Arc::new(Mutex::new(DEFAULT_SCRIPT.to_string()));
+ let (tx, rx) = mpsc::channel::();
{
- let world = Arc::clone(&world);
+ 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 {
- world.lock().unwrap().step();
+ 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));
}
});
@@ -28,20 +65,51 @@ fn main() {
let server = Server::http(&addr).expect("failed to bind");
println!("Simulation running at http://{addr}");
- for req in server.incoming_requests() {
- let world = Arc::clone(&world);
+ for mut req in server.incoming_requests() {
+ let (terrain, state, script) = (
+ Arc::clone(&terrain),
+ Arc::clone(&state),
+ Arc::clone(&script),
+ );
+ let tx = tx.clone();
thread::spawn(move || {
- let (body, ctype) = match req.url() {
- "/" => (INDEX.to_string(), "text/html; charset=utf-8"),
- "/api/terrain" => (world.lock().unwrap().terrain_json(), "application/json"),
- "/api/state" => (world.lock().unwrap().state_json(), "application/json"),
- _ => {
- let _ = req.respond(Response::from_string("not found").with_status_code(404));
- return;
+ let post = *req.method() == Method::Post;
+ 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_header(header));
+ let _ = req.respond(
+ Response::from_string(body)
+ .with_status_code(status)
+ .with_header(header),
+ );
});
}
}
diff --git a/src/world.rs b/src/world.rs
index 18b8773..fda2786 100644
--- a/src/world.rs
+++ b/src/world.rs
@@ -1,4 +1,7 @@
+use rune::termcolor::Buffer;
+use rune::{Any, Context, Diagnostics, Module, Source, Sources, Vm};
use std::fmt::Write;
+use std::sync::Arc;
pub const W: usize = 96;
pub const H: usize = 64;
@@ -73,6 +76,77 @@ fn fbm(x: f32, y: f32, seed: u64) -> f32 {
sum / norm
}
+/// What an entity senses this tick; passed to the Rune behaviour scripts.
+#[derive(Any, Default, Clone)]
+struct Ctx {
+ #[rune(get)]
+ night: bool,
+ #[rune(get)]
+ at_home: bool,
+ #[rune(get)]
+ hunger: f64,
+ #[rune(get)]
+ carry: i64,
+ #[rune(get)]
+ carried_food: i64,
+ #[rune(get)]
+ stock_food: i64,
+ #[rune(get)]
+ stock_wood: i64,
+ #[rune(get)]
+ stock_stone: i64,
+ #[rune(get)]
+ berry_near: bool,
+ #[rune(get)]
+ tree_near: bool,
+ #[rune(get)]
+ stone_near: bool,
+ #[rune(get)]
+ rabbit_near: bool,
+ #[rune(get)]
+ wolf_near: bool,
+ #[rune(get)]
+ prey_near: bool,
+ #[rune(get)]
+ roll: f64,
+}
+
+pub const DEFAULT_SCRIPT: &str = include_str!("behavior.rn");
+
+/// Compiles the script and smoke-tests every entry point so errors surface at load time.
+fn load_script(src: &str) -> Result {
+ let mut module = Module::new();
+ module.ty::().map_err(|e| e.to_string())?;
+ let mut context = Context::with_default_modules().map_err(|e| e.to_string())?;
+ context.install(module).map_err(|e| e.to_string())?;
+ let mut sources = Sources::new();
+ sources
+ .insert(Source::new("behavior", src).map_err(|e| e.to_string())?)
+ .map_err(|e| e.to_string())?;
+ let mut diagnostics = Diagnostics::new();
+ let unit = rune::prepare(&mut sources)
+ .with_context(&context)
+ .with_diagnostics(&mut diagnostics)
+ .build();
+ let unit = match unit {
+ Ok(u) => u,
+ Err(_) => {
+ let mut buf = Buffer::no_color();
+ let _ = diagnostics.emit(&mut buf, &sources);
+ return Err(String::from_utf8_lossy(buf.as_slice()).into_owned());
+ }
+ };
+ let runtime = context.runtime().map_err(|e| e.to_string())?;
+ let mut vm = Vm::new(Arc::new(runtime), Arc::new(unit));
+ for f in ["person", "rabbit", "wolf"] {
+ let v = vm
+ .call([f], (Ctx::default(),))
+ .map_err(|e| format!("{f}(): {e}"))?;
+ rune::from_value::(v).map_err(|_| format!("{f}() must return a string"))?;
+ }
+ Ok(vm)
+}
+
#[derive(Clone)]
pub struct Person {
id: u32,
@@ -116,6 +190,10 @@ pub struct World {
tick: u64,
next_id: u32,
log: Vec,
+ vm: Vm,
+ pub paused: bool,
+ pub speed: u32,
+ pub terrain_rev: u32,
}
fn cheb(a: (usize, usize), b: (usize, usize)) -> usize {
@@ -163,6 +241,10 @@ impl World {
tick: DAY / 4,
next_id: 1,
log: vec![],
+ vm: load_script(DEFAULT_SCRIPT).expect("behavior.rn failed to load"),
+ paused: false,
+ speed: 1,
+ terrain_rev: 0,
};
w.home = w
.find_land(GRASS)
@@ -393,11 +475,22 @@ impl World {
}
}
+ fn decide(&mut self, func: &str, c: Ctx) -> String {
+ match self.vm.call([func], (c,)) {
+ Ok(v) => rune::from_value::(v).unwrap_or_else(|_| "wander".into()),
+ Err(e) => {
+ eprintln!("script error in {func}: {e}");
+ "wander".into()
+ }
+ }
+ }
+
fn tick_person(&mut self, i: usize) {
let mut p = self.people[i].clone();
let night = self.daylight() < 0.2;
let pos = (p.x, p.y);
- let at_home = cheb(pos, self.home) <= 1;
+ let home = self.home;
+ let at_home = cheb(pos, home) <= 1;
p.hunger += if night && at_home { 0.04 } else { 0.1 };
if p.hunger >= 100.0 {
p.dead = true;
@@ -416,107 +509,80 @@ impl World {
self.food -= 1;
p.hunger = (p.hunger - 25.0).max(0.0);
}
- if self.wood >= 3 && self.stone >= 1 && self.tools < self.people.len() as u32 {
- self.wood -= 3;
- self.stone -= 1;
- self.tools += 1;
- }
- if self.wood >= 12 && self.stone >= 4 && self.houses.len() < 10 {
- let (hx, hy) = self.home;
- let x = (hx as i32 + self.rng.below(13) as i32 - 6).clamp(0, W as i32 - 1);
- let y = (hy as i32 + self.rng.below(13) as i32 - 6).clamp(0, H as i32 - 1);
- if self.walkable(x, y)
- && self.res[y as usize * W + x as usize].0 == 0
- && !self.houses.contains(&(x as usize, y as usize))
- {
- self.wood -= 12;
- self.stone -= 4;
- self.houses.push((x as usize, y as usize));
- self.say("A new house was built".into());
- }
- }
+ self.craft_and_build();
}
if p.hunger > 50.0 && p.food > 0 {
p.food -= 1;
p.hunger = (p.hunger - 25.0).max(0.0);
}
- let home = self.home;
- let yield_n = if self.tools >= self.people.len() as u32 {
+ let berry = self.nearest_res(pos, BERRY, 25);
+ let tree = self.nearest_res(pos, TREE, 25);
+ let stone = self.nearest_res(pos, STONE, 25);
+ let rabbit = Self::nearest_animal(&self.rabbits, pos, 25);
+ let c = Ctx {
+ night,
+ at_home,
+ hunger: p.hunger as f64,
+ carry: p.carry() as i64,
+ carried_food: p.food as i64,
+ stock_food: self.food as i64,
+ stock_wood: self.wood as i64,
+ stock_stone: self.stone as i64,
+ berry_near: berry.is_some(),
+ tree_near: tree.is_some(),
+ stone_near: stone.is_some(),
+ rabbit_near: rabbit.is_some(),
+ roll: self.rng.f() as f64,
+ ..Default::default()
+ };
+ let choice = self.decide("person", c);
+
+ let yield_n: u16 = if self.tools >= self.people.len() as u32 {
2
} else {
1
};
- let mut target: Option<(usize, usize)> = None;
- let mut gather: Option = None;
- let mut hunt = false;
-
- if night {
- if at_home {
- p.action = "sleeping";
- } else {
- p.action = "going home";
- target = Some(home);
- }
- } else if p.hunger > 50.0 && p.food == 0 && self.food > 0 {
- p.action = "going to eat";
- target = Some(home);
- } else if p.carry() >= 6 {
- p.action = "hauling goods";
- target = Some(home);
- } else {
- let order: [u8; 3] = if self.food < 12 || p.hunger > 50.0 {
- [BERRY, TREE, STONE]
- } else if self.wood <= self.stone * 2 + 8 {
- [TREE, STONE, BERRY]
- } else {
- [STONE, TREE, BERRY]
- };
- for k in order {
- if let Some(t) = self.nearest_res(pos, k, 25) {
- target = Some(t);
- gather = Some(k);
- p.action = match k {
- BERRY => "foraging",
- TREE => "chopping wood",
- _ => "mining stone",
- };
- break;
+ let gather = match choice.as_str() {
+ "forage" => Some((BERRY, berry, "foraging")),
+ "chop" => Some((TREE, tree, "chopping wood")),
+ "mine" => Some((STONE, stone, "mining stone")),
+ _ => None,
+ };
+ match (choice.as_str(), gather, rabbit) {
+ ("sleep", ..) => p.action = "sleeping",
+ ("go_home" | "haul" | "eat", ..) => {
+ p.action = match choice.as_str() {
+ "go_home" => "going home",
+ "haul" => "hauling goods",
+ _ => "going to eat",
+ };
+ if !at_home {
+ (p.x, p.y) = self.move_toward(pos, home);
}
}
- if (self.food < 12 || p.hunger > 50.0)
- && (gather != Some(BERRY) || self.rng.chance(0.3))
- {
- if let Some(r) = Self::nearest_animal(&self.rabbits, pos, 25) {
- target = Some((self.rabbits[r].x, self.rabbits[r].y));
- hunt = true;
- gather = None;
- p.action = "hunting";
- }
- }
- }
-
- if let Some(t) = target {
- if hunt {
+ ("hunt", _, Some(r)) => {
+ p.action = "hunting";
+ let t = (self.rabbits[r].x, self.rabbits[r].y);
if cheb(pos, t) <= 1 {
- if let Some(r) = Self::nearest_animal(&self.rabbits, pos, 1) {
- if self.rng.chance(0.5) {
- self.rabbits[r].dead = true;
- p.food += 3;
- }
+ if self.rng.chance(0.5) {
+ self.rabbits[r].dead = true;
+ p.food += 3;
}
} else {
(p.x, p.y) = self.move_toward(pos, t);
}
- } else if let Some(k) = gather {
+ }
+ (_, Some((kind, Some(t), label)), _) => {
+ p.action = label;
let idx = pos.1 * W + pos.0;
- if pos == t && self.res[idx].0 == k {
- let take = (yield_n as u16).min(self.res[idx].1);
+ if pos == t && self.res[idx].0 == kind {
+ let take = yield_n.min(self.res[idx].1);
self.res[idx].1 -= take;
if self.res[idx].1 == 0 {
self.res[idx].0 = 0;
}
- match k {
+ match kind {
BERRY => p.food += take as u32,
TREE => p.wood += take as u32,
_ => p.stone += take as u32,
@@ -524,16 +590,37 @@ impl World {
} else {
(p.x, p.y) = self.move_toward(pos, t);
}
- } else if !at_home || p.action == "going to eat" && cheb(pos, t) > 1 {
- (p.x, p.y) = self.move_toward(pos, t);
}
- } else if p.action != "sleeping" {
- p.action = "wandering";
- (p.x, p.y) = self.wander(p.x, p.y);
+ _ => {
+ p.action = "wandering";
+ (p.x, p.y) = self.wander(p.x, p.y);
+ }
}
self.people[i] = p;
}
+ fn craft_and_build(&mut self) {
+ if self.wood >= 3 && self.stone >= 1 && self.tools < self.people.len() as u32 {
+ self.wood -= 3;
+ self.stone -= 1;
+ self.tools += 1;
+ }
+ if self.wood >= 12 && self.stone >= 4 && self.houses.len() < 10 {
+ let (hx, hy) = self.home;
+ let x = (hx as i32 + self.rng.below(13) as i32 - 6).clamp(0, W as i32 - 1);
+ let y = (hy as i32 + self.rng.below(13) as i32 - 6).clamp(0, H as i32 - 1);
+ if self.walkable(x, y)
+ && self.res[y as usize * W + x as usize].0 == 0
+ && !self.houses.contains(&(x as usize, y as usize))
+ {
+ self.wood -= 12;
+ self.stone -= 4;
+ self.houses.push((x as usize, y as usize));
+ self.say("A new house was built".into());
+ }
+ }
+ }
+
fn tick_rabbit(&mut self, i: usize) {
let mut a = self.rabbits[i].clone();
a.energy -= 0.05;
@@ -541,14 +628,20 @@ impl World {
a.energy = (a.energy + 0.2).min(100.0);
}
let pos = (a.x, a.y);
- if let Some(w) = Self::nearest_animal(&self.wolves, pos, 4) {
- if self.rng.chance(0.8) {
+ let wolf = Self::nearest_animal(&self.wolves, pos, 4);
+ let c = Ctx {
+ wolf_near: wolf.is_some(),
+ roll: self.rng.f() as f64,
+ ..Default::default()
+ };
+ match (self.decide("rabbit", c).as_str(), wolf) {
+ ("flee", Some(w)) => {
let dx = (a.x as i32 - self.wolves[w].x as i32).signum();
let dy = (a.y as i32 - self.wolves[w].y as i32).signum();
(a.x, a.y) = self.move_dir(a.x, a.y, dx, dy);
}
- } else if self.rng.chance(0.5) {
- (a.x, a.y) = self.wander(a.x, a.y);
+ ("wander", _) => (a.x, a.y) = self.wander(a.x, a.y),
+ _ => {}
}
if a.energy > 80.0 && self.rabbits.len() < 120 && self.rng.chance(0.01) {
a.energy -= 30.0;
@@ -569,18 +662,24 @@ impl World {
let mut a = self.wolves[i].clone();
a.energy -= 0.15;
let pos = (a.x, a.y);
- if let Some(r) = Self::nearest_animal(&self.rabbits, pos, 10) {
- let t = (self.rabbits[r].x, self.rabbits[r].y);
- if cheb(pos, t) <= 1 {
- if self.rng.chance(0.4) {
- self.rabbits[r].dead = true;
- a.energy = (a.energy + 50.0).min(100.0);
+ let prey = Self::nearest_animal(&self.rabbits, pos, 10);
+ let c = Ctx {
+ prey_near: prey.is_some(),
+ ..Default::default()
+ };
+ match (self.decide("wolf", c).as_str(), prey) {
+ ("hunt", Some(r)) => {
+ let t = (self.rabbits[r].x, self.rabbits[r].y);
+ if cheb(pos, t) <= 1 {
+ if self.rng.chance(0.4) {
+ self.rabbits[r].dead = true;
+ a.energy = (a.energy + 50.0).min(100.0);
+ }
+ } else {
+ (a.x, a.y) = self.move_toward(pos, t);
}
- } else {
- (a.x, a.y) = self.move_toward(pos, t);
}
- } else {
- (a.x, a.y) = self.wander(a.x, a.y);
+ _ => (a.x, a.y) = self.wander(a.x, a.y),
}
if a.energy > 90.0 && self.wolves.len() < 10 && self.rng.chance(0.02) {
a.energy -= 40.0;
@@ -597,6 +696,202 @@ impl World {
self.wolves[i] = a;
}
+ pub fn set_script(&mut self, src: &str) -> Result<(), String> {
+ self.vm = load_script(src)?;
+ Ok(())
+ }
+
+ fn rect(t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> {
+ let n: Vec = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect();
+ if n.len() != 4 {
+ return None;
+ }
+ let c = |v: i64, max: usize| v.clamp(0, max as i64 - 1) as usize;
+ let (x0, x1) = (c(n[0].min(n[2]), W), c(n[0].max(n[2]), W));
+ let (y0, y1) = (c(n[1].min(n[3]), H), c(n[1].max(n[3]), H));
+ Some((x0, y0, x1, y1))
+ }
+
+ /// Applies one whitespace-separated edit command; malformed commands are ignored.
+ pub fn apply(&mut self, cmd: &str) {
+ let t: Vec<&str> = cmd.split_whitespace().collect();
+ let num = |i: usize| t.get(i).and_then(|s| s.parse::().ok());
+ match t.first().copied() {
+ Some("pause") => self.paused = num(1) != Some(0),
+ Some("speed") => {
+ if let Some(n) = num(1) {
+ self.speed = n.clamp(1, 8) as u32;
+ }
+ }
+ Some("stock") => {
+ if let (Some(name), Some(v)) = (t.get(1), num(2)) {
+ let v = v.clamp(0, 9999) as u32;
+ match *name {
+ "food" => self.food = v,
+ "wood" => self.wood = v,
+ "stone" => self.stone = v,
+ "tools" => self.tools = v,
+ _ => {}
+ }
+ }
+ }
+ Some("person") => {
+ if let (Some(id), Some(field), Some(v)) = (num(1), t.get(2), num(3)) {
+ if let Some(p) = self.people.iter_mut().find(|p| p.id as i64 == id) {
+ let u = v.clamp(0, 99) as u32;
+ match *field {
+ "hunger" => p.hunger = v.clamp(0, 99) as f32,
+ "food" => p.food = u,
+ "wood" => p.wood = u,
+ "stone" => p.stone = u,
+ _ => {}
+ }
+ }
+ }
+ }
+ Some("energy") => {
+ if let (Some((x0, y0, x1, y1)), Some(v)) = (Self::rect(&t, 1), num(5)) {
+ let v = v.clamp(1, 100) as f32;
+ let inside = |a: &Animal| a.x >= x0 && a.x <= x1 && a.y >= y0 && a.y <= y1;
+ for a in self.rabbits.iter_mut().chain(self.wolves.iter_mut()) {
+ if inside(a) {
+ a.energy = v;
+ }
+ }
+ }
+ }
+ Some("terrain") => {
+ if let (Some((x0, y0, x1, y1)), Some(kind)) = (Self::rect(&t, 1), num(5)) {
+ if (0..=6).contains(&kind) {
+ self.paint_terrain(x0, y0, x1, y1, kind as u8);
+ }
+ }
+ }
+ Some("res") => {
+ if let (Some((x0, y0, x1, y1)), Some(kind), Some(amt)) =
+ (Self::rect(&t, 1), num(5), num(6))
+ {
+ if (0..=3).contains(&kind) {
+ let amt = amt.clamp(1, 50) as u16;
+ for y in y0..=y1 {
+ for x in x0..=x1 {
+ let i = y * W + x;
+ let free = self.walkable(x as i32, y as i32)
+ && (x, y) != self.home
+ && !self.houses.contains(&(x, y));
+ if kind == 0 {
+ self.res[i] = (0, 0);
+ } else if free {
+ self.res[i] = (kind as u8, amt);
+ }
+ }
+ }
+ }
+ }
+ }
+ Some("spawn") => {
+ if let (Some(what), Some((x0, y0, x1, y1))) = (t.get(1), Self::rect(&t, 2)) {
+ let mut left = 25;
+ for y in y0..=y1 {
+ for x in x0..=x1 {
+ if left == 0 || !self.walkable(x as i32, y as i32) {
+ continue;
+ }
+ left -= 1;
+ let a = Animal {
+ x,
+ y,
+ energy: 70.0,
+ dead: false,
+ };
+ match *what {
+ "person" => {
+ self.spawn_person();
+ let p = self.people.last_mut().unwrap();
+ (p.x, p.y) = (x, y);
+ }
+ "rabbit" => self.rabbits.push(a),
+ "wolf" => self.wolves.push(a),
+ _ => left += 1,
+ }
+ }
+ }
+ }
+ }
+ Some("house") => {
+ if let Some((x0, y0, x1, y1)) = Self::rect(&t, 1) {
+ for y in y0..=y1 {
+ for x in x0..=x1 {
+ if self.houses.len() < 30
+ && self.walkable(x as i32, y as i32)
+ && self.res[y * W + x].0 == 0
+ && (x, y) != self.home
+ && !self.houses.contains(&(x, y))
+ {
+ self.houses.push((x, y));
+ }
+ }
+ }
+ }
+ }
+ Some("remove") => {
+ if let (Some((x0, y0, x1, y1)), Some(what)) = (Self::rect(&t, 1), t.get(5)) {
+ let inside = |x: usize, y: usize| x >= x0 && x <= x1 && y >= y0 && y <= y1;
+ let all = *what == "all";
+ if all || *what == "people" {
+ self.people.retain(|p| !inside(p.x, p.y));
+ }
+ if all || *what == "rabbits" {
+ self.rabbits.retain(|a| !inside(a.x, a.y));
+ }
+ if all || *what == "wolves" {
+ self.wolves.retain(|a| !inside(a.x, a.y));
+ }
+ if all || *what == "houses" {
+ self.houses.retain(|h| !inside(h.0, h.1));
+ }
+ if all || *what == "res" {
+ for y in y0..=y1 {
+ for x in x0..=x1 {
+ self.res[y * W + x] = (0, 0);
+ }
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn paint_terrain(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, kind: u8) {
+ let land = matches!(kind, SAND | GRASS | FOREST | HILLS);
+ for y in y0..=y1 {
+ for x in x0..=x1 {
+ if !land && (x, y) == self.home {
+ continue;
+ }
+ self.tiles[y * W + x] = kind;
+ if !land {
+ self.res[y * W + x] = (0, 0);
+ self.houses.retain(|h| *h != (x, y));
+ }
+ }
+ }
+ if !land {
+ let ok = |tiles: &[u8], x: usize, y: usize| {
+ matches!(tiles[y * W + x], SAND | GRASS | FOREST | HILLS)
+ };
+ let tiles = self.tiles.clone();
+ self.rabbits.retain(|a| ok(&tiles, a.x, a.y));
+ self.wolves.retain(|a| ok(&tiles, a.x, a.y));
+ let home = self.home;
+ for p in self.people.iter_mut().filter(|p| !ok(&tiles, p.x, p.y)) {
+ (p.x, p.y) = home;
+ }
+ }
+ self.terrain_rev += 1;
+ }
+
pub fn terrain_json(&self) -> String {
let tiles: String = self.tiles.iter().map(|t| (b'0' + t) as char).collect();
format!("{{\"w\":{},\"h\":{},\"tiles\":\"{}\"}}", W, H, tiles)
@@ -606,7 +901,10 @@ impl World {
let mut s = String::new();
let _ = write!(
s,
- "{{\"tick\":{},\"day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],",
+ "{{\"paused\":{},\"speed\":{},\"rev\":{},\"tick\":{},\"day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],",
+ self.paused,
+ self.speed,
+ self.terrain_rev,
self.tick,
self.tick / DAY + 1,
(self.tick % DAY) as f32 / DAY as f32 * 24.0,