From dd02a16d36e121687f218a18af863eb9f53b2258 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Wed, 7 Oct 2026 18:42:59 +0200 Subject: [PATCH] feat: drive agent behavior with Rune scripts --- Cargo.lock | 699 ++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/behavior.rn | 44 +++ src/index.html | 145 +++++++++- src/main.rs | 100 +++++-- src/world.rs | 498 +++++++++++++++++++++++++++------- 6 files changed, 1368 insertions(+), 119 deletions(-) create mode 100644 src/behavior.rn 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,