From d17668f3b24f98ce2f46cd78e91971527f8b582c Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 18:57:36 +0100 Subject: [PATCH] Wait and both kinds of until loops; blocks run directly Script runs are resumable threads saved with the game; Rhai is gone. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +- Cargo.lock | 321 +--------------------- Cargo.toml | 1 - docs/MISSION_SCRIPTING.md | 40 ++- lang/de.lang | 7 + lang/en.lang | 7 + lang/es.lang | 7 + lang/it.lang | 7 + lang/ru.lang | 7 + src/blocks.rs | 169 ++---------- src/editor/blocks_ui.rs | 11 +- src/editor/templates.rs | 28 +- src/mscript.rs | 540 +++++++++++++++++++++++++------------- 13 files changed, 466 insertions(+), 686 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 015613e..c0d7e23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,11 +69,10 @@ But before committing a completed change, the full local checks should pass unle ## Campaign scripting conventions - Mission scripts are **blocks** (`src/blocks.rs`), edited in the in-game block editor (`src/editor/blocks_ui.rs`); players never see code. -- Blocks are translated into Rhai internally and run by `src/mscript.rs` inside the fixed simulation step (deterministic, co-op safe). -- The Rust Rhai dependency must keep `no_time` enabled in `Cargo.toml` so the WASM output stays loadable by the macroquad JS bundle. -- Rhai reserves `spawn`; the internal API uses `spawn_unit`. +- The blocks are run directly by the interpreter in `src/mscript.rs` inside the fixed simulation step (deterministic, co-op safe); there is no scripting language underneath (Rhai was removed when "wait" came). +- Script runs are threads (resumable after "wait", saved with the game); loops have a step budget per check so nothing can hang. - Per mission: special scripts (start, victory check, defeat check, clock) and triggers (`TriggerKind` in `src/campaign_doc.rs`). -- New block kinds need: the `Block`/`Val` variant, `template`/`kind_name`, palette entry, checking, Rhai translation, the API function in `mscript.rs`, the editor fields in `blocks_ui.rs`, `blk.*` texts in every language file, and a test. +- New block kinds need: the `Block`/`Val` variant, `template`/`kind_name`, palette entry, checking (`blocks::check`, `vars_set`), execution in `mscript.rs` (`exec`/`run_thread`/`eval`), nesting in `list_at` (mscript) and `list_mut` (blocks_ui) if it has a body, the editor fields in `blocks_ui.rs`, `blk.*` texts in every language file, and a test. - Side numbers: -1 any, 0 player, 1 heroes, 2 Mossgrim, 3 Lady Nightwell, 4 rival 3. - Unknown creature names, zones, triggers and unset variables are reported by `blocks::check`. - Reference: `docs/CAMPAIGN_EDITOR.md` and `docs/MISSION_SCRIPTING.md`. diff --git a/Cargo.lock b/Cargo.lock index 187e071..a422ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,19 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "const-random", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -39,18 +26,6 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - [[package]] name = "bytemuck" version = "1.25.2" @@ -63,15 +38,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cfg-if" version = "1.0.5" @@ -84,39 +50,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" -[[package]] -name = "compact_str" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "static_assertions", - "zmij", -] - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom", - "once_cell", - "tiny-keccak", -] - [[package]] name = "core_maths" version = "0.1.1" @@ -135,18 +68,11 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "dungeoncraft" version = "0.1.0" dependencies = [ "macroquad", - "rhai", "serde", "serde_json", ] @@ -193,41 +119,6 @@ dependencies = [ "ttf-parser", ] -[[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 = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "glam" version = "0.27.0" @@ -264,17 +155,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - [[package]] name = "libc" version = "0.2.191" @@ -378,40 +258,19 @@ dependencies = [ "malloc_buf", ] -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - [[package]] name = "png" version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] -[[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" @@ -436,41 +295,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rhai" -version = "1.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3925b784c0bee6542637e425ed7511a63257bf11b7ecf51ffd845e2d24d9163" -dependencies = [ - "ahash", - "bitflags 2.13.2", - "compact_str", - "num-traits", - "once_cell", - "rhai_codegen", - "smallvec", - "smartstring", - "thin-vec", - "web-time", -] - -[[package]] -name = "rhai_codegen" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d9661edb4a4b8145b017582d634c5eb88ff1a5284a5aa701f83a28f0657a826" -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 = "serde" version = "1.0.229" @@ -498,7 +322,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -520,46 +344,6 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" - -[[package]] -name = "smartstring" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" -dependencies = [ - "autocfg", - "static_assertions", - "version_check", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[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.9" @@ -571,21 +355,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thin-vec" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" - -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "ttf-parser" version = "0.25.1" @@ -601,72 +370,6 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" -dependencies = [ - "cfg-if", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.9", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "winapi" version = "0.3.9" @@ -689,26 +392,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "zerocopy" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 9514c4e..9e8e3f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ license = "MIT" [dependencies] macroquad = { version = "0.4" } -rhai = { version = "1.27.0", default-features = false, features = ["std", "no_time"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/docs/MISSION_SCRIPTING.md b/docs/MISSION_SCRIPTING.md index fd4495a..1b541df 100644 --- a/docs/MISSION_SCRIPTING.md +++ b/docs/MISSION_SCRIPTING.md @@ -1,12 +1,12 @@ # Mission scripting with blocks Missions of editor campaigns are scripted with **blocks** (like Blockly): coloured puzzle pieces -put together in the block editor (see [CAMPAIGN_EDITOR.md](CAMPAIGN_EDITOR.md)). Players never see -code – internally the blocks are translated into Rhai, compiled once per mission and run inside the -game's fixed simulation step, so a co-op match runs them identically on every computer. +put together in the block editor (see [CAMPAIGN_EDITOR.md](CAMPAIGN_EDITOR.md)). There is no code: +the game runs the blocks directly, inside its fixed simulation step, so a co-op match runs them +identically on every computer. -Code: `src/blocks.rs` (blocks, values, checking, translation), `src/mscript.rs` (run time, triggers, -zones), `src/campaign_doc.rs` (`MissionScripts`, `TriggerDoc`, `TriggerKind`). +Code: `src/blocks.rs` (blocks, values, checking), `src/mscript.rs` (run time, triggers, zones), +`src/campaign_doc.rs` (`MissionScripts`, `TriggerDoc`, `TriggerKind`). ## Scripts of a mission @@ -64,6 +64,9 @@ creatures and heroes. | | Gold + / Mana + | amount (negative takes) | | Logic | If / else | condition; blocks inside | | | Repeat | times (0–100); blocks inside | +| | Repeat until | condition, checked **before** each run (already true: the inside never runs); blocks inside | +| | Repeat, then check: until | condition, checked **after** each run (the inside runs at least once); blocks inside | +| | Wait | seconds – the script pauses here and goes on with the next block later (also inside loops and "if") | | | Switch trigger | trigger, on/off | | Variables | Set | variable, value | | | Change | variable, value to add | @@ -91,28 +94,21 @@ campaign file check (`CampaignDoc::validate`). ## At run time (`src/mscript.rs`) -- The mission's program is generated by `mscript::program` – one Rhai function per script and - trigger (`s_start`, `s_victory`, `s_defeat`, `s_tick`, `t_0`, `t_1`, ...) – and compiled once. -- Scripts read the game (`m.gold()`, `m.count(side, kind)`, `m.in_zone(zone, side, kind)`, ...) - and collect actions (`m.message`, `m.spawn_unit`, `m.terrain`, `m.win`, ...), which are applied - after the call. Variables, the random state, fired triggers and zone contents are part of - `ScriptState` and saved with the game. +- Every run of a script (the start, each second's checks, each fired trigger) is a **thread**: the + lists of blocks it is in and where. *Wait* parks a thread until its time; threads are saved with + the game, so a waiting script goes on after loading. +- The victory check, defeat check and clock start every second, but not while their previous run is + still waiting. A trigger that fires again while its last run waits starts a second run. +- Loops without a *Wait* run up to 2000 blocks per check (ten checks a second) and then go on at the + next check – a script can never hang the game. +- Blocks act at once, in order: a *Count* after a *Spawn* already sees the new units. Variables, the + random state, fired triggers, zone contents and waiting threads are part of `ScriptState`. - Scripts always run from the first player's side; messages reach every player of a match. -- A script error stops nothing else: it is shown once as a message and written to the test log. - Test play shows the variables and the trigger log at the top right, and the zones on the map. -### Rhai API (internal) - -`gold() mana() time() get(name) set(name, v) count(side, kind) in_zone(zone, side, kind) -random(a, b) div(a, b) times(n) event_is(kind) message(t) objective(t) hint(t) -spawn_unit(side, kind, count, level, place, zone, name) wave(n) add_gold(n) add_mana(n) win() lose() -reveal(zone) camera(zone) terrain(zone, kind) kill(zone, side) trigger(name, on)` – numbers are -floats; `spawn` is a reserved word in Rhai, hence `spawn_unit`. The Rhai dependency keeps the -`no_time` feature (WASM). - ## The demo *Open the demo* in the campaign editor: mission 1 ("The Trial of Triggers") uses all 14 trigger -kinds, all special scripts and nearly every block on a painted map with five zones (rectangle, +kinds, all special scripts and every block (including both kinds of *until* loops and *Wait*) on a painted map with five zones (rectangle, circle, line, tiles); mission 2 is a random map behind a branch. Play it with *Test play* and watch the debug view. diff --git a/lang/de.lang b/lang/de.lang index 8e639d1..d3a10a9 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -1331,3 +1331,10 @@ demo.m2.title = Neu gewürfelt demo.m2.story = Diese Karte wird für jeden Durchgang der Kampagne neu gewürfelt. Besiege alle Helden. demo.m2.start = Eine Zufallskarte. Selbst ich habe sie noch nie gesehen. demo.m2.wave = Zwei Minuten sind um: Die Helden kommen. +blk.until = Wiederhole bis +blk.dountil = Wiederhole, prüfe danach: bis +blk.wait = Warte +blk.w_seconds = Sekunden +blk.help.until = Führt die Blöcke darin immer wieder aus, bis die Bedingung gilt - geprüft vor jedem Durchlauf; gilt sie schon, läuft der Inhalt gar nicht. +blk.help.dountil = Führt die Blöcke darin aus, prüft dann die Bedingung und wiederholt, bis sie gilt - der Inhalt läuft mindestens einmal. +blk.help.wait = Hält dieses Skript einige Sekunden an, danach geht es mit dem nächsten Block weiter (auch in Schleifen). diff --git a/lang/en.lang b/lang/en.lang index 3130563..4bbd642 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -1332,3 +1332,10 @@ demo.m2.title = Rolled Anew demo.m2.story = This map is rolled anew for every run of the campaign. Defeat every hero. demo.m2.start = A random map. Even I have never seen it before. demo.m2.wave = Two minutes are over: the heroes come. +blk.until = Repeat until +blk.dountil = Repeat, then check: until +blk.wait = Wait +blk.w_seconds = seconds +blk.help.until = Runs the blocks inside again and again until the condition holds - checked before each run, so if it already holds the inside does not run at all. +blk.help.dountil = Runs the blocks inside, then checks the condition and repeats until it holds - the inside always runs at least once. +blk.help.wait = Pauses this script for some seconds, then it goes on with the next block (also inside loops). diff --git a/lang/es.lang b/lang/es.lang index 4c935b7..9072a07 100644 --- a/lang/es.lang +++ b/lang/es.lang @@ -1332,3 +1332,10 @@ net.coop_editor = Editor campaign net.coop_builtin = Built-in campaign net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) +blk.until = Repetir hasta +blk.dountil = Repetir y luego: hasta +blk.wait = Esperar +blk.w_seconds = segundos +blk.help.until = Ejecuta los bloques de dentro una y otra vez hasta que se cumpla la condición; se comprueba antes de cada vuelta, así que si ya se cumple no se ejecuta nada. +blk.help.dountil = Ejecuta los bloques de dentro, luego comprueba la condición y repite hasta que se cumpla; el contenido se ejecuta al menos una vez. +blk.help.wait = Detiene este script unos segundos y luego sigue con el siguiente bloque (también dentro de bucles). diff --git a/lang/it.lang b/lang/it.lang index c41663b..1a64d6a 100644 --- a/lang/it.lang +++ b/lang/it.lang @@ -1332,3 +1332,10 @@ net.coop_editor = Editor campaign net.coop_builtin = Built-in campaign net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) +blk.until = Ripeti finché +blk.dountil = Ripeti, poi controlla: finché +blk.wait = Attendi +blk.w_seconds = secondi +blk.help.until = Esegue i blocchi interni più volte finché la condizione è vera; viene controllata prima di ogni giro, quindi se è già vera il contenuto non viene eseguito. +blk.help.dountil = Esegue i blocchi interni, poi controlla la condizione e ripete finché è vera; il contenuto viene eseguito almeno una volta. +blk.help.wait = Mette in pausa questo script per alcuni secondi, poi prosegue con il blocco successivo (anche nei cicli). diff --git a/lang/ru.lang b/lang/ru.lang index dde88d6..922741a 100644 --- a/lang/ru.lang +++ b/lang/ru.lang @@ -1332,3 +1332,10 @@ net.coop_editor = Editor campaign net.coop_builtin = Built-in campaign net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) +blk.until = Повторять, пока не +blk.dountil = Повторять, затем проверить: пока не +blk.wait = Ждать +blk.w_seconds = сек. +blk.help.until = Снова и снова выполняет блоки внутри, пока условие не станет верным; проверка перед каждым проходом, поэтому если оно уже верно, блоки не выполняются. +blk.help.dountil = Выполняет блоки внутри, затем проверяет условие и повторяет, пока оно не станет верным; блоки выполняются хотя бы один раз. +blk.help.wait = Приостанавливает этот скрипт на несколько секунд, затем выполняется следующий блок (и внутри циклов). diff --git a/src/blocks.rs b/src/blocks.rs index e43776b..85d930e 100644 --- a/src/blocks.rs +++ b/src/blocks.rs @@ -1,5 +1,5 @@ // Mission scripts as blocks: the block editor builds these trees, they are stored in the -// campaign file and translated into Rhai (internally only; the player never sees code). +// campaign file and run directly from the blocks (see mscript.rs); the player never sees code. // // A script is a list of statement blocks. Values (numbers, yes/no conditions) are small // expression trees. Every block has a fixed shape, so the editor can draw and check it. @@ -32,6 +32,9 @@ pub enum Block { AddVar { name: String, value: Val }, If { cond: Val, then: Vec, #[serde(default)] els: Vec }, Repeat { times: Val, body: Vec }, + Until { cond: Val, body: Vec }, // repeat until: checked before each run + DoUntil { cond: Val, body: Vec }, // repeat ... until: checked after each run + Wait { secs: Val }, Win, Lose, Reveal { zone: String }, @@ -141,7 +144,7 @@ pub const NUM_VALUES: [&str; 9] = ["num", "var", "gold", "mana", "time", "creatu pub const BOOL_VALUES: [&str; 6] = ["cmp", "and", "or", "not", "bool", "eventkind"]; // statement blocks in the palette: (category, kind) -pub const PALETTE: [(&str, &str); 18] = [ +pub const PALETTE: [(&str, &str); 21] = [ ("say", "message"), ("say", "objective"), ("say", "hint"), @@ -155,6 +158,9 @@ pub const PALETTE: [(&str, &str); 18] = [ ("world", "mana"), ("logic", "if"), ("logic", "repeat"), + ("logic", "until"), + ("logic", "dountil"), + ("logic", "wait"), ("logic", "trigger"), ("vars", "setvar"), ("vars", "addvar"), @@ -181,6 +187,9 @@ impl Block { "mana" => Block::Mana { amount: n(100.0) }, "if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() }, "repeat" => Block::Repeat { times: n(3.0), body: Vec::new() }, + "until" => Block::Until { cond: Val::template("cmp").unwrap(), body: Vec::new() }, + "dountil" => Block::DoUntil { cond: Val::template("cmp").unwrap(), body: Vec::new() }, + "wait" => Block::Wait { secs: n(5.0) }, "trigger" => Block::Trigger { name: String::new(), on: true }, "setvar" => Block::SetVar { name: String::new(), value: n(0.0) }, "addvar" => Block::AddVar { name: String::new(), value: n(1.0) }, @@ -205,6 +214,9 @@ impl Block { Block::Mana { .. } => "mana", Block::If { .. } => "if", Block::Repeat { .. } => "repeat", + Block::Until { .. } => "until", + Block::DoUntil { .. } => "dountil", + Block::Wait { .. } => "wait", Block::Trigger { .. } => "trigger", Block::SetVar { .. } => "setvar", Block::AddVar { .. } => "addvar", @@ -305,6 +317,13 @@ fn check_list(list: &[Block], n: &Names, path: &mut Vec, out: &mut Vec { + check_val(cond, Ty::Bool, n, path, out); + path.push(0); + check_list(body, n, path, out); + path.pop(); + } + Block::Wait { secs } => check_val(secs, Ty::Num, n, path, out), Block::Reveal { zone } | Block::Camera { zone } | Block::Kill { zone, .. } if !zone_ok(zone) => out.push(p("blk.err_zone", path)), Block::Terrain { zone, kind } => { if !zone_ok(zone) { @@ -345,160 +364,16 @@ pub fn vars_set(list: &[Block], out: &mut Vec) { vars_set(then, out); vars_set(els, out); } - Block::Repeat { body, .. } => vars_set(body, out), + Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => vars_set(body, out), _ => {} } } } -// ------------------------------------------------------------------ Rhai code - -fn q(s: &str) -> String { - let mut o = String::from("\""); - for c in s.chars() { - match c { - '"' => o.push_str("\\\""), - '\\' => o.push_str("\\\\"), - '\n' => o.push_str("\\n"), - '\r' | '\t' => o.push(' '), - c => o.push(c), - } - } - o.push('"'); - o -} - -fn f(n: f64) -> String { - if n.is_finite() { - format!("{:?}", n) // always with a decimal point: Rhai keeps it a float - } else { - "0.0".into() - } -} - -pub fn val_code(v: &Val) -> String { - match v { - Val::Num { n } => f(*n), - Val::Bool { b } => b.to_string(), - Val::Var { name } => format!("m.get({})", q(name)), - Val::Gold => "m.gold()".into(), - Val::Mana => "m.mana()".into(), - Val::Time => "m.time()".into(), - Val::Creatures { side, kind } => format!("m.count({}, {})", side, q(kind)), - Val::InZone { zone, side, kind } => format!("m.in_zone({}, {}, {})", q(zone), side, q(kind)), - Val::Random { a, b } => format!("m.random({}, {})", val_code(a), val_code(b)), - Val::Math { op, a, b } => { - let op = if MATH_OPS.contains(&op.as_str()) { op.as_str() } else { "+" }; - if op == "/" { - format!("m.div({}, {})", val_code(a), val_code(b)) - } else { - format!("({} {} {})", val_code(a), op, val_code(b)) - } - } - Val::Cmp { op, a, b } => { - let op = match op.as_str() { - "=" => "==", - o if CMP_OPS.contains(&o) => o, - _ => ">", - }; - format!("({} {} {})", val_code(a), op, val_code(b)) - } - Val::And { a, b } => format!("({} && {})", val_code(a), val_code(b)), - Val::Or { a, b } => format!("({} || {})", val_code(a), val_code(b)), - Val::Not { a } => format!("(!{})", val_code(a)), - Val::EventKind { kind } => format!("m.event_is({})", q(kind)), - } -} - -fn cond_code(v: &Val) -> String { - if v.ty() == Ty::Bool { - val_code(v) - } else { - format!("({} != 0.0)", val_code(v)) - } -} - -fn list_code(list: &[Block], depth: usize, out: &mut String) { - let ind = " ".repeat(depth + 1); - for b in list { - let line = match b { - Block::Message { text } => format!("m.message({});", q(text)), - Block::Objective { text } => format!("m.objective({});", q(text)), - Block::Hint { text } => format!("m.hint({});", q(text)), - Block::Spawn { side, kind, count, level, place, name } => { - let (pk, pz) = match place { - Place::Zone(z) => ("zone", z.as_str()), - Place::Event => ("event", ""), - Place::Core => ("core", ""), - }; - format!("m.spawn_unit({}, {}, {}, {}, {}, {}, {});", side, q(kind), val_code(count), val_code(level), q(pk), q(pz), q(name)) - } - Block::Wave { count } => format!("m.wave({});", val_code(count)), - Block::Gold { amount } => format!("m.add_gold({});", val_code(amount)), - Block::Mana { amount } => format!("m.add_mana({});", val_code(amount)), - Block::SetVar { name, value } => format!("m.set({}, {});", q(name), val_code(value)), - Block::AddVar { name, value } => format!("m.set({}, m.get({}) + {});", q(name), q(name), val_code(value)), - Block::If { cond, then, els } => { - out.push_str(&format!("{}if {} {{\n", ind, cond_code(cond))); - list_code(then, depth + 1, out); - if !els.is_empty() { - out.push_str(&format!("{}}} else {{\n", ind)); - list_code(els, depth + 1, out); - } - out.push_str(&format!("{}}}\n", ind)); - continue; - } - Block::Repeat { times, body } => { - out.push_str(&format!("{}for _i{} in 0..m.times({}) {{\n", ind, depth, val_code(times))); - list_code(body, depth + 1, out); - out.push_str(&format!("{}}}\n", ind)); - continue; - } - Block::Win => "m.win();".into(), - Block::Lose => "m.lose();".into(), - Block::Reveal { zone } => format!("m.reveal({});", q(zone)), - Block::Camera { zone } => format!("m.camera({});", q(zone)), - Block::Terrain { zone, kind } => format!("m.terrain({}, {});", q(zone), q(kind)), - Block::Kill { zone, side } => format!("m.kill({}, {});", q(zone), side), - Block::Trigger { name, on } => format!("m.trigger({}, {});", q(name), on), - }; - out.push_str(&ind); - out.push_str(&line); - out.push('\n'); - } -} - -// a Rhai function `fn (m) { ... }` for a list of blocks -pub fn function_code(name: &str, list: &[Block]) -> String { - let mut out = format!("fn {}(m) {{\n", name); - list_code(list, 0, &mut out); - out.push_str("}\n"); - out -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn blocks_become_rhai_code() { - let list = vec![ - Block::Message { text: "Hello \"master\"".into() }, - Block::If { - cond: Val::Cmp { op: "=".into(), a: Box::new(Val::Var { name: "wave".into() }), b: Box::new(Val::num(2.0)) }, - then: vec![Block::Spawn { side: 1, kind: "Knight".into(), count: Val::num(3.0), level: Val::num(2.0), place: Place::Zone("gate".into()), name: String::new() }], - els: vec![Block::Win], - }, - Block::Repeat { times: Val::num(2.0), body: vec![Block::AddVar { name: "wave".into(), value: Val::num(1.0) }] }, - ]; - let code = function_code("t_0", &list); - assert!(code.contains("m.message(\"Hello \\\"master\\\"\");"), "{}", code); - assert!(code.contains("if (m.get(\"wave\") == 2.0) {"), "{}", code); - assert!(code.contains("m.spawn_unit(1, \"Knight\", 3.0, 2.0, \"zone\", \"gate\", \"\");"), "{}", code); - assert!(code.contains("} else {")); - assert!(code.contains("for _i0 in 0..m.times(2.0) {")); - } - #[test] fn checking_finds_missing_zones_vars_and_wrong_types() { let zones = vec!["gate".to_string()]; diff --git a/src/editor/blocks_ui.rs b/src/editor/blocks_ui.rs index 6564920..52f94d0 100644 --- a/src/editor/blocks_ui.rs +++ b/src/editor/blocks_ui.rs @@ -85,7 +85,7 @@ fn list_mut<'a>(root: &'a mut Vec, lp: &[usize]) -> Option<&'a mut Vec body, + Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body, _ => return None, }; k += 2; @@ -432,7 +432,7 @@ impl BlockEdit { // slots: upper half before this block, lower half after it slots.push((Rect::new(x, y - CH() * 0.3, w, header.h * 0.5 + CH() * 0.3), lp.to_vec(), k)); slots.push((Rect::new(x, y + header.h * 0.5, w, header.h * 0.5), lp.to_vec(), k + 1)); - let nested = matches!(list[k], Block::If { .. } | Block::Repeat { .. }); + let nested = matches!(list[k], Block::If { .. } | Block::Repeat { .. } | Block::Until { .. } | Block::DoUntil { .. }); // the block: header row with its fields draw_block_shape(header, col, selected, found); if let Some(p) = problem { @@ -479,7 +479,7 @@ impl BlockEdit { els } } - Block::Repeat { body, .. } => body, + Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body, _ => unreachable!(), }; let start = y; @@ -563,6 +563,11 @@ impl BlockEdit { self.value(im, gfx, times, Ty::Num, path, 1, cx, y, ctx); word(cx, &tr("blk.w_times")); } + Block::Until { cond, .. } | Block::DoUntil { cond, .. } => self.value(im, gfx, cond, Ty::Bool, path, 1, cx, y, ctx), + Block::Wait { secs } => { + self.value(im, gfx, secs, Ty::Num, path, 1, cx, y, ctx); + word(cx, &tr("blk.w_seconds")); + } Block::Win | Block::Lose => {} Block::Reveal { zone } | Block::Camera { zone } => self.zone_dd(im, gfx, zone, path, 1, cx, y, ctx), Block::Terrain { zone, kind } => { diff --git a/src/editor/templates.rs b/src/editor/templates.rs index bf5336c..e7e842d 100644 --- a/src/editor/templates.rs +++ b/src/editor/templates.rs @@ -164,16 +164,38 @@ pub fn demo_campaign() -> CampaignDoc { trig("vault_claimed", TriggerKind::ZoneClaimed { zone: "vault".into() }, true, vec![say("demo.vault"), Block::Gold { amount: n(500.0) }]), trig("tunnel_dug", TriggerKind::ZoneDug { zone: "tunnel".into() }, true, vec![say("demo.tunnel")]), trig("crowd", TriggerKind::ZoneCount { zone: "field".into(), side: 1, kind: String::new(), op: ">=".into(), n: 4.0 }, true, vec![say("demo.crowd"), Block::Camera { zone: "field".into() }]), - trig("reinforcements", TriggerKind::Timer { secs: 90.0, every: false }, true, vec![say("demo.wave"), Block::Spawn { side: 1, kind: "Archer".into(), count: n(2.0), level: n(2.0), place: Place::Zone("field".into()), name: String::new() }]), + // repeat until (checked first): one archer every 15 seconds until four heroes stand on the field + trig( + "reinforcements", + TriggerKind::Timer { secs: 90.0, every: false }, + true, + vec![ + say("demo.wave"), + Block::Until { + cond: cmp(">=", Val::InZone { zone: "field".into(), side: 1, kind: String::new() }, n(4.0)), + body: vec![Block::Spawn { side: 1, kind: "Archer".into(), count: n(1.0), level: n(2.0), place: Place::Zone("field".into()), name: String::new() }, Block::Wait { secs: n(15.0) }], + }, + ], + ), trig("clock", TriggerKind::Timer { secs: 30.0, every: true }, false, vec![Block::AddVar { name: "ticks".into(), value: n(1.0) }]), trig("knight_falls", TriggerKind::UnitDies { side: 1, kind: String::new(), name: String::new() }, false, vec![Block::AddVar { name: "kills".into(), value: n(1.0) }, Block::Message { text: tr("demo.kills") }]), - trig("captain_falls", TriggerKind::UnitDies { side: 1, kind: String::new(), name: "captain".into() }, true, vec![say("demo.captain"), Block::Mana { amount: n(50.0) }]), + trig("captain_falls", TriggerKind::UnitDies { side: 1, kind: String::new(), name: "captain".into() }, true, vec![say("demo.captain"), Block::Repeat { times: n(5.0), body: vec![Block::Mana { amount: n(10.0) }] }]), trig("tower_down", TriggerKind::TowerFalls { side: 1 }, false, vec![say("demo.tower"), Block::Gold { amount: Val::Random { a: Box::new(n(100.0)), b: Box::new(n(300.0)) } }]), trig("rich", TriggerKind::Value { what: "gold".into(), var: String::new(), op: ">=".into(), n: 8000.0 }, true, vec![say("demo.rich")]), trig("lair", TriggerKind::RoomBuilt { room: "Lair".into() }, true, vec![Block::Hint { text: tr("demo.lair") }]), trig("spell", TriggerKind::SpellCast { spell: String::new() }, false, vec![Block::Mana { amount: n(10.0) }]), trig("slap", TriggerKind::Slap, true, vec![say("demo.slap")]), - trig("research", TriggerKind::Research, true, vec![say("demo.research"), Block::Repeat { times: n(2.0), body: vec![Block::Gold { amount: n(100.0) }] }]), + // repeat ... until (checked after): a little gold three times, a second apart + trig( + "research", + TriggerKind::Research, + true, + vec![ + say("demo.research"), + Block::SetVar { name: "bonus".into(), value: n(0.0) }, + Block::DoUntil { cond: cmp(">=", var("bonus"), n(3.0)), body: vec![Block::Gold { amount: n(100.0) }, Block::AddVar { name: "bonus".into(), value: n(1.0) }, Block::Wait { secs: n(1.0) }] }, + ], + ), ]; doc.missions.push(m); diff --git a/src/mscript.rs b/src/mscript.rs index 75a9517..470f08e 100644 --- a/src/mscript.rs +++ b/src/mscript.rs @@ -1,24 +1,23 @@ // Mission scripts at run time: the block scripts of a mission (campaign_doc::MissionScripts) -// become one Rhai program; the triggers are checked ten times a second inside the fixed -// simulation step, so a co-op match runs them identically on every computer. -// -// The program never touches the game directly: reading functions (m.gold(), m.in_zone(..)) -// look at the game through a pointer that is valid while a script runs, and everything a -// script does is collected as actions that are applied afterwards. +// are run directly from their blocks inside the fixed simulation step, so a co-op match runs +// them identically on every computer. Every run of a script is a small "thread" (where it is +// in which list of blocks); "wait" parks a thread until its time has come, and threads are +// saved with the game. Loops without a wait run up to a budget of steps per check and go on at +// the next check, so a script can never hang the game. -use std::cell::RefCell; use std::collections::BTreeMap; -use rhai::{Engine, Scope, AST}; use serde::{Deserialize, Serialize}; -use crate::blocks::function_code; +use crate::blocks::{Block, Place, Val}; use crate::campaign_doc::{MissionScripts, TriggerKind, ZoneDoc}; use crate::game::*; use crate::map::*; const CHECK_STEPS: u64 = 5; // triggers are checked every 5 fixed steps (0.1 s) const MAX_LOG: usize = 60; +const STEP_BUDGET: u32 = 2000; // blocks a thread may run per check before it yields +const MAX_THREADS: usize = 64; // pending events the simulation reports (the rest is found by comparing two checks) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -59,10 +58,40 @@ pub struct ScriptState { next_second: f32, rng: u32, pub error: Option, + #[serde(default)] + threads: Vec, // scripts that are waiting (or yielded in a long loop) #[serde(skip)] pub camera: Option<(f32, f32)>, // a block asked the camera to look here (taken by main) } +// which script a thread runs +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +enum Src { + Start, + Victory, + Defeat, + Tick, + Trigger(usize), +} + +// a list of blocks being worked through: its path (pairs of block index and branch below the +// script's own list), the next block, and what kind of list it is +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Frame { + lp: Vec, + idx: usize, + left: i64, // repeat: runs still to come after this one (-1: not a repeat) + loop_kind: u8, // 0 plain, 1 repeat, 2 repeat until (checked before), 3 repeat ... until (checked after) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Thread { + src: Src, + frames: Vec, + wake: f32, + ev: EventInfo, +} + // what a running script asks for #[derive(Clone, Debug, PartialEq)] enum Act { @@ -83,44 +112,12 @@ enum Act { } // the triggering unit (for "where the unit is" and "the unit is a ...") -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] struct EventInfo { kind: Option, pos: Option<(f32, f32)>, } -struct Ctx { - game: *const Game, - vars: BTreeMap, - acts: Vec, - event: EventInfo, - rng: u32, -} - -thread_local! { - static CTX: RefCell = RefCell::new(Ctx { game: std::ptr::null(), vars: BTreeMap::new(), acts: Vec::new(), event: EventInfo::default(), rng: 1 }); - // the compiled program of the running mission (key: the program text) - static PROGRAM: RefCell> = RefCell::new(None); -} - -// reads the game while a script runs (the pointer is set by run_fn around every call) -fn with_game(f: impl FnOnce(&Game) -> R, fallback: R) -> R { - CTX.with(|c| { - let p = c.borrow().game; - if p.is_null() { - fallback - } else { - // SAFETY: run_fn sets the pointer from a live &Game for the duration of the call - // and clears it afterwards; scripts run on this thread only - f(unsafe { &*p }) - } - }) -} - -fn act(a: Act) { - CTX.with(|c| c.borrow_mut().acts.push(a)); -} - pub fn kind_by_name(name: &str) -> Option { let n = name.trim().to_lowercase(); crate::blocks::KINDS.iter().position(|k| k.to_lowercase() == n).map(|i| match i { @@ -247,118 +244,41 @@ fn line_side(z: &ZoneDoc, x: f32, y: f32) -> i8 { } } -// ------------------------------------------------------------------ the program - -// the Rhai program of a mission: one function per script and trigger -pub fn program(s: &MissionScripts) -> String { - let mut code = String::new(); - code.push_str(&function_code("s_start", &s.start)); - code.push_str(&function_code("s_victory", &s.victory)); - code.push_str(&function_code("s_defeat", &s.defeat)); - code.push_str(&function_code("s_tick", &s.tick)); - for (k, t) in s.triggers.iter().enumerate() { - code.push_str(&function_code(&format!("t_{}", k), &t.script)); - } - code -} - -#[derive(Clone)] -struct M; - -fn engine() -> Engine { - let mut e = Engine::new(); - e.set_max_operations(100_000); - e.set_max_call_levels(16); - e.register_type_with_name::("mission"); - // reading - e.register_fn("gold", |_: &mut M| with_game(|g| g.gold as f64, 0.0)); - e.register_fn("mana", |_: &mut M| with_game(|g| g.mana as f64, 0.0)); - e.register_fn("time", |_: &mut M| with_game(|g| g.time as f64, 0.0)); - e.register_fn("get", |_: &mut M, name: &str| CTX.with(|c| c.borrow().vars.get(name).copied().unwrap_or(0.0))); - e.register_fn("set", |_: &mut M, name: &str, v: f64| { - CTX.with(|c| { - c.borrow_mut().vars.insert(name.to_string(), if v.is_finite() { v } else { 0.0 }); - }) - }); - e.register_fn("count", |_: &mut M, side: i64, kind: &str| { - with_game(|g| g.ents.iter().filter(|e| e.alive && e.captive == 0 && !e.chained && matches(e.side(), e.kind, side as i32, kind)).count() as f64, 0.0) - }); - e.register_fn("in_zone", |_: &mut M, zone: &str, side: i64, kind: &str| { - with_game( - |g| { - let Some(z) = g.script.as_ref().and_then(|s| s.zones.iter().find(|z| z.name == zone)) else { return 0.0 }; - g.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, side as i32, kind) && zone_contains(z, e.x, e.y)).count() as f64 - }, - 0.0, - ) - }); - e.register_fn("random", |_: &mut M, a: f64, b: f64| { - CTX.with(|c| { - let mut c = c.borrow_mut(); - // xorshift: deterministic, part of the saved script state - let mut x = c.rng.max(1); - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - c.rng = x; - let (lo, hi) = (a.min(b).round(), a.max(b).round()); - lo + (x as f64 % (hi - lo + 1.0)).floor() - }) - }); - e.register_fn("div", |_: &mut M, a: f64, b: f64| if b == 0.0 { 0.0 } else { a / b }); - e.register_fn("times", |_: &mut M, n: f64| if n.is_finite() { (n.round() as i64).clamp(0, 100) } else { 0 }); - e.register_fn("event_is", |_: &mut M, kind: &str| CTX.with(|c| c.borrow().event.kind.is_some() && c.borrow().event.kind == kind_by_name(kind))); - // doing - e.register_fn("message", |_: &mut M, s: &str| act(Act::Message(s.to_string()))); - e.register_fn("objective", |_: &mut M, s: &str| act(Act::Objective(s.to_string()))); - e.register_fn("hint", |_: &mut M, s: &str| act(Act::Hint(s.to_string()))); - e.register_fn("spawn_unit", |_: &mut M, side: i64, kind: &str, count: f64, level: f64, place: &str, zone: &str, name: &str| { - act(Act::Spawn { side: side as i32, kind: kind.to_string(), count: count.round() as i64, level: level.round() as i64, place: place.to_string(), zone: zone.to_string(), name: name.to_string() }) - }); - e.register_fn("wave", |_: &mut M, n: f64| act(Act::Wave(n.round() as i64))); - e.register_fn("add_gold", |_: &mut M, n: f64| act(Act::Gold(n))); - e.register_fn("add_mana", |_: &mut M, n: f64| act(Act::Mana(n))); - e.register_fn("win", |_: &mut M| act(Act::Win)); - e.register_fn("lose", |_: &mut M| act(Act::Lose)); - e.register_fn("reveal", |_: &mut M, z: &str| act(Act::Reveal(z.to_string()))); - e.register_fn("camera", |_: &mut M, z: &str| act(Act::Camera(z.to_string()))); - e.register_fn("terrain", |_: &mut M, z: &str, k: &str| act(Act::Terrain(z.to_string(), k.to_string()))); - e.register_fn("kill", |_: &mut M, z: &str, side: i64| act(Act::Kill(z.to_string(), side as i32))); - e.register_fn("trigger", |_: &mut M, name: &str, on: bool| act(Act::Trigger(name.to_string(), on))); - e -} - -// runs one function of the mission's program; returns the actions or an error -fn run_fn(g: &Game, code: &str, name: &str, vars: &mut BTreeMap, rng: &mut u32, event: EventInfo) -> Result, String> { - PROGRAM.with(|p| { - let mut p = p.borrow_mut(); - if p.as_ref().map_or(true, |(c, _, _)| c != code) { - let e = engine(); - let ast = e.compile(code).map_err(|e| e.to_string())?; - *p = Some((code.to_string(), e, ast)); - } - let (_, e, ast) = p.as_ref().unwrap(); - CTX.with(|c| { - let mut c = c.borrow_mut(); - c.game = g as *const Game; - c.vars = std::mem::take(vars); - c.acts.clear(); - c.event = event; - c.rng = *rng; - }); - let mut scope = Scope::new(); - let r = e.call_fn::(&mut scope, ast, name, (M,)); - CTX.with(|c| { - let mut c = c.borrow_mut(); - c.game = std::ptr::null(); - *vars = std::mem::take(&mut c.vars); - *rng = c.rng; - match r { - Ok(_) => Ok(std::mem::take(&mut c.acts)), - Err(err) => Err(err.to_string()), +// the list of blocks at a path inside a script +fn list_at<'a>(s: &'a MissionScripts, src: Src, lp: &[usize]) -> Option<&'a [Block]> { + let mut cur: &[Block] = match src { + Src::Start => &s.start, + Src::Victory => &s.victory, + Src::Defeat => &s.defeat, + Src::Tick => &s.tick, + Src::Trigger(k) => &s.triggers.get(k)?.script, + }; + let mut k = 0; + while k + 1 < lp.len() + 1 && k < lp.len() { + let (i, branch) = (lp[k], *lp.get(k + 1)?); + cur = match cur.get(i)? { + Block::If { then, els, .. } => { + if branch == 0 { + then + } else { + els + } } - }) - }) + Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body, + _ => return None, + }; + k += 2; + } + Some(cur) +} + +fn xorshift(rng: &mut u32) -> u32 { + let mut x = (*rng).max(1); + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *rng = x; + x } // ------------------------------------------------------------------ the game side @@ -429,58 +349,240 @@ impl Game { return; } let mut s = self.script.take().unwrap(); - let code = program(&s.scripts); - let mut fire: Vec<(String, EventInfo, Option)> = Vec::new(); // function, event, trigger + let mut fire: Vec<(String, EventInfo, Option)> = Vec::new(); + let mut start: Vec<(Src, EventInfo)> = Vec::new(); if !s.started { s.started = true; s.next_second = self.time + 1.0; self.remember(&mut s, true); - fire.push(("s_start".into(), EventInfo::default(), None)); + start.push((Src::Start, EventInfo::default())); } else { self.check_triggers(&mut s, &mut fire); if self.time >= s.next_second { s.next_second += 1.0; - for f in ["s_tick", "s_victory", "s_defeat"] { - fire.push((f.into(), EventInfo::default(), None)); + // the checks run every second, but never twice at once (one may still be waiting) + for src in [Src::Tick, Src::Victory, Src::Defeat] { + if !s.threads.iter().any(|t| t.src == src) { + start.push((src, EventInfo::default())); + } } } } - for (f, ev, trig) in fire { - if let Some(k) = trig { - // a trigger fires: once-triggers only the first time, switched-off ones never - if !s.enabled.get(k).copied().unwrap_or(false) || (s.scripts.triggers[k].once && s.done[k]) { - continue; - } - s.done[k] = true; - let name = s.scripts.triggers[k].name.clone(); - s.log(self.time, name); + for (_, ev, trig) in fire { + let Some(k) = trig else { continue }; + // a trigger fires: once-triggers only the first time, switched-off ones never + if !s.enabled.get(k).copied().unwrap_or(false) || (s.scripts.triggers[k].once && s.done[k]) { + continue; } - let (mut vars, mut rng) = (std::mem::take(&mut s.vars), s.rng); - let r = run_fn(self, &code, &f, &mut vars, &mut rng, ev.clone()); - s.vars = vars; - s.rng = rng; - match r { - Ok(acts) => { - for a in acts { - self.apply_act(&mut s, a, &ev); - } - } - Err(e) => { - if s.error.is_none() { - self.script_msg("msg.script_error", vec![e.chars().take(120).collect()]); - } - s.log(self.time, format!("! {}", e.chars().take(80).collect::())); - s.error = Some(e); - } - } - if self.state != 0 { - break; + s.done[k] = true; + let name = s.scripts.triggers[k].name.clone(); + s.log(self.time, name); + start.push((Src::Trigger(k), ev)); + } + let mut threads = std::mem::take(&mut s.threads); + for (src, ev) in start { + threads.push(Thread { src, frames: vec![Frame { lp: Vec::new(), idx: 0, left: -1, loop_kind: 0 }], wake: 0.0, ev }); + } + // run every thread whose time has come; the waiting ones stay + let scripts = std::mem::take(&mut s.scripts); + let mut keep = Vec::new(); + for mut t in threads { + if self.state == 0 && t.wake <= self.time && !self.run_thread(&mut s, &scripts, &mut t) { + keep.push(t); + } else if t.wake > self.time { + keep.push(t); } } + if keep.len() > MAX_THREADS { + keep.drain(0..keep.len() - MAX_THREADS); + } + s.scripts = scripts; + s.threads = keep; s.events.clear(); self.script = Some(s); } + // runs a thread until it ends (true), waits or used up its steps (false) + fn run_thread(&mut self, s: &mut ScriptState, scripts: &MissionScripts, t: &mut Thread) -> bool { + let mut budget = STEP_BUDGET; + loop { + if self.state != 0 { + return true; + } + if budget == 0 { + t.wake = self.time; // go on at the next check + return false; + } + budget -= 1; + let Some(fr) = t.frames.last() else { return true }; + let Some(list) = list_at(scripts, t.src, &fr.lp) else { return true }; + let (idx, lp, left, loop_kind) = (fr.idx, fr.lp.clone(), fr.left, fr.loop_kind); + if idx >= list.len() { + // the end of a list: loop again, or back to the list around it + let again = match loop_kind { + 1 => left > 0, + 2 | 3 => { + // the loop's condition sits in its block in the list around it + let outer = list_at(scripts, t.src, &lp[..lp.len() - 2]).and_then(|l| l.get(lp[lp.len() - 2])); + match outer { + Some(Block::Until { cond, .. }) | Some(Block::DoUntil { cond, .. }) => !self.cond(s, cond, &t.ev), + _ => false, + } + } + _ => false, + }; + if again { + let fr = t.frames.last_mut().unwrap(); + fr.idx = 0; + if loop_kind == 1 { + fr.left -= 1; + } + continue; + } + t.frames.pop(); + match t.frames.last_mut() { + Some(parent) => parent.idx += 1, + None => return true, + } + continue; + } + let inner = |branch: usize, left: i64, kind: u8| { + let mut p = lp.clone(); + p.push(idx); + p.push(branch); + Frame { lp: p, idx: 0, left, loop_kind: kind } + }; + match &list[idx] { + Block::If { cond, .. } => { + let c = self.cond(s, cond, &t.ev); + t.frames.push(inner(if c { 0 } else { 1 }, -1, 0)); + } + Block::Repeat { times, .. } => { + let n = self.eval(s, times, &t.ev).round().clamp(0.0, 100.0) as i64; + if n > 0 { + t.frames.push(inner(0, n - 1, 1)); + } else { + t.frames.last_mut().unwrap().idx += 1; + } + } + Block::Until { cond, .. } => { + // checked before each run: already true means the inside never runs + if self.cond(s, cond, &t.ev) { + t.frames.last_mut().unwrap().idx += 1; + } else { + t.frames.push(inner(0, -1, 2)); + } + } + Block::DoUntil { .. } => t.frames.push(inner(0, -1, 3)), + Block::Wait { secs } => { + let n = self.eval(s, secs, &t.ev).clamp(0.1, 36000.0) as f32; + t.frames.last_mut().unwrap().idx += 1; + t.wake = self.time + n; + return false; + } + b => { + let b = b.clone(); + t.frames.last_mut().unwrap().idx += 1; + self.exec(s, &b, &t.ev); + } + } + } + } + + // a value + fn eval(&self, s: &mut ScriptState, v: &Val, ev: &EventInfo) -> f64 { + let r = match v { + Val::Num { n } => *n, + Val::Bool { b } => *b as i32 as f64, + Val::Var { name } => s.vars.get(name).copied().unwrap_or(0.0), + Val::Gold => self.gold as f64, + Val::Mana => self.mana as f64, + Val::Time => self.time as f64, + Val::Creatures { side, kind } => self.ents.iter().filter(|e| e.alive && e.captive == 0 && !e.chained && matches(e.side(), e.kind, *side, kind)).count() as f64, + Val::InZone { zone, side, kind } => match s.zone(zone) { + Some(z) => self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(z, e.x, e.y)).count() as f64, + None => 0.0, + }, + Val::Random { a, b } => { + let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev)); + let (lo, hi) = (a.min(b).round(), a.max(b).round()); + lo + (xorshift(&mut s.rng) as f64 % (hi - lo + 1.0)).floor() + } + Val::Math { op, a, b } => { + let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev)); + match op.as_str() { + "-" => a - b, + "*" => a * b, + "/" => { + if b == 0.0 { + 0.0 + } else { + a / b + } + } + _ => a + b, + } + } + Val::Cmp { op, a, b } => { + let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev)); + cmp(op, a, b) as i32 as f64 + } + Val::And { a, b } => (self.cond(s, a, ev) && self.cond(s, b, ev)) as i32 as f64, + Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64, + Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64, + Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64, + }; + if r.is_finite() { + r + } else { + 0.0 + } + } + + fn cond(&self, s: &mut ScriptState, v: &Val, ev: &EventInfo) -> bool { + self.eval(s, v, ev) != 0.0 + } + + // one block that does something + fn exec(&mut self, s: &mut ScriptState, b: &Block, ev: &EventInfo) { + let act = match b { + Block::Message { text } => Act::Message(text.clone()), + Block::Objective { text } => Act::Objective(text.clone()), + Block::Hint { text } => Act::Hint(text.clone()), + Block::Spawn { side, kind, count, level, place, name } => { + let (pk, pz) = match place { + Place::Zone(z) => ("zone", z.clone()), + Place::Event => ("event", String::new()), + Place::Core => ("core", String::new()), + }; + Act::Spawn { side: *side, kind: kind.clone(), count: self.eval(s, count, ev).round() as i64, level: self.eval(s, level, ev).round() as i64, place: pk.into(), zone: pz, name: name.clone() } + } + Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64), + Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)), + Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)), + Block::SetVar { name, value } => { + let v = self.eval(s, value, ev); + s.vars.insert(name.clone(), v); + return; + } + Block::AddVar { name, value } => { + let v = s.vars.get(name).copied().unwrap_or(0.0) + self.eval(s, value, ev); + s.vars.insert(name.clone(), v); + return; + } + Block::Win => Act::Win, + Block::Lose => Act::Lose, + Block::Reveal { zone } => Act::Reveal(zone.clone()), + Block::Camera { zone } => Act::Camera(zone.clone()), + Block::Terrain { zone, kind } => Act::Terrain(zone.clone(), kind.clone()), + Block::Kill { zone, side } => Act::Kill(zone.clone(), *side), + Block::Trigger { name, on } => Act::Trigger(name.clone(), *on), + // the flow blocks are handled by run_thread + Block::If { .. } | Block::Repeat { .. } | Block::Until { .. } | Block::DoUntil { .. } | Block::Wait { .. } => return, + }; + self.apply_act(s, act, ev); + } + // what the next check compares against: living units, towers, rooms, research, zone counts fn remember(&self, s: &mut ScriptState, first: bool) { s.alive = self.ents.iter().filter(|e| e.alive && e.captive == 0).map(|e| Seen { id: e.id, kind: e.kind, side: e.side(), x: e.x, y: e.y }).collect(); @@ -903,6 +1005,70 @@ mod tests { assert_eq!(v.get("rich"), Some(&1.0)); } + #[test] + fn waits_and_both_kinds_of_until_loops() { + let mut g = Game::new(3, 64, 64); + let cmp = |op: &str, a: Val, b: f64| Val::Cmp { op: op.into(), a: Box::new(a), b: Box::new(Val::num(b)) }; + let var = |n: &str| Val::Var { name: n.into() }; + let scripts = MissionScripts { + start: vec![ + Block::SetVar { name: "a".into(), value: Val::num(0.0) }, + Block::SetVar { name: "pre".into(), value: Val::num(0.0) }, + Block::SetVar { name: "post".into(), value: Val::num(0.0) }, + // repeat until (checked before): already true, so the inside never runs + Block::Until { cond: cmp(">=", var("a"), 0.0), body: vec![Block::AddVar { name: "pre".into(), value: Val::num(1.0) }] }, + // repeat ... until (checked after): runs once even though it is already true + Block::DoUntil { cond: cmp(">=", var("a"), 0.0), body: vec![Block::AddVar { name: "post".into(), value: Val::num(1.0) }] }, + // count to three, one step every second + Block::Until { + cond: cmp(">=", var("a"), 3.0), + body: vec![Block::Wait { secs: Val::num(1.0) }, Block::AddVar { name: "a".into(), value: Val::num(1.0) }], + }, + Block::SetVar { name: "done".into(), value: Val::num(1.0) }, + ], + ..Default::default() + }; + g.script = Some(ScriptState::new(scripts, vec![], 7)); + run(&mut g, 0.3); + let v = |g: &Game, k: &str| g.script.as_ref().unwrap().vars.get(k).copied(); + assert_eq!(v(&g, "pre"), Some(0.0), "repeat until checks first"); + assert_eq!(v(&g, "post"), Some(1.0), "repeat ... until runs once"); + assert_eq!(v(&g, "a"), Some(0.0), "waiting"); + assert_eq!(v(&g, "done"), None); + run(&mut g, 1.5); + assert_eq!(v(&g, "a"), Some(1.0), "one step after a second"); + run(&mut g, 2.2); + assert_eq!(v(&g, "a"), Some(3.0)); + assert_eq!(v(&g, "done"), Some(1.0), "the script went on after the loop"); + // a waiting script survives saving and loading + let mut g2 = Game::new(3, 64, 64); + g2.script = Some(ScriptState::new( + MissionScripts { start: vec![Block::Wait { secs: Val::num(2.0) }, Block::SetVar { name: "late".into(), value: Val::num(1.0) }], ..Default::default() }, + vec![], + 1, + )); + run(&mut g2, 0.5); + let json = serde_json::to_string(&g2).unwrap(); + let mut g3: Game = serde_json::from_str(&json).unwrap(); + g3.map.activate(); + run(&mut g3, 2.0); + assert_eq!(g3.script.as_ref().unwrap().vars.get("late"), Some(&1.0)); + } + + #[test] + fn an_endless_loop_does_not_hang_the_game() { + let mut g = Game::new(3, 64, 64); + let scripts = MissionScripts { + start: vec![Block::Until { cond: Val::Bool { b: false }, body: vec![Block::AddVar { name: "n".into(), value: Val::num(1.0) }] }], + ..Default::default() + }; + g.script = Some(ScriptState::new(scripts, vec![], 7)); + run(&mut g, 1.0); + let n = g.script.as_ref().unwrap().vars["n"]; + assert!(n > 100.0, "the loop runs: {}", n); + assert!(g.time > 0.9, "and the game goes on"); + } + #[test] fn zone_shapes() { let r = rect("r", (2.0, 2.0), (4.0, 3.0));