diff --git a/.claude/settings.json b/.claude/settings.json index 9625fc11..164322a1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,14 @@ "Bash(just bar::sync-logs)", "Bash(just bar::sync-logs *)", "Bash(just bar::logs)", - "Bash(just bar::logs *)" + "Bash(just bar::logs *)", + "Bash(emmylua_check *)", + "Bash(stylua --check *)", + "Bash(lx --lua-version 5.1 lint *)", + "Bash(code --list-extensions *)", + "Bash(distrobox list *)", + "Bash(just bar::mission-logs)", + "Bash(just bar::mission-logs *)" ] } } diff --git a/.emmyrc.json b/.emmyrc.json new file mode 100644 index 00000000..035905e3 --- /dev/null +++ b/.emmyrc.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/main/crates/emmylua_code_analysis/resources/schema.json", + "completion": { + "autoRequireSeparator": "/" + }, + "diagnostics": { + "disable": [ + "duplicate-set-field" + ], + "globals": [ + "Turn", + "Move", + "Spin", + "StopSpin", + "WaitForTurn", + "WaitForMove", + "Hide", + "Show", + "Explode", + "EmitSfx", + "StartThread", + "SetSignalMask", + "Signal", + "Sleep", + "GetUnitValue", + "SetUnitValue", + "piece", + "script", + "UnitScript", + "x_axis", + "y_axis", + "z_axis", + "SIG_WALK", + "UNITSCRIPT_DIR", + "widgetHandler", + "gadgetHandler", + "fontHandler", + "LUAUI_DIRNAME", + "socket", + "pairsByKeys", + "SendToUnsynced", + "CallAsTeam", + "handler", + "lowerkeys", + "addon", + "gcinfo", + "loadlib", + "_G", + "self", + "widget", + "gadget", + "GG", + "ghInfo", + "include", + "GameCMD", + "Scenario", + "game_engine", + "SG", + "CMD_AREA_MEX", + "CMD_WANT_CLOAK", + "CMD_WANTED_SPEED", + "GadgetCrashingAircraft", + "ExplosionDefs", + "GL_TEXTURE_2D", + "Policies", + "Actions", + "describe", + "it", + "before_each", + "after_each", + "setup", + "teardown" + ] + }, + "runtime": { + "version": "Lua5.1", + "requirePattern": [ + "?", + "?.lua" + ], + "requireLikeFunction": [ + "VFS.Include", + "include" + ] + }, + "workspace": { + "workspaceRoots": [ + "Beyond-All-Reason" + ], + "ignoreDir": [ + ".vscode", + "RecoilEngine", + "teiserver", + "bar-lobby", + "BYAR-Chobby", + "SPADS", + "spads_config_bar", + "SpringLobbyInterface", + "lua-doc-extractor", + "ansible-spads-setup", + "bar-db", + "bar-live-services", + "bar_debug_launcher", + "bar-lua-codemod", + "bar-mission-kit/target", + "templates", + "docker" + ], + "ignoreGlobs": [ + "**/.lux/**", + "**/node_modules/**" + ] + } +} diff --git a/bar-mission-kit/.gitignore b/bar-mission-kit/.gitignore new file mode 100644 index 00000000..a629ed40 --- /dev/null +++ b/bar-mission-kit/.gitignore @@ -0,0 +1,3 @@ +/target +*.vsix +vscode/server/ diff --git a/bar-mission-kit/Cargo.lock b/bar-mission-kit/Cargo.lock new file mode 100644 index 00000000..0d4ce049 --- /dev/null +++ b/bar-mission-kit/Cargo.lock @@ -0,0 +1,1270 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bar-mission-kit" +version = "0.1.0" +dependencies = [ + "clap", + "dioxus", + "dioxus-ssr", + "emmylua_parser", + "glob", + "serde", + "serde_json", +] + +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dioxus" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a247114500f1a78e87022defa8173de847accfada8e8809dfae23a118a580c" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-html", + "dioxus-signals", +] + +[[package]] +name = "dioxus-core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c03f451a119e47433c16e2d8eb5b15bf7d6e6734eb1a4c47574e6711dadff8d" +dependencies = [ + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash", + "rustversion", + "serde", + "slab", + "slotmap", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105c954caaaedf8cd10f3d1ba576b01e18aa8d33ad435182125eefe488cf0064" +dependencies = [ + "convert_case", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dioxus-core-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91a82fccfa48574eb7aa183e297769540904694844598433a9eb55896ad9f93b" +dependencies = [ + "once_cell", +] + +[[package]] +name = "dioxus-hooks" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948e2b3f20d9d4b2c300aaa60281b1755f3298684448920b27106da5841896d0" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-html" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c9a40e6fee20ce7990095492dedb6a753eebe05e67d28271a249de74dc796d" +dependencies = [ + "async-trait", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ba87b53688a2c9f619ecdf4b3b955bc1f08bd0570a80a0d626c405f6d14a76" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dioxus-rsx" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb588e05800b5a7eb90b2f40fca5bbd7626e823fb5e1ba21e011de649b45aa1" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dioxus-signals" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10e032dbb3a2c0386ec8b8ee59bc20b5aeb67038147c855801237b45b13d72ac" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "once_cell", + "parking_lot", + "rustc-hash", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-ssr" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e1fac097e71ccec4e9e15d44b68393be2981276e42170703180bceb0b6f122" +dependencies = [ + "askama_escape", + "dioxus-core", + "dioxus-core-types", + "rustc-hash", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "emmylua_parser" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0103cc231288ddc9391785db73be15d5928951e54cfe17c73af8c4d371678db" +dependencies = [ + "rowan", + "rust-i18n", + "serde", +] + +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generational-box" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a673cf4fb0ea6a91aa86c08695756dfe875277a912cdbf33db9a9f62d47ed82b" +dependencies = [ + "parking_lot", + "tracing", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ignore" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy-js-bundle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49596223b9d9d4947a14a25c142a6e7d8ab3f27eb3ade269d238bb8b5c267e2" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys", +] + +[[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" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[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 = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +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 = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash", + "text-size", +] + +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml", + "triomphe", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bar-mission-kit/Cargo.toml b/bar-mission-kit/Cargo.toml new file mode 100644 index 00000000..eb78033f --- /dev/null +++ b/bar-mission-kit/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bar-mission-kit" +version = "0.1.0" +edition = "2021" + +[dependencies] +emmylua_parser = "0.28" +glob = "0.3" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dioxus = { version = "0.6", default-features = false, features = ["macro", "html", "signals"] } +dioxus-ssr = "0.6" diff --git a/bar-mission-kit/check_terminal_js.sh b/bar-mission-kit/check_terminal_js.sh new file mode 100755 index 00000000..a7d4bfdf --- /dev/null +++ b/bar-mission-kit/check_terminal_js.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# The browser terminal's script is embedded in terminal.html, so a syntax error +# there is invisible to cargo — it only shows up as a blank panel at runtime +# (the static "waiting for the view artifact" placeholder never gets replaced). +# Extract the script and parse it. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" +python3 - <<'PY' > /tmp/bar_terminal_script.js +import re +src = open("web/terminal.html").read() +print("\n".join(re.findall(r"]*>(.*?)", src, re.S))) +PY +node --check /tmp/bar_terminal_script.js +echo "terminal.html script: syntax-ok" + +# The VS Code extension is plain JS with no build step: nothing else catches a +# syntax error before VS Code silently fails to activate it. +node --check vscode/extension.js +echo "vscode/extension.js: syntax-ok" diff --git a/bar-mission-kit/fixtures/modules/combat/types/actions.lua b/bar-mission-kit/fixtures/modules/combat/types/actions.lua new file mode 100644 index 00000000..6e7334e1 --- /dev/null +++ b/bar-mission-kit/fixtures/modules/combat/types/actions.lua @@ -0,0 +1,27 @@ +---@meta actions + +--- Combat's actions, declared once for every grammar that names them. Each is +--- a class that is callable where it can be performed; a mode facet (a domain +--- a grant is written against) can be added to any of them without a second +--- declaration somewhere else. + +--- Protect a unit. Its .Until sugar bounds that protection's LIFETIME with a +--- companion trigger — the desugared When(condition).Do(Combat.Unprotect(unit)), +--- armed when the protection is applied, not at load. +---@class CombatProtect +---@overload fun(unit: MissionUnitRef): MissionProtectEffect + +---@class CombatUnprotect +---@overload fun(unit: MissionUnitRef): MissionEffect + +--- Protect's return: a plain effect plus the Until lifetime sugar. +---@class MissionProtectEffect +---@field execute fun(ctx: MissionContext) +---@field Until fun(condition: MissionCondition): MissionEffect + +---@class CombatActions +---@field Protect CombatProtect +---@field Unprotect CombatUnprotect + +---@type CombatActions +Combat = {} diff --git a/bar-mission-kit/fixtures/modules/construction/types/actions.lua b/bar-mission-kit/fixtures/modules/construction/types/actions.lua new file mode 100644 index 00000000..5d99ea4a --- /dev/null +++ b/bar-mission-kit/fixtures/modules/construction/types/actions.lua @@ -0,0 +1,20 @@ +---@meta actions + +--- Construction's actions, declared once for both grammars. Each names +--- something a builder may do for an ally; a mode grants or denies it, and +--- parameterises it where the verb takes terms (a build delay, in seconds). + +--- A grant written against a construction action. +---@class ConstructionGrant +---@field domain string +---@field category string|nil +---@field tier integer|nil + +---@class ConstructionActions +---@field Assist ConstructionGrant +---@field Reclaim ConstructionGrant +---@field Resurrect ConstructionGrant +---@field Build ConstructionGrant + +---@type ConstructionActions +Construction = {} diff --git a/bar-mission-kit/fixtures/modules/matchflow/types/actions.lua b/bar-mission-kit/fixtures/modules/matchflow/types/actions.lua new file mode 100644 index 00000000..96d329b2 --- /dev/null +++ b/bar-mission-kit/fixtures/modules/matchflow/types/actions.lua @@ -0,0 +1,24 @@ +---@meta actions + +--- MatchFlow's actions, declared once for every grammar that names them. +--- Each is a class that is callable where it can be performed; a mode facet +--- (a domain a grant is written against) can be added to any of them without +--- a second declaration somewhere else. + +--- Holds from the first cadence tick after arming. +---@class MatchFlowStarted +---@overload fun(): MissionCondition + +---@class MatchFlowVictory +---@overload fun(team: MissionTeam): MissionEffect + +---@class MatchFlowDefeat +---@overload fun(team: MissionTeam): MissionEffect + +---@class MatchFlowActions +---@field Started MatchFlowStarted +---@field Victory MatchFlowVictory +---@field Defeat MatchFlowDefeat + +---@type MatchFlowActions +MatchFlow = {} diff --git a/bar-mission-kit/fixtures/modules/missions/types/missions.lua b/bar-mission-kit/fixtures/modules/missions/types/missions.lua new file mode 100644 index 00000000..55050a62 --- /dev/null +++ b/bar-mission-kit/fixtures/modules/missions/types/missions.lua @@ -0,0 +1,147 @@ +---@meta actions + +--- Mission-runtime types: trigger engine descriptors and the authoring DSL's +--- chain/condition/effect shapes, dot-only and closure-free. Mission files must load identically in the synced sandbox (which strips rawset) and in busted. + +--- Domain aliases: the DSL's typed parameters. Names are LOAD-BEARING beyond +--- the checker — the mission kit derives its semantic model from them (alias -> slot semantic, literal unions -> editor enums). +---@alias UnitDefName string unit def name, e.g. "armpw" +---@alias MissionUnitName string roster unit name, declared by units.lua Named(...) +---@alias MissionUnitGroup string roster group name, declared by units.lua Grouped(...) +---@alias ObjectiveName string +---@alias MissionTeamRole "player"|"enemy"|"gaia" spawn-time team role, resolved at arm +--- Wall-clock seconds, never frames. An alias and not a bare number for the +--- usual reason — a `number` has nothing to call itself in a sentence, so an +--- editor can only offer a nameless box, and the one thing an author needs to +--- know here is which unit they are typing in. The DSL converts on the way in +--- using the engine's own tick rate, so "30" is thirty seconds at any speed. +---@alias MissionSeconds number + +--- Mission bus vocabulary, CLOSED BY TYPE: every event name crossing the bus +--- is a member of this alias, so the checker flags typos across every inputs/OnEvent consumer as type errors. +---@alias MissionEventName +---| "UnitFinished" +---| "UnitDestroyed" +---| "UnitGiven" +---| "UnitTaken" +---| "UnitEnteredLos" +---| "mission.objective_changed" +---| "waves.wave_spawned" +---| "waves.wave_cleared" +---| "waves.boss_spawned" +---| "waves.boss_defeated" + +--- A condition carries metadata about what can change its answer: inputs +--- name bus events (nil = poll every cadence). Pure — reads only ctx, captures configuration never progress (progress lives in engine state, the savegame rule). +---@class MissionCondition +---@field evaluate fun(ctx: MissionContext): boolean +---@field inputs MissionEventName[]|nil events that can change this answer; nil = poll every cadence + +--- What the engine hands every condition and effect: the gadget builds it +--- from Spring, specs from plain tables. Unit destroyed/spotted answers are latched — once true, stay true. +---@class MissionContext +---@field GetUnitDefCount fun(teamID: integer, unitDefName: string): integer count of finished units of that def +---@field IsObjectiveComplete fun(name: string): boolean +---@field IsUnitDestroyed fun(name: string): boolean +---@field IsUnitSpotted fun(name: string, allyTeamID: integer): boolean +---@field TransferGroup fun(groupName: string, teamID: integer) +---@field Protect fun(name: string) combat-module protection by roster name +---@field Unprotect fun(name: string) +---@field StartWaves fun(request: table) waves-module pressure, by pack +---@field StopWaves fun(pack: string) +---@field SetWaveIntensity fun(pack: string, intensity: number) +---@field SurgeWaves fun(pack: string) +---@field WaveStatus fun(pack: string): WaveStatus|nil +---@field frame integer current game frame + +--- A lazy effect built by a named verb (e.g. Objective("x").Complete()); the +--- engine executes it when the trigger fires. Captures configuration only, never progress. +---@class MissionEffect +---@field execute fun(ctx: MissionContext) + +--- The injected Objective verb's handle: Complete() builds the effect side, +--- IsComplete() the condition side. +---@class MissionObjective +---@field Complete fun(): MissionEffect +---@field IsComplete fun(): MissionCondition + +--- A named-unit reference produced by the injected Unit verb. Both +--- conditions are latched; the name is validated against the roster at load — unknown names never arm. +---@class MissionUnitRef +---@field name MissionUnitName +---@field IsDestroyed fun(): MissionCondition +---@field IsSpotted fun(team: MissionTeam): MissionCondition + +--- The dot-only builder chain returned by Spawn. Positions are map fractions +--- until real maps pin real coordinates; At is required — a chain without one fails the load. +---@class MissionSpawnChain +---@field At fun(fx: number, fz: number): MissionSpawnChain +---@field Named fun(name: MissionUnitName): MissionSpawnChain +---@field Grouped fun(group: MissionUnitGroup): MissionSpawnChain + +--- The dot-only builder chain returned by Claim. No At: a claimed unit is +--- already somewhere. OrSpawnAt is required, and says where to build one when +--- the team turns out to have none. +---@class MissionClaimChain +---@field Named fun(name: MissionUnitName): MissionClaimChain +---@field Grouped fun(group: MissionUnitGroup): MissionClaimChain +---@field OrSpawnAt fun(fx: number, fz: number): MissionClaimChain + +--- One validated spawn entry, as Roster.Finalize returns it. +---@class MissionRosterEntry +---@field def UnitDefName +---@field team MissionTeamRole +---@field fx number map-fraction position, resolved against map size at spawn +---@field fz number +---@field name MissionUnitName|nil declared by Named +---@field group MissionUnitGroup|nil declared by Grouped +---@field claim boolean|nil written by Claim: bind to an existing unit if the team has one + +--- A registered trigger. Identity = source filename + declaration order, +--- stamped at registration — the unregister-by-identity key for hot reload. +---@class TriggerDescriptor +---@field id string ":" +---@field filename string mission-relative trigger file path +---@field order integer 1-based declaration order within the file +---@field condition MissionCondition +---@field effects MissionEffect[] executed in Do order when the condition fires +---@field once boolean fire at most once (default true) +---@field delayFrames integer hold the effects until the conditions have held this long; 0 fires at once + +--- The dot-only builder chain returned by When. There is no terminator: the +--- loader finalizes all chains when the file's include returns; a chain without a Do fails the load. +---@class TriggerChain +---@field When fun(condition: MissionCondition): TriggerChain another condition; all must hold +---@field After fun(seconds: MissionSeconds): TriggerChain hold the effects until the conditions have held that long +---@field Do fun(effect: MissionEffect): TriggerChain repeatable; effects run in Do order +---@field Once fun(once: boolean?): TriggerChain default true; pass false for repeating triggers + +--- A unit-def reference produced by the injected UnitDef verb. Carries the +--- name only; resolution to an id happens where Spring exists. +---@class MissionUnitDefRef +---@field name UnitDefName + +--- The injected Team.Player handle. Demo rule: resolves to the first human +--- team at mission load. +---@class MissionTeam +---@field teamID integer +---@field allyTeam integer +---@field Has fun(unitDef: MissionUnitDefRef, count: integer): MissionCondition + +--- Serializable trigger progress: the pile a checkpoint saves. Definitions +--- reload from source; this table is reapplied on top. +---@class TriggerEngineState +---@field fired table trigger id -> has fired +---@field heldSince table trigger id -> frame its conditions first held, for delays + +--- What a required module's mission_dsl.lua returns. The loader composes the +--- sandbox env from the missions manifest's requires list — the dependency +--- list IS the vocabulary whitelist; a global collision is a load error. +---@class MissionDslFile +---@field filename string mission-relative trigger file path +---@field Register fun(descriptor: TriggerDescriptor) +---@field names table roster unit names, for load-time validation +---@field groups table roster group names, for load-time validation + +---@class MissionDslContribution +---@field ForFile fun(file: MissionDslFile): { env: table, Finalize: fun()|nil } diff --git a/bar-mission-kit/fixtures/modules/missions/types/mode_policy.lua b/bar-mission-kit/fixtures/modules/missions/types/mode_policy.lua new file mode 100644 index 00000000..1034c517 --- /dev/null +++ b/bar-mission-kit/fixtures/modules/missions/types/mode_policy.lua @@ -0,0 +1,27 @@ +---@meta policy mode + +--- The missions mode-preset surface: what modules/missions/modes/*.lua files +--- author against (via lib/mode_dsl.lua over modules/mode_builder.lua). + +--- A mode noun: names the policy domain a verb acts on. +---@class MissionModeNoun +---@field domain string + +--- The Mode chain (missions vocabulary). Every verb returns the chain; the +--- chain IS the ModeConfig the preset file returns. +---@class MissionModeChain +---@field Desc fun(desc: string): MissionModeChain +---@field Ranked fun(): MissionModeChain +---@field RetainValues fun(): MissionModeChain +---@field Hidden fun(): MissionModeChain +---@field Unlocked fun(): MissionModeChain +---@field Locked fun(): MissionModeChain +---@field Own fun(noun: MissionModeNoun): MissionModeChain + +---Start a mode chain; the key is the name's snake_case. +---@param name string +---@return MissionModeChain +function Mode(name) end + +---@type { End: MissionModeNoun } +Match = {} diff --git a/bar-mission-kit/fixtures/modules/missions/types/trigger_policy.lua b/bar-mission-kit/fixtures/modules/missions/types/trigger_policy.lua new file mode 100644 index 00000000..5af2fbe6 --- /dev/null +++ b/bar-mission-kit/fixtures/modules/missions/types/trigger_policy.lua @@ -0,0 +1,55 @@ +---@meta policy trigger + +--- The trigger-file authoring environment: the statements and handles only +--- a mission speaks. Actions a module can perform live in its own +--- types/actions.lua, where every grammar can read them. +--- +--- The trigger-file authoring environment. These globals exist in no real +--- scope: mission_loader injects them into each triggers/*.lua sandbox (the +--- sandbox IS the API surface). This meta file mirrors that injection so the +--- language server sees what a trigger file sees. + +---Start a trigger chain with its arming condition. Chain more conditions +---with .When(...), effects with .Do(...), behavior with .Once(...). +---@param condition MissionCondition +---@return TriggerChain +function When(condition) end + +---Objective handle: .Complete() builds the effect side, .IsComplete() the +---condition side. +---@param name ObjectiveName +---@return MissionObjective +function Objective(name) end + +---Unit-def reference by name; resolution to an id happens where Spring exists. +---@param name UnitDefName +---@return MissionUnitDefRef +function UnitDef(name) end + +---Named-unit reference: the condition side of one unit the mission's +---units.lua roster spawned. Validated against the roster at load — an +---unknown name is a load error, not a silent never-true condition. +---@param name MissionUnitName +---@return MissionUnitRef +function Unit(name) end + +---Declare one unit of the mission's opening world state. units.lua sandbox +---only — not injected into trigger files. Chain .At(fx, fz) (required, map +---fractions), .Named(name), .Grouped(group). +---@param unitDef MissionUnitDefRef +---@param team MissionTeamRole +---@return MissionSpawnChain +function Spawn(unitDef, team) end + +---Take a unit the team already owns instead of adding another, and give it a +---mission name. units.lua sandbox only. Chain .Named(name), .Grouped(group) +---and .OrSpawnAt(fx, fz) (required — it says where to build one when the team +---turns out to have none). +---@param unitDef MissionUnitDefRef +---@param team MissionTeamRole +---@return MissionClaimChain +function Claim(unitDef, team) end + +---@type { Player: MissionTeam } +Team = {} + diff --git a/bar-mission-kit/fixtures/modules/scavengers/types/actions.lua b/bar-mission-kit/fixtures/modules/scavengers/types/actions.lua new file mode 100644 index 00000000..9eedcf3a --- /dev/null +++ b/bar-mission-kit/fixtures/modules/scavengers/types/actions.lua @@ -0,0 +1,16 @@ +---@meta actions + +--- Scavengers' mission vocabulary. The verbs are Waves'; what this module +--- contributes is the set of packs that exist, so a mission file's +--- Scavengers.Skirmish is a checked name and not a string. + +--- The packs. Skirmish is pressure with no ending of its own — a mission's +--- triggers end it. Assault is a real fight. Horde is the multiplayer mode, +--- boss included. +---@class ScavengerPacks +---@field Skirmish WavePackRef +---@field Assault WavePackRef +---@field Horde WavePackRef + +---@type ScavengerPacks +Scavengers = {} diff --git a/bar-mission-kit/fixtures/modules/scavengers/types/mode_policy.lua b/bar-mission-kit/fixtures/modules/scavengers/types/mode_policy.lua new file mode 100644 index 00000000..5abab1ff --- /dev/null +++ b/bar-mission-kit/fixtures/modules/scavengers/types/mode_policy.lua @@ -0,0 +1,43 @@ +---@meta policy mode + +--- The scavengers mode vocabulary: what a preset under modules/scavengers/modes +--- may say. Declared here because a mode preset checks against the mode surface +--- of its OWN module — the trigger vocabulary in actions.lua is a different +--- language for a different kind of file, and the two never mix. +--- +--- The verbs come from the shared PvE grammar in modules/waves; the nouns are +--- the packs, so a mode dials the same Horde a mission could name. + +--- A wave pack as a mode noun. Every dial takes one, because a module can run +--- more than one director and a preset has to say which it is tuning. +---@class ScavengersPackNoun + +--- The Mode chain (scavengers vocabulary). Every verb returns the chain; the +--- chain IS the ModeConfig the preset file returns. The lock modifiers apply to +--- the option the preceding verb wrote, which is how Difficulty and Endless are +--- left open for the lobby while everything else is pinned. +---@class ScavengersModeChain +---@field Desc fun(desc: string): ScavengersModeChain +---@field Ranked fun(): ScavengersModeChain +---@field RetainValues fun(): ScavengersModeChain +---@field Hidden fun(): ScavengersModeChain +---@field Unlocked fun(): ScavengersModeChain +---@field Locked fun(): ScavengersModeChain +---@field Difficulty fun(pack: ScavengersPackNoun, difficulty: string): ScavengersModeChain +---@field Boss fun(pack: ScavengersPackNoun, count: integer, timeMultiplier: number?): ScavengersModeChain +---@field Grace fun(pack: ScavengersPackNoun, multiplier: number): ScavengersModeChain +---@field Pace fun(pack: ScavengersPackNoun, timeMultiplier: number, countMultiplier: number): ScavengersModeChain +---@field Placement fun(pack: ScavengersPackNoun, placement: WaveBurrowPlacement): ScavengersModeChain +---@field Endless fun(pack: ScavengersPackNoun, endless: boolean): ScavengersModeChain +--- Field a bot. The one thing the modoptions cannot express: scavengers are +--- activated by a scavengers AI being present, not by an option, which is what +--- keeps every existing lobby working. +---@field Bot fun(aiName: string): ScavengersModeChain + +---Start a mode chain; the key is the name's snake_case. +---@param name string +---@return ScavengersModeChain +function Mode(name) end + +---@type { Skirmish: ScavengersPackNoun, Assault: ScavengersPackNoun, Horde: ScavengersPackNoun } +Scavengers = {} diff --git a/bar-mission-kit/fixtures/modules/transfer/types/actions.lua b/bar-mission-kit/fixtures/modules/transfer/types/actions.lua new file mode 100644 index 00000000..35efbd50 --- /dev/null +++ b/bar-mission-kit/fixtures/modules/transfer/types/actions.lua @@ -0,0 +1,51 @@ +---@meta actions + +--- Transfer's actions, declared once for both grammars. A mode names one to +--- grant it, a mission calls one to perform it, and this is the declaration +--- both read — an action cannot mean one thing to a mode file and another to +--- a trigger file, because there is only the one entry. +--- +--- An action is callable where it can be performed (@overload) and carries +--- variants where a grant can be narrowed (.AtT2, .Constructors). Either +--- facet may be absent: Give cannot be granted, Assist cannot be performed. + +--- Narrowing a grant: the same action, said of one unit category or one tech +--- tier. Not callable — a mission performs the action, not a slice of it. +---@class TransferGrant +---@field domain string +---@field category string|nil +---@field tier integer|nil + +--- Units: grantable, narrowable, and performable. +---@class TransferUnits : TransferGrant +---@field AtT2 TransferGrant +---@field AtT3 TransferGrant +---@field Constructors TransferGrant +---@field Resource TransferGrant +---@overload fun(group: MissionUnitGroup, team: MissionTeam): MissionEffect + +--- Resources: grantable and narrowable; nothing performs it from a trigger. +---@class TransferResources : TransferGrant +---@field Metal TransferGrant +---@field Energy TransferGrant +---@field AtT2 TransferGrant +---@field AtT3 TransferGrant + +--- Fiat: performable, and deliberately not grantable — a mode has no say, so +--- there is no domain for a grant to be written against. +---@class TransferGive +---@overload fun(group: MissionUnitGroup, team: MissionTeam): MissionEffect + +---@class TransferActions +---@field Units TransferUnits +---@field Resources TransferResources +---@field Give TransferGive + +---@type TransferActions +Transfer = {} + +---@type TransferGrant +Take = {} + +---@type TransferGrant +Tech = {} diff --git a/bar-mission-kit/fixtures/modules/transfer/types/mode_policy.lua b/bar-mission-kit/fixtures/modules/transfer/types/mode_policy.lua new file mode 100644 index 00000000..72b4abd6 --- /dev/null +++ b/bar-mission-kit/fixtures/modules/transfer/types/mode_policy.lua @@ -0,0 +1,29 @@ +---@meta policy mode + +--- The sharing mode-preset surface: what modules/transfer/modes/*.lua files +--- author against (via mode_dsl.lua over modules/mode_builder.lua). + + +--- The Mode chain (sharing vocabulary). Every verb returns the chain; the +--- chain IS the ModeConfig the preset file returns. Modifiers (Hidden, +--- Unlocked, Locked) apply to the most recent policy. +---@class SharingModeChain +---@field Desc fun(desc: string): SharingModeChain +---@field Ranked fun(): SharingModeChain +---@field RetainValues fun(): SharingModeChain +---@field Hidden fun(): SharingModeChain +---@field Unlocked fun(): SharingModeChain +---@field Locked fun(): SharingModeChain +---@field Allow fun(noun: TransferGrant): SharingModeChain +---@field Deny fun(noun: TransferGrant): SharingModeChain +---@field Tax fun(noun: TransferGrant, rate: number): SharingModeChain +---@field Stun fun(noun: TransferGrant, seconds: number?): SharingModeChain +---@field Defer fun(noun: TransferGrant): SharingModeChain +---@field Delay fun(noun: TransferGrant, seconds: number): SharingModeChain +---@field Gate fun(noun: TransferGrant, t2: number, t3: number): SharingModeChain +---@field Open fun(noun: TransferGrant, t2: number, t3: number): SharingModeChain + +---Start a mode chain; the key is the name's snake_case. +---@param name string +---@return SharingModeChain +function Mode(name) end diff --git a/bar-mission-kit/fixtures/modules/waves/types/actions.lua b/bar-mission-kit/fixtures/modules/waves/types/actions.lua new file mode 100644 index 00000000..0998575a --- /dev/null +++ b/bar-mission-kit/fixtures/modules/waves/types/actions.lua @@ -0,0 +1,62 @@ +---@meta actions + +--- Waves' mission vocabulary, declared once for every grammar that names it. +--- The mission kit derives its authoring surface from these types, so the +--- alias and class names here are load-bearing beyond the checker. + +--- The mission dial. 1.0 is the pack's own pace; below is background +--- pressure, above is a siege. An ALIAS, not a bare number, because the +--- editor derives its slot names from param types — a `number` has nothing +--- to call itself in a sentence. +---@alias WaveIntensity number + +--- A wave pack: a flavor module's noun, naming a composition defined once in +--- that module. `name` doubles as the director's name and its savegame key. +---@class WavePackRef +---@field name string ".", the running director's name +---@field module string the flavor module that can rebuild the spec +---@field pack string which builder inside it + +--- Begin's chain. .Against is required — a director with no target has +--- nobody to attack; the rest are dials with sane defaults. +--- +--- It IS an effect, not merely effect-shaped: a Do takes the chain directly, +--- so every link returns something Do accepts and the statement reads as one +--- sentence however many dials it turns. +---@class MissionWavesChain : MissionEffect +---@field Against fun(team: MissionTeam): MissionWavesChain +---@field From fun(fx: number, fz: number): MissionWavesChain +---@field Intensity fun(intensity: WaveIntensity): MissionWavesChain + +---@class WavesBegin +---@overload fun(pack: WavePackRef): MissionWavesChain + +---@class WavesIntensify +---@overload fun(pack: WavePackRef, intensity: WaveIntensity): MissionEffect + +---@class WavesSurge +---@overload fun(pack: WavePackRef): MissionEffect + +---@class WavesEnd +---@overload fun(pack: WavePackRef): MissionEffect + +---@class WavesSpawned +---@overload fun(pack: WavePackRef, count: integer?): MissionCondition + +---@class WavesCleared +---@overload fun(pack: WavePackRef, count: integer?): MissionCondition + +---@class WavesBossDefeated +---@overload fun(pack: WavePackRef, count: integer?): MissionCondition + +---@class WavesActions +---@field Begin WavesBegin +---@field Intensify WavesIntensify +---@field Surge WavesSurge +---@field End WavesEnd +---@field Spawned WavesSpawned +---@field Cleared WavesCleared +---@field BossDefeated WavesBossDefeated + +---@type WavesActions +Waves = {} diff --git a/bar-mission-kit/src/graph.rs b/bar-mission-kit/src/graph.rs new file mode 100644 index 00000000..f8db9505 --- /dev/null +++ b/bar-mission-kit/src/graph.rs @@ -0,0 +1,117 @@ +//! Trigger-graph artifact: the mission's dependency structure as GraphViz +//! DOT, derived from the decorated AST. Edges follow objective state: +//! a trigger that Completes an objective feeds every trigger watching +//! IsComplete on it; MatchFlow.Victory/Defeat are terminal nodes. The DSL +//! doc's editor stage 2 (read-only trigger graph) as data — renderers can +//! come later without re-deriving anything. + +use crate::model::{MissionAst, Value}; + +pub fn dot(ast: &MissionAst) -> String { + let mut nodes = String::new(); + let mut edges = String::new(); + + for file in &ast.files { + for group in &file.groups { + for trigger in &group.triggers { + let id = node_id(&trigger.id); + let label = trigger.label.clone().unwrap_or_else(|| trigger.id.clone()); + nodes.push_str(&format!( + " {id} [label=\"{}\", shape=box];\n", + escape(&label) + )); + for step in &trigger.steps { + for arg in &step.args { + scan_value(&id, &step.verb, arg, &mut nodes, &mut edges); + } + } + } + } + } + + format!("digraph mission {{\n rankdir=LR;\n{nodes}{edges}}}\n") +} + +fn scan_value(trigger_id: &str, verb: &str, value: &Value, nodes: &mut String, edges: &mut String) { + let Value::Verb { path, calls, .. } = value else { + return; + }; + if path == "Objective" { + let Some(name) = calls + .first() + .and_then(|c| c.args.first()) + .and_then(|a| match a { + Value::String { value, .. } => Some(value.clone()), + _ => None, + }) + else { + return; + }; + let objective = format!("objective_{}", sanitize(&name)); + let chained = calls.iter().filter_map(|c| c.name.as_deref()).next(); + nodes.push_str(&format!( + " {objective} [label=\"{}\", shape=ellipse];\n", + escape(&name) + )); + match (verb, chained) { + (_, Some("Complete")) => { + edges.push_str(&format!(" {trigger_id} -> {objective};\n")); + } + (_, Some("IsComplete")) => { + edges.push_str(&format!(" {objective} -> {trigger_id};\n")); + } + _ => {} + } + } else if path == "MatchFlow.Victory" || path == "MatchFlow.Defeat" { + let terminal = if path.ends_with("Victory") { "VICTORY" } else { "DEFEAT" }; + nodes.push_str(&format!( + " {terminal} [shape=doublecircle];\n" + )); + edges.push_str(&format!(" {trigger_id} -> {terminal};\n")); + } + // nested verb args (e.g. conditions wrapping other verbs) + for call in calls { + for arg in &call.args { + scan_value(trigger_id, verb, arg, nodes, edges); + } + } +} + +fn node_id(trigger_id: &str) -> String { + format!("t_{}", sanitize(trigger_id)) +} + +fn sanitize(text: &str) -> String { + text.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect() +} + +fn escape(text: &str) -> String { + text.replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + #[test] + fn hello_pawns_graph_has_the_objective_cascade() { + let source = r#" +When(Team.Player.Has(UnitDef("armpw"), 3)) + .Do(Objective("build_pawns").Complete()) + +When(Objective("build_pawns").IsComplete()) + .Do(MatchFlow.Victory(Team.Player)) +"#; + let rec = crate::recognizer::recognize_file("triggers/win.lua", source).unwrap(); + let ast = crate::model::MissionAst { + version: 1, + generation: 1, + files: vec![rec.file], + surface: serde_json::Value::Null, + }; + let dot = super::dot(&ast); + assert!(dot.contains("t_triggers_win_lua_1 -> objective_build_pawns"), "{dot}"); + assert!(dot.contains("objective_build_pawns -> t_triggers_win_lua_2"), "{dot}"); + assert!(dot.contains("t_triggers_win_lua_2 -> VICTORY"), "{dot}"); + } +} diff --git a/bar-mission-kit/src/http.rs b/bar-mission-kit/src/http.rs new file mode 100644 index 00000000..01886a6d --- /dev/null +++ b/bar-mission-kit/src/http.rs @@ -0,0 +1,363 @@ +//! Loopback HTTP adapter over the filesystem contract. GET serves the +//! artifacts serve already writes; POST drops intents into the same channels +//! the game uses. No shared state with the serve loop — the regeneration +//! cycle is the confirmation, same as every other client. +//! +//! GET /whoami -> which editor dir this process is serving +//! GET /view -> mission_view.json +//! GET /status -> status.json +//! POST /edit -> /edits/http__.json (validated shape) +//! POST /open -> /open_request.json +//! POST /undo -> /undo_request.json +//! POST /select_mission -> /select_mission.json + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Minimal JSON string literal. Only the two escapes a filesystem path can +/// realistically contain — this is not a general encoder and does not pretend +/// to be one. +fn json_string(raw: &str) -> String { + let mut out = String::with_capacity(raw.len() + 2); + out.push('"'); + for c in raw.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Ask whoever currently holds `addr` which editor dir they are serving. +/// `None` means nothing answered, or answered with something we cannot read — +/// in both cases the caller should treat the port as belonging to a stranger. +pub fn probe_editor_dir(addr: &str) -> Option { + let mut stream = TcpStream::connect(addr).ok()?; + let timeout = std::time::Duration::from_millis(700); + stream.set_read_timeout(Some(timeout)).ok()?; + stream.set_write_timeout(Some(timeout)).ok()?; + write!( + stream, + "GET /whoami HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + ) + .ok()?; + let mut raw = String::new(); + stream.read_to_string(&mut raw).ok()?; + let body = raw.split("\r\n\r\n").nth(1)?; + let key = "\"editor_dir\":\""; + let start = body.find(key)? + key.len(); + let rest = &body[start..]; + let mut out = String::new(); + let mut chars = rest.chars(); + while let Some(c) = chars.next() { + match c { + '"' => return Some(out), + '\\' => out.push(chars.next()?), + c => out.push(c), + } + } + None +} + +/// Why a bind attempt did not produce a server. +#[derive(Debug)] +pub enum BindError { + /// Somebody already holds the address. The caller has to decide whether + /// that somebody is a duplicate of itself or a different workspace. + InUse, + Other, +} + +/// Bind and serve on a background thread. Returns the bound port (for tests: +/// pass port 0 to get an ephemeral one). +#[cfg(test)] +pub fn spawn(addr: &str, editor_dir: PathBuf) -> Option { + try_spawn(addr, editor_dir).ok() +} + +/// Bind, distinguishing "taken" from every other failure. A taken port is a +/// normal thing that happens the moment two workspaces are open at once; it is +/// not an error until the caller has worked out whose it is. +pub fn try_spawn(addr: &str, editor_dir: PathBuf) -> Result { + let listener = match TcpListener::bind(addr) { + Ok(l) => l, + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => return Err(BindError::InUse), + Err(e) => { + eprintln!("http: cannot bind {addr}: {e}"); + return Err(BindError::Other); + } + }; + let Some(port) = listener.local_addr().ok().map(|a| a.port()) else { + eprintln!("http: bound {addr} but it reports no local address"); + return Err(BindError::Other); + }; + eprintln!("http: listening on http://127.0.0.1:{port}"); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let dir = editor_dir.clone(); + std::thread::spawn(move || { + let _ = handle(stream, dir); + }); + } + }); + Ok(port) +} + +fn handle(mut stream: TcpStream, editor_dir: PathBuf) -> std::io::Result<()> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("").to_string(); + let path = parts + .next() + .unwrap_or("") + .split('?') + .next() + .unwrap_or("") + .to_string(); + + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 || line.trim_end().is_empty() { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body)?; + + match (method.as_str(), path.as_str()) { + ("OPTIONS", _) => respond(&mut stream, 204, "text/plain", b""), + ("GET", "/") => respond( + &mut stream, + 200, + "text/html; charset=utf-8", + include_bytes!("../web/terminal.html"), + ), + // Identity, so a second invocation can tell whether the process + // already holding this port is itself in another window or a different + // workspace entirely. Cheap, and needs no state of its own. + ("GET", "/whoami") => { + let body = format!( + "{{\"editor_dir\":{},\"pid\":{}}}", + json_string(&editor_dir.to_string_lossy()), + std::process::id() + ); + respond(&mut stream, 200, "application/json", body.as_bytes()) + } + ("GET", "/view") => respond_file(&mut stream, editor_dir.join("mission_view.json")), + ("GET", "/ast") => respond_file(&mut stream, editor_dir.join("mission_ast.json")), + ("GET", "/status") => respond_file(&mut stream, editor_dir.join("status.json")), + ("GET", "/state") => respond_file(&mut stream, editor_dir.join("state.json")), + ("GET", "/open_request") => respond_file(&mut stream, editor_dir.join("open_target.json")), + ("POST", "/edit") => respond_edit(&mut stream, &editor_dir, &body), + // Undo drops an intent like everything else — the serve loop decides + // what it means and whether it is allowed. + ("POST", "/undo") => { + // An empty body means "the last thing I did, wherever it was". + let payload: &[u8] = if body.is_empty() { b"{}" } else { &body }; + match std::fs::write(editor_dir.join("undo_request.json"), payload) { + Ok(()) => respond(&mut stream, 202, "application/json", b"{\"queued\":true}"), + Err(e) => respond(&mut stream, 400, "text/plain", e.to_string().as_bytes()), + } + } + ("POST", "/open") => { + if serde_json::from_slice::(&body).is_err() { + return respond(&mut stream, 400, "text/plain", b"bad open request"); + } + std::fs::write(editor_dir.join("open_request.json"), &body)?; + respond(&mut stream, 200, "application/json", b"{\"ok\":true}") + } + ("POST", "/select_mission") => { + if serde_json::from_slice::(&body).is_err() { + return respond(&mut stream, 400, "text/plain", b"bad select_mission"); + } + std::fs::write(editor_dir.join("select_mission.json"), &body)?; + respond(&mut stream, 200, "application/json", b"{\"ok\":true}") + } + _ => respond(&mut stream, 404, "text/plain", b"not found"), + } +} + +/// The intent lands in edits/ for the serve loop to apply through the +/// grammar gate; 202 means accepted for validation, not applied. +fn respond_edit(stream: &mut TcpStream, editor_dir: &PathBuf, body: &[u8]) -> std::io::Result<()> { + if serde_json::from_slice::(body).is_err() { + return respond(stream, 400, "text/plain", b"bad edit intent"); + } + let edits_dir = editor_dir.join("edits"); + std::fs::create_dir_all(&edits_dir)?; + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let n = SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::fs::write(edits_dir.join(format!("http_{millis}_{n}.json")), body)?; + respond(stream, 202, "application/json", b"{\"ok\":true}") +} + +fn respond_file(stream: &mut TcpStream, path: PathBuf) -> std::io::Result<()> { + match std::fs::read(&path) { + Ok(bytes) => respond(stream, 200, "application/json", &bytes), + Err(_) => respond(stream, 404, "text/plain", b"artifact not written yet"), + } +} + +fn respond(stream: &mut TcpStream, code: u16, content_type: &str, body: &[u8]) -> std::io::Result<()> { + let reason = match code { + 200 => "OK", + 202 => "Accepted", + 204 => "No Content", + 400 => "Bad Request", + _ => "Not Found", + }; + // CORS: the VS Code webview origin is vscode-webview://, browsers send + // preflights; loopback-only bind is the actual security boundary. + let head = format!( + "HTTP/1.1 {code} {reason}\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {}\r\n\ + Cache-Control: no-store\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\ + Access-Control-Allow-Headers: content-type\r\n\ + Connection: close\r\n\r\n", + body.len() + ); + stream.write_all(head.as_bytes())?; + stream.write_all(body) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(port: u16, raw: &str) -> String { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream.write_all(raw.as_bytes()).unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + response + } + + fn tmpdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("bar-mission-kit-http-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn a_second_serve_can_tell_whose_port_it_is() { + // The whole point of /whoami: when the bind fails, the loser has to + // decide between "that is me in another window" (do nothing) and "that + // is a different checkout" (move aside). It cannot decide without this. + let dir = std::env::temp_dir().join(format!("kit_whoami_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let port = spawn("127.0.0.1:0", dir.clone()).unwrap(); + let addr = format!("127.0.0.1:{port}"); + + let seen = probe_editor_dir(&addr).expect("the holder should answer"); + assert_eq!(std::path::PathBuf::from(seen), dir); + + // Binding it again is refused as taken, not as some other failure. + assert!(matches!( + try_spawn(&addr, dir.clone()), + Err(BindError::InUse) + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn probing_something_that_is_not_a_serve_answers_nothing() { + // A stranger on the port must not be mistaken for our own editor dir, + // or a second serve would exit thinking it was already running. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = format!("127.0.0.1:{}", listener.local_addr().unwrap().port()); + std::thread::spawn(move || { + for mut s in listener.incoming().flatten() { + let _ = s.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n"); + } + }); + assert_eq!(probe_editor_dir(&addr), None); + } + + #[test] + fn view_is_served_and_edits_land_in_the_channel() { + let dir = tmpdir("roundtrip"); + std::fs::write(dir.join("mission_view.json"), "{\"generation\":3}").unwrap(); + let port = spawn("127.0.0.1:0", dir.clone()).unwrap(); + + let response = request(port, "GET /view HTTP/1.1\r\n\r\n"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.contains("{\"generation\":3}")); + assert!(response.contains("Access-Control-Allow-Origin: *")); + + let intent = "{\"file\":\"t.lua\",\"start\":0,\"end\":1,\"new_text\":\"x\"}"; + let response = request( + port, + &format!("POST /edit HTTP/1.1\r\nContent-Length: {}\r\n\r\n{intent}", intent.len()), + ); + assert!(response.starts_with("HTTP/1.1 202"), "{response}"); + let edits: Vec<_> = std::fs::read_dir(dir.join("edits")).unwrap().flatten().collect(); + assert_eq!(edits.len(), 1); + assert_eq!(std::fs::read_to_string(edits[0].path()).unwrap(), intent); + } + + #[test] + fn garbage_intents_are_refused_before_the_channel() { + let dir = tmpdir("garbage"); + let port = spawn("127.0.0.1:0", dir.clone()).unwrap(); + let response = request(port, "POST /edit HTTP/1.1\r\nContent-Length: 4\r\n\r\nnope"); + assert!(response.starts_with("HTTP/1.1 400"), "{response}"); + assert!(!dir.join("edits").exists() || std::fs::read_dir(dir.join("edits")).unwrap().count() == 0); + } + + #[test] + fn the_browser_terminal_is_served_at_the_root() { + let dir = tmpdir("terminal"); + let port = spawn("127.0.0.1:0", dir).unwrap(); + let response = request(port, "GET / HTTP/1.1\r\n\r\n"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.contains("text/html")); + assert!(response.contains("Campaign Editor")); + // query strings must not defeat the router (the sidebar loads /?embed=1) + let response = request(port, "GET /?embed=1 HTTP/1.1\r\n\r\n"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + } + + #[test] + fn mission_selections_land_in_the_channel() { + let dir = tmpdir("select"); + let port = spawn("127.0.0.1:0", dir.clone()).unwrap(); + let body = "{\"name\":\"cm8_ashfall\"}"; + let response = request( + port, + &format!("POST /select_mission HTTP/1.1\r\nContent-Length: {}\r\n\r\n{body}", body.len()), + ); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert_eq!(std::fs::read_to_string(dir.join("select_mission.json")).unwrap(), body); + let response = request(port, "POST /select_mission HTTP/1.1\r\nContent-Length: 4\r\n\r\nnope"); + assert!(response.starts_with("HTTP/1.1 400"), "{response}"); + } + + #[test] + fn missing_artifacts_are_a_clean_404() { + let dir = tmpdir("missing"); + let port = spawn("127.0.0.1:0", dir).unwrap(); + let response = request(port, "GET /view HTTP/1.1\r\n\r\n"); + assert!(response.starts_with("HTTP/1.1 404"), "{response}"); + } +} diff --git a/bar-mission-kit/src/main.rs b/bar-mission-kit/src/main.rs new file mode 100644 index 00000000..7e2b3832 --- /dev/null +++ b/bar-mission-kit/src/main.rs @@ -0,0 +1,668 @@ +//! bar-mission-kit: the mission DSL recognizer + validator CLI. +//! +//! One grammar, two consumers: +//! parse — emit the decorated mission AST as JSON (the RML form's input, +//! and later the write-back layer's document model) +//! check — same walk, findings only; nonzero exit on a non-conforming +//! mission (CI's validator) + +mod graph; +mod http; +mod model; +mod recognizer; +mod serve; +mod types; +mod view; + +use clap::{Parser, Subcommand}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "bar-mission-kit", about = "Mission DSL recognizer/validator")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Emit the decorated mission AST as JSON. + Parse { + /// Mission trigger files, or directories to scan for triggers/*.lua + paths: Vec, + /// Write JSON here instead of stdout + #[arg(long)] + out: Option, + }, + /// Validate mission files; print findings, exit nonzero on any. + Check { + paths: Vec, + }, + /// Run the editor service: watch missions, regenerate the AST artifact, + /// apply UI edit intents, handle open-in-editor requests. + Serve { + /// Mission directory to watch (e.g. .../modules/missions/hello_pawns). + /// Optional with --missions-root. + missions_dir: Option, + /// Follow the game: watch this missions root and re-scope to whatever + /// mission the game arms (active_mission.json from the bridge). + #[arg(long)] + missions_root: Option, + /// Directory for the artifact + edits/ + open_request.json + #[arg(long)] + editor_dir: PathBuf, + /// Shell template for open-in-editor. Empty (the default) means the + /// VS Code extension owns opening: it routes to the window whose + /// workspace contains the mission. Set e.g. "code -g {file}:{line}" + /// for extension-less setups. + #[arg(long, default_value = "")] + editor_cmd: String, + /// Loopback HTTP address for editor clients (VS Code webview) + #[arg(long, default_value = "127.0.0.1:8571")] + listen: String, + }, +} + +/// Record the port actually bound, so a client can find a serve that had to +/// move aside. Written next to everything else the editor dir already carries. +fn announce_port(editor_dir: &std::path::Path, port: u16) { + let _ = std::fs::create_dir_all(editor_dir); + let _ = std::fs::write( + editor_dir.join("serve_port.json"), + format!("{{\"port\":{port},\"pid\":{}}}", std::process::id()), + ); +} + +fn collect_lua_files(paths: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + for path in paths { + if path.is_dir() { + // The loader's contract: a mission is its triggers/ dir plus an + // optional units.lua roster. Scanning wider (missions root mode) + // must not recognize lib/gadget code. + for pattern in [ + format!("{}/**/triggers/*.lua", path.display()), + format!("{}/**/units.lua", path.display()), + format!("{}/**/modes/*.lua", path.display()), + ] { + for entry in glob::glob(&pattern).expect("valid glob").flatten() { + // spec/modes/, spec/**/triggers/ etc. are busted's, not ours. + // actions/ is the framework's action slot, and a module + // whose action happens to be called units.lua is not a + // mission roster — transfer has exactly that. + if entry + .components() + .any(|c| c.as_os_str() == "spec" || c.as_os_str() == "actions") + { + continue; + } + // modules/modes is a MODULE (mode infrastructure); its own + // files aren't presets. Presets live in /modes/. + fn dir_name(p: Option<&std::path::Path>) -> &str { + p.and_then(|d| d.file_name()).and_then(|n| n.to_str()).unwrap_or("") + } + let parent = entry.parent(); + if dir_name(parent) == "modes" + && dir_name(parent.and_then(|p| p.parent())) == "modules" + { + continue; + } + files.push(entry); + } + } + let roster = path.join("units.lua"); + if roster.is_file() { + files.push(roster); + } + } else { + files.push(path.clone()); + } + } + files.sort(); + files.dedup(); + files +} + +fn display_path(file: &Path, roots: &[PathBuf]) -> String { + for root in roots { + if let Ok(rel) = file.strip_prefix(root) { + return rel.display().to_string(); + } + } + file.display().to_string() +} + +/// Every verb a module publishes becomes a palette entry, with the example call +/// built from its signature. Labels and compositions come from the overlay; +/// nothing here is a second copy of the vocabulary. +fn palette( + overlay: &mut serde_json::Map, + types: &types::TypeSurface, + modules: &[types::ModuleInfo], +) { + let labels: std::collections::BTreeMap = overlay + .get("labels") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + let derived = types.roles(); + for (role, paths) in [ + ("conditions", if modules.is_empty() { derived.conditions.clone() } + else { modules.iter().flat_map(|m| m.conditions.iter().cloned()).collect() }), + ("effects", if modules.is_empty() { derived.effects.clone() } + else { modules.iter().flat_map(|m| m.effects.iter().cloned()).collect() }), + ] { + let mut entries: Vec = Vec::new(); + for path in paths { + let Some(template) = types.template_for(&path) else { continue }; + entries.push(serde_json::json!({ + "label": labels.get(&path).cloned().unwrap_or_else(|| humanize(&path)), + "template": template, + })); + } + if let Some(extra) = overlay.get(role).and_then(|v| v.as_array()) { + entries.extend(extra.iter().cloned()); + } + overlay.insert(role.into(), serde_json::Value::Array(entries)); + } +} + +/// "Transfer.Units" -> "Transfer units". A label the overlay does not override. +fn humanize(path: &str) -> String { + let mut words: Vec = Vec::new(); + for (i, segment) in path.split('.').enumerate() { + let mut current = String::new(); + for (j, ch) in segment.char_indices() { + if ch.is_uppercase() && j > 0 { + words.push(std::mem::take(&mut current)); + } + current.push(if i == 0 && j == 0 { ch } else { ch.to_ascii_lowercase() }); + } + words.push(current); + } + words.retain(|w| !w.is_empty()); + words.join(" ") +} + +pub(crate) const MISSION_SURFACE: &str = include_str!("../surfaces/missions.json"); + +pub fn collect_ast(paths: &[PathBuf], generation: u64) -> (model::MissionAst, Vec) { + let files = collect_lua_files(paths); + // The grammar's source of truth: the game's LuaCATS types. Resolution is + // PER FILE — each file answers to its own module's published surface + // (nearest marked types/ dir), so a sharing mode preset and a mission + // trigger file check against different vocabularies in one walk. + let mut surfaces: std::collections::HashMap<(PathBuf, &'static str), std::rc::Rc> = + std::collections::HashMap::new(); + let mut surface_for = |file: &PathBuf| -> std::rc::Rc { + // A preset and a trigger file can live in one module and are written in + // different vocabularies, so the policy is part of the cache key. Keyed + // on the types dir alone, whichever kind was seen first would answer + // for both — and the other would be checked against the wrong grammar. + let policy = match recognizer::FileKind::of(&file.to_string_lossy()) { + recognizer::FileKind::ModePreset => "mode", + recognizer::FileKind::Statements => "trigger", + }; + let key = types::TypeSurface::types_dir_near_policy(file, policy) + .unwrap_or_else(|| PathBuf::from("")); + surfaces + .entry((key, policy)) + .or_insert_with(|| { + std::rc::Rc::new(types::TypeSurface::load_near_policy( + std::slice::from_ref(file), + policy, + )) + }) + .clone() + }; + + let mut surface: serde_json::Value = + serde_json::from_str(MISSION_SURFACE).expect("valid surface overlay"); + let mut ast = model::MissionAst { version: 1, generation, files: Vec::new(), surface: serde_json::Value::Null }; + let mut findings = Vec::new(); + let mut enums: std::collections::BTreeMap> = Default::default(); + for file in &files { + let rel = display_path(file, paths); + let source = match std::fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + findings.push(model::Finding { + path: rel, + line: 0, + message: format!("cannot read: {e}"), + span: None, + }); + continue; + } + }; + let type_surface = surface_for(file); + enums.extend(type_surface.enums()); + match recognizer::recognize_file_with(&rel, &source, &type_surface) { + Ok(recognized) => { + findings.extend(recognized.findings); + ast.files.push(recognized.file); + } + Err(e) => findings.push(model::Finding { + path: rel, + line: 0, + message: format!("parse error: {e}"), + span: None, + }), + } + } + if let Some(overlay) = surface.as_object_mut() { + // Derived editor enums ride the artifact so every terminal renders + // literal-union parameters as pickers. + overlay.insert("enums".into(), serde_json::to_value(enums).expect("serializable enums")); + // The module explorer: every module publishing a marked surface, + // discovered the same way the grammar is. + if let Some(root) = files + .first() + .and_then(|f| types::TypeSurface::types_dir_near(f)) + .and_then(|t| t.parent().and_then(|m| m.parent()).map(|p| p.to_path_buf())) + { + let modules = types::explore_modules(&root); + palette(overlay, &types::TypeSurface::load_near(&files), &modules); + overlay.insert( + "modules".into(), + serde_json::to_value(modules).expect("serializable modules"), + ); + } + } + if surface + .get("conditions") + .and_then(|v| v.as_array()) + .is_none_or(|a| a.is_empty()) + { + // No modules tree in reach (a mission opened on its own): derive the + // palette from the types the kit mirrors, so the editor is still usable. + if let Some(overlay) = surface.as_object_mut() { + palette(overlay, types::TypeSurface::builtin(), &[]); + } + } + ast.surface = surface; + findings.extend(cross_check_names(&ast.files)); + (ast, findings) +} + +/// Cross-file noun check: every Unit()/Units reference must name something +/// units.lua declared. Only meaningful when a roster was walked — a partial +/// (single-file) invocation stays quiet. +fn cross_check_names(files: &[model::FileAst]) -> Vec { + if !files.iter().any(|f| f.path.ends_with("units.lua")) { + return Vec::new(); + } + let unit_defs: std::collections::HashSet<&str> = + files.iter().flat_map(|f| f.unit_defs.iter().map(String::as_str)).collect(); + let group_defs: std::collections::HashSet<&str> = + files.iter().flat_map(|f| f.group_defs.iter().map(String::as_str)).collect(); + let mut findings = Vec::new(); + for file in files { + for r in &file.unit_refs { + if !unit_defs.contains(r.name.as_str()) { + findings.push(model::Finding { + path: file.path.clone(), + line: r.line, + message: format!("Unit(\"{}\"): units.lua declares no such name", r.name), + span: None, + }); + } + } + for r in &file.group_refs { + if !group_defs.contains(r.name.as_str()) { + findings.push(model::Finding { + path: file.path.clone(), + line: r.line, + message: format!("group \"{}\": units.lua declares no such group", r.name), + span: None, + }); + } + } + } + findings +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.command { + Command::Parse { paths, out } => { + let (ast, findings) = collect_ast(&paths, 1); + for f in &findings { + eprintln!("{}:{}: {}", f.path, f.line, f.message); + } + let json = serde_json::to_string_pretty(&ast).expect("serializable AST"); + match out { + Some(path) => { + if let Err(e) = std::fs::write(&path, json) { + eprintln!("cannot write {}: {e}", path.display()); + return ExitCode::FAILURE; + } + } + None => println!("{json}"), + } + ExitCode::SUCCESS + } + Command::Check { paths } => { + let (_ast, findings) = collect_ast(&paths, 1); + for f in &findings { + println!("{}:{}: {}", f.path, f.line, f.message); + } + if findings.is_empty() { + println!("OK"); + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } + Command::Serve { missions_dir, missions_root, editor_dir, editor_cmd, listen } => { + let Some(initial) = missions_dir.or_else(|| missions_root.clone()) else { + eprintln!("serve needs a missions dir or --missions-root"); + return ExitCode::FAILURE; + }; + // A taken port is the normal consequence of the VS Code extension + // already serving this workspace. Silently carrying on without HTTP + // was the worst of the options: the second process stayed alive, + // invisible, writing the SAME editor dir as the first, so whichever + // wrote last won and the panel showed whichever that was. + match http::try_spawn(&listen, editor_dir.clone()) { + Ok(port) => announce_port(&editor_dir, port), + Err(http::BindError::Other) => return ExitCode::FAILURE, + Err(http::BindError::InUse) => { + let ours = editor_dir.canonicalize().unwrap_or_else(|_| editor_dir.clone()); + let theirs = http::probe_editor_dir(&listen) + .map(std::path::PathBuf::from) + .map(|p| p.canonicalize().unwrap_or(p)); + if theirs.as_deref() == Some(ours.as_path()) { + // Same workspace, already served. Doing nothing is the + // correct outcome, and it is a success, not a failure — + // the caller asked for this directory to be served and + // it is being served. + eprintln!( + "serve: {} is already being served at http://{listen} — nothing to do", + ours.display() + ); + return ExitCode::SUCCESS; + } + // Somebody else's workspace. Step aside onto a free port so + // two checkouts can be open at once. + match http::try_spawn("127.0.0.1:0", editor_dir.clone()) { + Ok(port) => { + match theirs { + Some(dir) => eprintln!( + "serve: http://{listen} is serving {} — using port {port} instead", + dir.display() + ), + None => eprintln!( + "serve: http://{listen} is taken by something that is not a mission serve — using port {port} instead" + ), + } + announce_port(&editor_dir, port); + } + Err(_) => { + eprintln!("serve: no free loopback port available"); + return ExitCode::FAILURE; + } + } + } + } + serve::Server::new(initial, editor_dir, editor_cmd, missions_root).run() + } + } +} + +#[cfg(test)] +mod tests { + use crate::model::Value; + + const WIN: &str = r#" +When(Team.Player.Has(UnitDef("armpw"), 3)) + .Do(Objective("build_pawns").Complete()) + +When(Objective("build_pawns").IsComplete()) + .Do(MatchFlow.Victory(Team.Player)) +"#; + + #[test] + fn recognizes_the_hello_pawns_mission() { + let rec = crate::recognizer::recognize_file("triggers/win.lua", WIN).unwrap(); + assert!(rec.findings.is_empty(), "findings: {:?}", rec.findings); + assert_eq!(rec.file.groups.len(), 1); + let triggers = &rec.file.groups[0].triggers; + assert_eq!(triggers.len(), 2); + assert_eq!(triggers[0].id, "triggers/win.lua:1"); + let steps: Vec<&str> = triggers[0].steps.iter().map(|s| s.verb.as_str()).collect(); + assert_eq!(steps, vec!["When", "Do"]); + + match &triggers[0].steps[0].args[0] { + Value::Verb { path, calls, .. } => { + assert_eq!(path, "Team.Player.Has"); + assert_eq!(calls.len(), 1); + match &calls[0].args[1] { + Value::Number { value, .. } => assert_eq!(*value, 3.0), + other => panic!("expected count literal, got {other:?}"), + } + } + other => panic!("expected verb condition, got {other:?}"), + } + + match &triggers[1].steps[1].args[0] { + Value::Verb { path, calls, .. } => { + assert_eq!(path, "MatchFlow.Victory"); + match &calls[0].args[0] { + Value::Name { path, .. } => assert_eq!(path, "Team.Player"), + other => panic!("expected Team.Player ref, got {other:?}"), + } + } + other => panic!("expected verb effect, got {other:?}"), + } + } + + #[test] + fn chained_invocations_survive() { + let src = r#" +When(Region("north").EnteredBy(Team.Player, { count = 5 })) + .Do(Wave.Define("flank").Route(Path("east")).Spawn()) +"#; + let rec = crate::recognizer::recognize_file("triggers/w.lua", src).unwrap(); + assert!(rec.findings.is_empty(), "findings: {:?}", rec.findings); + let t = &rec.file.groups[0].triggers[0]; + match &t.steps[1].args[0] { + Value::Verb { path, calls, .. } => { + assert_eq!(path, "Wave.Define"); + let names: Vec> = + calls.iter().map(|c| c.name.as_deref()).collect(); + assert_eq!(names, vec![None, Some("Route"), Some("Spawn")]); + } + other => panic!("expected verb, got {other:?}"), + } + } + + #[test] + fn function_bodies_are_findings() { + let src = "When(C()).Do(function() end)\n"; + let rec = crate::recognizer::recognize_file("triggers/bad.lua", src).unwrap(); + assert!(rec + .findings + .iter() + .any(|f| f.message.contains("closure-free"))); + } + + #[test] + fn non_chain_statements_are_findings() { + let src = "local x = 1\n"; + let rec = crate::recognizer::recognize_file("triggers/bad.lua", src).unwrap(); + assert_eq!(rec.findings.len(), 1); + assert_eq!(rec.file.opaque.len(), 1); + } + + #[test] + fn group_and_label_decorators_shape_the_tree() { + let src = r#" +---@group("Waves") +---@label("First blood") +When(C()).Do(E()) +"#; + let rec = crate::recognizer::recognize_file("triggers/d.lua", src).unwrap(); + assert_eq!(rec.file.groups.len(), 1); + assert_eq!(rec.file.groups[0].label.as_deref(), Some("Waves")); + assert_eq!( + rec.file.groups[0].triggers[0].label.as_deref(), + Some("First blood") + ); + } + + #[test] + fn semantics_objectives_and_insert_points_are_stamped() { + let rec = crate::recognizer::recognize_file("triggers/win.lua", WIN).unwrap(); + assert_eq!(rec.file.objectives, vec!["build_pawns".to_string()]); + let t1 = &rec.file.groups[0].triggers[0]; + match &t1.steps[0].args[0] { + Value::Verb { calls, .. } => { + match &calls[0].args[0] { + Value::Verb { calls, .. } => match &calls[0].args[0] { + Value::String { semantic, .. } => { + assert_eq!(semantic.as_deref(), Some("unit_def_name")) + } + other => panic!("expected unit string, got {other:?}"), + }, + other => panic!("expected UnitDef verb, got {other:?}"), + } + match &calls[0].args[1] { + Value::Number { semantic, .. } => { + assert_eq!(semantic.as_deref(), Some("count")) + } + other => panic!("expected count, got {other:?}"), + } + } + other => panic!("expected Has verb, got {other:?}"), + } + let at = t1.insert_effect_at; + assert!(WIN[..at].trim_end().ends_with(".Do(Objective(\"build_pawns\").Complete())"), "{}", &WIN[..at]); + } + + #[test] + fn a_chain_without_do_is_a_finding() { + let src = "When(C()).Once()\n"; + let rec = crate::recognizer::recognize_file("triggers/r.lua", src).unwrap(); + assert!(rec.findings.iter().any(|f| f.message.contains("no Do"))); + } + + #[test] + fn a_leftover_register_is_named_explicitly() { + let src = "When(C()).Do(E()).Register()\n"; + let rec = crate::recognizer::recognize_file("triggers/r.lua", src).unwrap(); + assert!(rec.findings.iter().any(|f| f.message.contains("Register is gone"))); + } + + #[test] + fn an_undeclared_statement_verb_is_a_finding() { + let src = "Spwan(UnitDef(\"corlab\"), \"gaia\").At(0.1, 0.1)\n"; + let rec = crate::recognizer::recognize_file("units.lua", src).unwrap(); + assert!(rec.findings.iter().any(|f| f.message.contains("unknown statement verb 'Spwan'") + && f.message.contains("When") + && f.message.contains("Spawn")), "{:?}", rec.findings); + assert_eq!(rec.file.opaque.len(), 1); + } + + #[test] + fn spawn_chains_are_recognized_from_the_types() { + let src = "Spawn(UnitDef(\"corlab\"), \"gaia\")\n\t.At(0.42, 0.42)\n\t.Named(\"hub\")\n\t.Grouped(\"outpost\")\n"; + let rec = crate::recognizer::recognize_file("units.lua", src).unwrap(); + assert!(rec.findings.is_empty(), "findings: {:?}", rec.findings); + let steps: Vec<&str> = rec.file.groups[0].triggers[0].steps.iter().map(|s| s.verb.as_str()).collect(); + assert_eq!(steps, vec!["Spawn", "At", "Named", "Grouped"]); + assert_eq!(rec.file.unit_defs, vec!["hub".to_string()]); + assert_eq!(rec.file.group_defs, vec!["outpost".to_string()]); + assert!(rec.file.unit_refs.is_empty()); + match &rec.file.groups[0].triggers[0].steps[0].args[1] { + Value::String { semantic, .. } => assert_eq!(semantic.as_deref(), Some("team_role")), + other => panic!("expected team role string, got {other:?}"), + } + } + + #[test] + fn a_spawn_without_at_is_a_finding_and_unknown_chain_verbs_name_the_chain() { + let src = "Spawn(UnitDef(\"corlab\"), \"gaia\").Armed(true)\n"; + let rec = crate::recognizer::recognize_file("units.lua", src).unwrap(); + assert!(rec.findings.iter().any(|f| f.message.contains("no At")), "{:?}", rec.findings); + assert!(rec.findings.iter().any(|f| f.message.contains("unknown chain verb 'Armed'") + && f.message.contains("At")), "{:?}", rec.findings); + } + + #[test] + fn trigger_files_reference_roster_names_for_the_cross_check() { + let src = "When(Unit(\"hub\").IsDestroyed())\n\t.Do(Transfer.Units(\"outpost\", Team.Player))\n"; + let rec = crate::recognizer::recognize_file("triggers/t.lua", src).unwrap(); + let units: Vec<&str> = rec.file.unit_refs.iter().map(|r| r.name.as_str()).collect(); + let groups: Vec<&str> = rec.file.group_refs.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(units, vec!["hub"]); + assert_eq!(groups, vec!["outpost"]); + assert!(rec.file.unit_defs.is_empty()); + } + + #[test] + fn mode_presets_recognize_with_their_import_preamble_and_return_chain() { + let dir = std::env::temp_dir().join(format!("bmk-modes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("types")).unwrap(); + std::fs::create_dir_all(dir.join("modes")).unwrap(); + std::fs::write( + dir.join("types/dsl.lua"), + "---@meta dsl\n\n---@class TestModeChain\n---@field Desc fun(d: string): TestModeChain\n---@field Deny fun(n: table): TestModeChain\n\n---@param name string\n---@return TestModeChain\nfunction Mode(name) end\n", + ) + .unwrap(); + std::fs::write( + dir.join("modes/strict.lua"), + "local ModeDSL = VFS.Include(\"modules/x/mode_dsl.lua\")\nlocal Mode, Share = ModeDSL.Mode, ModeDSL.Share\n\nreturn Mode(\"Strict\")\n\t.Desc(\"No sharing, taxed at -1.\")\n\t.Deny(Share.Resources)\n", + ) + .unwrap(); + + let (ast, findings) = crate::collect_ast(&[dir.clone()], 1); + assert!(findings.is_empty(), "{:?}", findings.iter().map(|f| &f.message).collect::>()); + let steps: Vec<&str> = ast.files[0].groups[0].triggers[0] + .steps + .iter() + .map(|s| s.verb.as_str()) + .collect(); + assert_eq!(steps, vec!["Mode", "Desc", "Deny"]); + match &ast.files[0].groups[0].triggers[0].steps[1].args[0] { + Value::String { value, .. } => assert!(value.contains("-1")), + other => panic!("expected desc string, got {other:?}"), + } + // a real (non-import) local is still outside the surface + std::fs::write( + dir.join("modes/bad.lua"), + "local x = 1\nreturn Mode(\"Bad\").Desc(\"x\")\n", + ) + .unwrap(); + let (_ast, findings) = crate::collect_ast(&[dir.clone()], 2); + assert!(findings.iter().any(|f| f.message.contains("import")), "{:?}", + findings.iter().map(|f| &f.message).collect::>()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_mission_walk_cross_checks_names_against_the_roster() { + let dir = std::env::temp_dir().join(format!("bmk-crosscheck-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("triggers")).unwrap(); + std::fs::write( + dir.join("units.lua"), + "Spawn(UnitDef(\"corlab\"), \"gaia\")\n\t.At(0.4, 0.4)\n\t.Named(\"hub\")\n", + ) + .unwrap(); + std::fs::write( + dir.join("triggers/t.lua"), + "When(Unit(\"hubb\").IsDestroyed())\n\t.Do(Objective(\"x\").Complete())\n", + ) + .unwrap(); + let (ast, findings) = crate::collect_ast(&[dir.clone()], 1); + assert!(findings.iter().any(|f| f.message.contains("no such name") && f.message.contains("hubb")), + "{:?}", findings.iter().map(|f| &f.message).collect::>()); + assert_eq!( + ast.surface["enums"]["team_role"][0].as_str(), + Some("player") + ); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs new file mode 100644 index 00000000..c1ad9615 --- /dev/null +++ b/bar-mission-kit/src/model.rs @@ -0,0 +1,180 @@ +//! The decorated mission AST: what the recognizer emits and every other +//! consumer (validator, RML form, future write-back) reads. Nodes keep byte +//! spans into the source file so UI edits can resolve to text edits. + +use serde::Serialize; + +/// Byte span [start, end) into the source file. +pub type Span = (usize, usize); + +#[derive(Serialize, Debug)] +pub struct MissionAst { + /// Recognizer version; bump when the node vocabulary changes. + pub version: u32, + /// Monotonic regeneration counter (serve mode); consumers poll this. + pub generation: u64, + pub files: Vec, + /// The authoring surface schema (curated overlay; annotation-derived + /// later): condition/effect templates the form's add-palette offers. + pub surface: serde_json::Value, +} + +#[derive(Serialize, Debug)] +pub struct FileAst { + /// Path as given on the command line (mission-relative when a dir walk). + pub path: String, + /// FNV-1a hash of the source bytes this AST was built from. Edit intents + /// carry it back as base_hash: compare-and-swap against clobbering a file + /// that changed since the view was built. + pub hash: String, + /// Every Objective("name") seen in this file — dropdown fodder. + pub objectives: Vec, + /// Unit/group names this file DECLARES (units.lua Named/Grouped) — the + /// definition sites Unit()/Units.* references are cross-checked against. + pub unit_defs: Vec, + pub group_defs: Vec, + /// Unit/group references this file makes (trigger files), with lines for + /// cross-check findings. + pub unit_refs: Vec, + pub group_refs: Vec, + /// Byte offset where a whole new trigger chain can be appended (EOF). + pub insert_trigger_at: usize, + /// Sections in file order. Chains before any `---@group` land in an + /// unlabeled leading section. + pub groups: Vec, + /// Spans the recognizer refused to classify. Empty in a conforming + /// trigger file — the validator reports each as an error there. + pub opaque: Vec, +} + +#[derive(Serialize, Debug)] +pub struct Group { + /// From `---@group("...")`; None for the leading unlabeled section. + pub label: Option, + pub triggers: Vec, +} + +#[derive(Serialize, Debug)] +pub struct Trigger { + /// filename:declaration-order — the same identity the runtime stamps. + pub id: String, + pub span: Span, + /// 1-based source line of the chain start (for open-in-editor). + pub line: usize, + /// Byte offset where a new `.Do(...)` line can be inserted (past the + /// chain's last line). Insert intents use start == end == this. + pub insert_effect_at: usize, + /// Whole-statement removal span: the chain's source lines, newline + /// inclusive. Replace with "" to delete the trigger. + pub remove_span: Span, + /// Byte offset where a new `.AndWhen(...)` line can be inserted (past the + /// When line's newline). + pub insert_condition_at: usize, + /// From a `---@label("...")` directly above the chain. + pub label: Option, + pub steps: Vec, +} + +#[derive(Serialize, Debug)] +pub struct Step { + /// Chain verb: When (first and repeated), Do, Once, Debounce. + pub verb: String, + /// 1-based source line of this step (open-in-editor lands here). + pub line: usize, + /// The args list INCLUDING parens — replacing it swaps the step's + /// content: new_text = "(" + template + ")". + pub span: Span, + pub args: Vec, + /// This step's whole source line (with newline). Replace with "" to + /// remove the step; the grammar gate protects chain legality. + pub remove_span: Span, +} + +/// An argument node. `kind` discriminates for JSON consumers. +#[derive(Serialize, Debug)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Value { + Number { + value: f64, + span: Span, + /// Domain meaning ("count", ...) stamped by the annotator. + #[serde(skip_serializing_if = "Option::is_none")] + semantic: Option, + }, + String { + value: String, + span: Span, + /// Domain meaning ("unit_def_name", "objective_name", ...). + #[serde(skip_serializing_if = "Option::is_none")] + semantic: Option, + }, + Boolean { + value: bool, + span: Span, + }, + /// A bare dotted reference, e.g. `Team.Player`. + Name { + path: String, + span: Span, + }, + /// A verb expression: dotted path + one or more invocations, e.g. + /// `Objective("x").Complete()` or + /// `Wave.Define("w").Route(Path("p")).Spawn()`. The first invocation is + /// the call on the path itself (name = None); later ones are chained. + Verb { + path: String, + calls: Vec, + span: Span, + }, + /// A table of literals/refs, e.g. `{ count = 5 }`. + Table { + fields: Vec, + span: Span, + }, + /// Anything the subset does not admit (a function body, arithmetic, a + /// computed index). Validator error in trigger files. + Opaque { + span: Span, + reason: String, + }, +} + +#[derive(Serialize, Debug)] +pub struct Invocation { + /// None for the initial call on the path; Some("Complete") for `.Complete(...)`. + pub name: Option, + pub args: Vec, + pub span: Span, +} + +#[derive(Serialize, Debug)] +pub struct Field { + /// Named keys only in the subset (`{ count = 5 }`); positional entries + /// get "1", "2", ... + pub key: String, + pub value: Value, +} + +#[derive(Serialize, Debug)] +pub struct Opaque { + pub span: Span, + pub reason: String, +} + +/// One named-noun reference (a Unit/Units argument) with its source line. +#[derive(Serialize, Debug, Clone)] +pub struct NameRef { + pub name: String, + pub line: usize, +} + +/// A validator finding, printed `path:line: message` in check mode. +#[derive(Debug)] +pub struct Finding { + pub path: String, + pub line: usize, + pub message: String, + /// Byte span of the token at fault, when the finding is about one. Editors + /// underline this rather than the whole line; None means line-wide. + pub span: Option, +} diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs new file mode 100644 index 00000000..16b28204 --- /dev/null +++ b/bar-mission-kit/src/recognizer.rs @@ -0,0 +1,666 @@ +//! The subset recognizer: classifies emmylua_parser CST nodes into the +//! decorated mission AST. The grammar is DERIVED from the game's LuaCATS +//! annotations (crate::types::TypeSurface): statement heads are the injected +//! globals returning a chain class, chain verbs are that class's fields, and +//! slot semantics come from parameter types. A top-level verb the types do +//! not declare is a finding; expressions outside the literal subset stay the +//! opaque exit hatch. Check mode is the same walk with findings. + +use crate::model::*; +use crate::types::TypeSurface; +use emmylua_parser::{ + LuaAstNode, LuaCallExpr, LuaChunk, LuaExpr, LuaIndexKey, LuaLiteralToken, LuaParser, + LuaStat, LuaTableExpr, ParserConfig, +}; +use std::collections::BTreeMap; + +/// What kind of mission file a path is; the grammar differs slightly by kind. +/// Trigger/roster files are sandbox-injected statement chains. Mode presets +/// are plain modules: an import preamble (VFS.Include + destructure locals) +/// followed by `return Mode(...)...` — same chains, different plumbing. +#[derive(Clone, Copy, PartialEq)] +pub enum FileKind { + Statements, + ModePreset, +} + +impl FileKind { + pub fn of(path: &str) -> FileKind { + // By path COMPONENT, and either separator. A substring test for + // "modes/" reads false on Windows, where the display path arrives with + // backslashes, and true for a directory merely ending in the word — + // "gamemodes/" is not a preset directory. + let preset = path + .split(|c| c == '/' || c == '\\') + .any(|component| component == "modes"); + if preset { + FileKind::ModePreset + } else { + FileKind::Statements + } + } +} + +/// Steps a chain must contain to be executable, per head. The runtime +/// enforces these at Finalize; declaring them keeps check mode's findings +/// aligned. (Not derivable from the types — a required call is semantics.) +const REQUIRED_STEP: &[(&str, &str, &str)] = &[ + ("When", "Do", "trigger chain has no Do — every statement needs at least one effect"), + ("Spawn", "At", "spawn chain has no At — every spawn needs a position"), +]; + +pub struct Recognized { + pub file: FileAst, + pub findings: Vec, +} + +/// Recognize against the built-in snapshot surface. Real runs derive the +/// surface from the game tree (TypeSurface::load_near); this entry serves +/// tests and embedded-default consumers. +#[cfg_attr(not(test), allow(dead_code))] +pub fn recognize_file(path: &str, source: &str) -> Result { + recognize_file_with(path, source, TypeSurface::builtin()) +} + +pub fn recognize_file_with( + path: &str, + source: &str, + surface: &TypeSurface, +) -> Result { + let tree = LuaParser::parse(source, ParserConfig::default()); + let errors = tree.get_errors(); + if !errors.is_empty() { + return Err(errors + .iter() + .map(|e| e.message.to_string()) + .collect::>() + .join("; ")); + } + + let mut rec = Rec { + path: path.to_string(), + source, + kind: FileKind::of(path), + heads: surface.statement_heads(), + surface, + groups: vec![Group { label: None, triggers: Vec::new() }], + opaque: Vec::new(), + findings: Vec::new(), + order: 0, + }; + + let chunk: LuaChunk = tree.get_chunk_node(); + if let Some(block) = chunk.get_block() { + for stat in block.get_stats() { + rec.statement(&stat); + } + } + + // Drop an empty unlabeled leading section if grouped chains exist. + let mut groups = rec.groups; + if groups.len() > 1 && groups[0].label.is_none() && groups[0].triggers.is_empty() { + groups.remove(0); + } + + // The annotation pass: stamp literal leaves with the semantics their + // parameter types carry, collecting the nouns as they go by. The roster + // file is the declaration site for unit/group names; everywhere else a + // stamped name is a reference to cross-check. + let mut nouns = Nouns::default(); + let annotator = Annotator { + surface, + declares: path.ends_with("units.lua"), + source, + }; + for group in &mut groups { + for trigger in &mut group.triggers { + let head = trigger.steps.first().map(|s| s.verb.clone()).unwrap_or_default(); + for step in &mut trigger.steps { + if let Some(sig) = surface.step_sig(&head, &step.verb) { + let sig = sig.clone(); + annotator.stamp_call(&sig, &mut step.args, &mut nouns); + } + for arg in &mut step.args { + annotator.value(arg, &mut nouns); + } + } + } + } + nouns.objectives.sort(); + nouns.objectives.dedup(); + + Ok(Recognized { + file: FileAst { + path: rec.path, + hash: fnv1a(source.as_bytes()), + objectives: nouns.objectives, + unit_defs: nouns.unit_defs, + group_defs: nouns.group_defs, + unit_refs: nouns.unit_refs, + group_refs: nouns.group_refs, + insert_trigger_at: source.len(), + groups, + opaque: rec.opaque, + }, + findings: rec.findings, + }) +} + +struct Rec<'s> { + path: String, + source: &'s str, + kind: FileKind, + /// statement head -> chain class, derived from the types. + heads: BTreeMap, + surface: &'s TypeSurface, + groups: Vec, + opaque: Vec, + findings: Vec, + order: usize, +} + +/// A verb expression unrolled from the nested call CST: +/// base dotted path + invocations in source order. +struct Unrolled { + path: Vec, + calls: Vec, + /// A chained `.Name` seen after a call, waiting for its CallExpr. + pending: Option, +} + +impl<'s> Rec<'s> { + fn line_of(&self, byte: usize) -> usize { + line_of(self.source, byte) + } + + fn finding(&mut self, span: Span, message: String) { + let line = self.line_of(span.0); + let path = self.path.clone(); + self.findings.push(Finding { path, line, message, span: None }); + } + + fn mark_opaque(&mut self, span: Span, reason: &str) { + self.finding(span, reason.to_string()); + self.opaque.push(Opaque { span, reason: reason.to_string() }); + } + + fn head_list(&self) -> String { + self.heads.keys().cloned().collect::>().join("/") + } + + fn statement(&mut self, stat: &LuaStat) { + let span = node_span(stat); + // Decorators ride the comment lines directly above the statement. + let decorators = self.leading_decorators(span.0); + for d in &decorators { + if d.name == "group" { + self.groups.push(Group { + label: d.args.first().cloned(), + triggers: Vec::new(), + }); + } + } + let label = decorators + .iter() + .find(|d| d.name == "label") + .and_then(|d| d.args.first().cloned()); + + match stat { + LuaStat::CallExprStat(call_stat) => { + let Some(call) = call_stat.get_call_expr() else { + self.mark_opaque(span, "unreadable call statement"); + return; + }; + if let Some(trigger) = self.statement_chain(&call, span, label) { + self.groups.last_mut().unwrap().triggers.push(trigger); + } + } + // Mode presets return their chain; the return IS the statement. + LuaStat::ReturnStat(ret) if self.kind == FileKind::ModePreset => { + let exprs: Vec = ret.get_expr_list().collect(); + match exprs.as_slice() { + [LuaExpr::CallExpr(call)] => { + if let Some(trigger) = self.statement_chain(call, span, label) { + self.groups.last_mut().unwrap().triggers.push(trigger); + } + } + _ => self.mark_opaque(span, "mode presets return exactly one Mode chain"), + } + } + // Mode presets bind their vocabulary with an import preamble: + // `local X = VFS.Include(...)` and destructures of such locals. + // Plumbing, not content — tolerated, not modeled. + LuaStat::LocalStat(local) if self.kind == FileKind::ModePreset => { + let imports = local.get_value_exprs().all(|expr| match &expr { + LuaExpr::CallExpr(call) => call + .get_prefix_expr() + .and_then(|p| match p { + LuaExpr::IndexExpr(_) | LuaExpr::NameExpr(_) => Some(()), + _ => None, + }) + .is_some(), + LuaExpr::IndexExpr(_) | LuaExpr::NameExpr(_) => true, + _ => false, + }); + if !imports { + self.mark_opaque(span, "mode preset locals may only import vocabulary"); + } + } + _ => { + let message = format!( + "mission files contain only verb chains ({}) — closure-free surface", + self.head_list() + ); + self.mark_opaque(span, &message); + } + } + } + + /// Recognize one statement chain against the derived grammar; None if + /// this call statement is not one (already reported). + fn statement_chain( + &mut self, + call: &LuaCallExpr, + span: Span, + label: Option, + ) -> Option { + let unrolled = self.unroll(&LuaExpr::CallExpr(call.clone()), span)?; + let head = unrolled.path.join("."); + if unrolled.path.len() != 1 || !self.heads.contains_key(&head) { + let message = format!( + "unknown statement verb '{head}' — the injected environment declares: {}", + self.head_list() + ); + self.mark_opaque(span, &message); + return None; + } + + // The first invocation is the head itself; later invocations carry + // their own names (.When/.Do/.At/...). + let mut steps = Vec::new(); + for (i, invocation) in unrolled.calls.into_iter().enumerate() { + let verb = if i == 0 { + head.clone() + } else { + match invocation.name.clone() { + Some(name) => name, + None => { + self.mark_opaque(span, "call without a step name in chain"); + return None; + } + } + }; + let line = self.line_of(invocation.span.0); + steps.push(Step { verb, line, span: invocation.span, args: invocation.args, remove_span: (0, 0) }); + } + + // Grammar checks (also the validator's rules). No terminator: the + // chain ends at its last call. + for (required_head, required_verb, message) in REQUIRED_STEP { + if head == *required_head && !steps.iter().any(|s| s.verb == *required_verb) { + self.finding(span, message.to_string()); + } + } + let chain_verbs = self.surface.chain_verbs(&head); + for step in steps.iter().skip(1) { + if step.verb == "Register" { + self.finding(step.span, "Register is gone — chains end at their last Do".into()); + } else if !chain_verbs.iter().any(|v| v == &step.verb) { + self.finding( + step.span, + format!( + "unknown chain verb '{}' after {head} (the {head} chain declares: {})", + step.verb, + chain_verbs.join("/") + ), + ); + } + } + + self.order += 1; + for step in &mut steps { + step.remove_span = line_bounds(self.source, step.span.0, step.span.1); + } + let insert_condition_at = steps + .first() + .filter(|s| s.verb == head) + .map(|s| line_bounds(self.source, s.span.0, s.span.1).1) + .unwrap_or(span.0); + // New chained lines append past the chain's last line. + let insert_effect_at = line_bounds(self.source, span.0, span.1).1; + Some(Trigger { + id: format!("{}:{}", self.path, self.order), + span, + line: self.line_of(span.0), + insert_effect_at, + insert_condition_at, + remove_span: line_bounds(self.source, span.0, span.1), + label, + steps, + }) + } + + /// Unroll a nested call/index expression into base path + invocations. + /// Returns None (with a finding) when the shape leaves the subset. + fn unroll(&mut self, expr: &LuaExpr, span: Span) -> Option { + match expr { + LuaExpr::NameExpr(name) => { + let text = name.get_name_text()?; + Some(Unrolled { path: vec![text.to_string()], calls: Vec::new(), pending: None }) + } + LuaExpr::IndexExpr(index) => { + let prefix = index.get_prefix_expr()?; + let mut unrolled = self.unroll(&prefix, span)?; + let key = match index.get_index_key() { + Some(LuaIndexKey::Name(token)) => token.get_name_text().to_string(), + _ => { + self.mark_opaque(span, "computed index in reference"); + return None; + } + }; + if unrolled.calls.is_empty() && unrolled.pending.is_none() { + unrolled.path.push(key); + } else if unrolled.pending.is_none() { + unrolled.pending = Some(key); + } else { + self.mark_opaque(span, "nested index between chained calls"); + return None; + } + Some(unrolled) + } + LuaExpr::CallExpr(call) => { + if call.is_colon_call() { + self.mark_opaque(span, "method (colon) call — the surface is dot-only"); + return None; + } + let prefix = call.get_prefix_expr()?; + let mut unrolled = self.unroll(&prefix, span)?; + let args = self.call_args(call); + // A nested CallExpr spans its whole prefix chain; the args + // list is the part that belongs to THIS invocation. + let call_span = call + .get_args_list() + .map(|list| node_span(&list)) + .unwrap_or_else(|| node_span(call)); + let name = unrolled.pending.take(); + unrolled.calls.push(Invocation { name, args, span: call_span }); + Some(unrolled) + } + _ => { + self.mark_opaque(span, "expression outside the mission subset"); + None + } + } + } + + fn call_args(&mut self, call: &LuaCallExpr) -> Vec { + let Some(list) = call.get_args_list() else { + return Vec::new(); + }; + list.get_args().map(|arg| self.value(&arg)).collect() + } + + /// Classify one expression into the subset's value vocabulary. + fn value(&mut self, expr: &LuaExpr) -> Value { + let span = node_span(expr); + match expr { + LuaExpr::LiteralExpr(literal) => match literal.get_literal() { + Some(LuaLiteralToken::Number(number)) => { + use emmylua_parser::NumberResult; + let value = match number.get_number_value() { + NumberResult::Int(i) => i as f64, + NumberResult::Uint(u) => u as f64, + NumberResult::Float(f) => f, + NumberResult::Number => f64::NAN, + }; + Value::Number { value, span, semantic: None } + } + Some(LuaLiteralToken::String(string)) => Value::String { + value: string.get_value(), + span, + semantic: None, + }, + Some(LuaLiteralToken::Bool(b)) => Value::Boolean { + value: b.is_true(), + span, + }, + _ => self.opaque_value(span, "unsupported literal"), + }, + LuaExpr::TableExpr(table) => self.table(table, span), + LuaExpr::NameExpr(_) | LuaExpr::IndexExpr(_) | LuaExpr::CallExpr(_) => { + let Some(unrolled) = self.unroll(expr, span) else { + return Value::Opaque { span, reason: "unrecognized reference".into() }; + }; + if unrolled.pending.is_some() { + return self.opaque_value(span, "dangling index after a call"); + } + let path = unrolled.path.join("."); + if unrolled.calls.is_empty() { + Value::Name { path, span } + } else { + Value::Verb { path, calls: unrolled.calls, span } + } + } + LuaExpr::ClosureExpr(_) => self.opaque_value( + span, + "function body in a trigger file — build the effect with a named verb (closure-free surface)", + ), + // Negative number literals (`-1`): the parser sees unary minus. + LuaExpr::UnaryExpr(unary) => { + let negated_number = unary + .get_op_token() + .map(|op| op.get_op() == emmylua_parser::UnaryOperator::OpUnm) + .unwrap_or(false) + .then(|| unary.get_expr()) + .flatten() + .and_then(|inner| match self.value(&inner) { + Value::Number { value, .. } => Some(value), + _ => None, + }); + match negated_number { + Some(value) => Value::Number { value: -value, span, semantic: None }, + None => self.opaque_value(span, "expression outside the mission subset"), + } + } + _ => self.opaque_value(span, "expression outside the mission subset"), + } + } + + fn opaque_value(&mut self, span: Span, reason: &str) -> Value { + self.finding(span, reason.to_string()); + Value::Opaque { span, reason: reason.to_string() } + } + + fn table(&mut self, table: &LuaTableExpr, span: Span) -> Value { + let mut fields = Vec::new(); + let mut index = 0usize; + for field in table.get_fields() { + let value = match field.get_value_expr() { + Some(expr) => self.value(&expr), + None => self.opaque_value(node_span(&field), "table field without a value"), + }; + let key = match field.get_field_key() { + Some(LuaIndexKey::Name(token)) => token.get_name_text().to_string(), + Some(LuaIndexKey::String(s)) => s.get_value(), + None => { + index += 1; + index.to_string() + } + _ => String::from("?"), + }; + fields.push(Field { key, value }); + } + Value::Table { fields, span } + } + + /// Parse `---@group("Wave timing")` style decorators from the comment + /// lines directly above `start_byte`. Comment decorators are grammar, not + /// hints — but unknown names are ignored (forward compatibility). + fn leading_decorators(&self, start_byte: usize) -> Vec { + let head = &self.source[..start_byte.min(self.source.len())]; + let mut out = Vec::new(); + for line in head.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Some(rest) = trimmed.strip_prefix("---@") else { + break; // first non-decorator line ends the run + }; + let (name, args_text) = match rest.split_once('(') { + Some((n, a)) => (n.trim(), a.trim_end_matches(')')), + None => (rest.trim(), ""), + }; + let args = args_text + .split(',') + .map(|a| a.trim().trim_matches('"').to_string()) + .filter(|a| !a.is_empty()) + .collect(); + out.push(Decorator { name: name.to_string(), args }); + } + out.reverse(); + out + } +} + +struct Decorator { + name: String, + args: Vec, +} + +//------------------------------------------------------------------------------ +// The type-driven annotator: semantics come from parameter types. A declared +// alias becomes the slot's semantic (UnitDefName -> unit_def_name); a plain +// number parameter falls back to its name (count). Vocabulary the types do +// not cover is simply left unstamped — the custom exit hatch. +//------------------------------------------------------------------------------ + +#[derive(Default)] +struct Nouns { + objectives: Vec, + unit_defs: Vec, + group_defs: Vec, + unit_refs: Vec, + group_refs: Vec, +} + +struct Annotator<'s> { + surface: &'s TypeSurface, + /// units.lua declares names; every other file references them. + declares: bool, + source: &'s str, +} + +impl<'s> Annotator<'s> { + /// Stamp one invocation's literal args from a signature, positionally. + fn stamp_call(&self, sig: &crate::types::FnSig, args: &mut [Value], nouns: &mut Nouns) { + for (param, arg) in sig.params.iter().zip(args.iter_mut()) { + let (name, type_name) = param; + match arg { + Value::String { value, span, semantic } => { + if let Some(slug) = self.surface.semantic_for(type_name) { + self.collect(&slug, value, span.0, nouns); + *semantic = Some(slug); + } + } + Value::Number { semantic, .. } => { + let slug = self + .surface + .semantic_for(type_name) + .unwrap_or_else(|| name.clone()); + *semantic = Some(slug); + } + _ => {} + } + } + } + + fn collect(&self, slug: &str, value: &str, at: usize, nouns: &mut Nouns) { + match slug { + "objective_name" => nouns.objectives.push(value.to_string()), + "unit_name" => { + if self.declares { + nouns.unit_defs.push(value.to_string()); + } else { + nouns.unit_refs.push(NameRef { + name: value.to_string(), + line: line_of(self.source, at), + }); + } + } + "unit_group" => { + if self.declares { + nouns.group_defs.push(value.to_string()); + } else { + nouns.group_refs.push(NameRef { + name: value.to_string(), + line: line_of(self.source, at), + }); + } + } + _ => {} + } + } + + /// Annotate one value tree: resolve verb paths through the type surface, + /// stamping each invocation's args from the signature it lands on, then + /// recurse into every argument. + fn value(&self, value: &mut Value, nouns: &mut Nouns) { + let Value::Verb { path, calls, .. } = value else { + return; + }; + let mut current: Option = self.surface.resolve_path(path); + for call in calls.iter_mut() { + let sig = match &call.name { + None => current.clone(), + Some(name) => current + .as_ref() + .and_then(|prev| prev.ret.as_deref()) + .and_then(|class| self.surface.member_sig(class, name)) + .cloned(), + }; + if let Some(sig) = &sig { + self.stamp_call(sig, &mut call.args, nouns); + } + if call.name.is_some() { + current = sig; + } + for arg in &mut call.args { + self.value(arg, nouns); + } + } + } +} + +fn line_of(source: &str, byte: usize) -> usize { + source[..byte.min(source.len())] + .bytes() + .filter(|&b| b == b'\n') + .count() + + 1 +} + +/// Line bounds around a byte span: (start of first line, past the newline +/// of the last line). The unit of statement/step removal. +fn line_bounds(source: &str, start: usize, end: usize) -> (usize, usize) { + let s = start.min(source.len()); + let e = end.min(source.len()); + let line_start = source[..s].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_end = source[e..].find('\n').map(|i| e + i + 1).unwrap_or(source.len()); + (line_start, line_end) +} + +/// Stable content hash (FNV-1a 64) for the CAS precondition on edits. +pub fn fnv1a(bytes: &[u8]) -> String { + let mut hash: u64 = 0xcbf29ce484222325; + for &b in bytes { + hash ^= b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +fn node_span(node: &N) -> Span { + let range = node.get_range(); + (usize::from(range.start()), usize::from(range.end())) +} diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs new file mode 100644 index 00000000..fdc01f1a --- /dev/null +++ b/bar-mission-kit/src/serve.rs @@ -0,0 +1,1328 @@ +//! Serve mode: the long-running editor service. The filesystem is the entire +//! interface (editor_architecture_plan.md): +//! +//! missions/**.lua --watch--> /mission_ast.json (file -> UI) +//! /edits/*.json --apply+validate--> missions/**.lua (UI -> file) +//! /open_request.json --> $EDITOR_CMD (mode switch to code) +//! +//! Every write to a mission file goes through the recognizer first: an edit +//! that produces a parse error or a grammar finding is rejected and reported +//! in /status.json. The mission's .lua files stay the source of truth. + +use crate::model::{MissionAst, Span}; +use crate::recognizer; +use crate::view; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const POLL: Duration = Duration::from_millis(300); + +/// A span edit the UI (or any tool) requests: replace bytes [start, end) of +/// `file` (mission-relative path, as emitted in the AST) with `new_text`. +#[derive(Deserialize, Debug)] +pub struct EditIntent { + pub file: String, + pub start: usize, + pub end: usize, + pub new_text: String, + /// Hash of the file content the edit was computed against (FileAst.hash). + /// A whole-file hash, so a second edit from the same view generation + /// always arrives stale; the journal rebases it onto the current file, and + /// only a genuine overlap is refused. + #[serde(default)] + pub base_hash: Option, +} + +/// One write serve performed, keyed by the file hashes it moved between. +#[derive(Clone, Debug)] +struct AppliedEdit { + before: String, + after: String, + start: usize, + end: usize, + new_len: usize, + /// What this write replaced. The rebase logic never needed it — spans and + /// hashes are enough to carry an intent forward — but undo cannot + /// reconstruct anything without it, and the journal is the only place that + /// knows. + old_text: String, +} + +/// Recent writes per file, so an intent stamped with an older view generation +/// can be replayed onto what those writes left behind. Only writes serve made +/// itself are recorded: any other route to the current bytes stays a refusal. +#[derive(Default)] +pub struct EditJournal { + by_file: std::collections::HashMap>, + /// The file the last recorded write landed in. Ctrl+Z means "undo the + /// thing I just did", and the form spans several files, so the panel + /// cannot be the one to say which. + last_written: Option, +} + +const JOURNAL_DEPTH: usize = 64; + +fn stale(file: &str) -> String { + format!("file changed on disk since the view was built ({file}) — refreshing view instead of writing") +} + +impl EditJournal { + fn record(&mut self, file: &str, applied: AppliedEdit) { + self.last_written = Some(file.to_string()); + let entries = self.by_file.entry(file.to_string()).or_default(); + entries.push_back(applied); + while entries.len() > JOURNAL_DEPTH { + entries.pop_front(); + } + } + + /// Carry `span` from the file state `base` forward to `current`, one + /// recorded write at a time. Err when the route is unrecorded (someone + /// else wrote the file) or when a write landed on the span itself. + fn rebase(&self, file: &str, base: &str, current: &str, span: Span) -> Result { + let entries = self.by_file.get(file).ok_or_else(|| stale(file))?; + let (mut start, mut end) = span; + let mut at = base; + for _ in 0..=entries.len() { + if at == current { + return Ok((start, end)); + } + // newest first: a file that returned to an earlier state has two + // edges out of it, and the recent one is the live history + let step = entries.iter().rev().find(|e| e.before == at).ok_or_else(|| stale(file))?; + if step.start == start && step.end == end { + // The same leaf, rewritten: this intent replaces all of it, so + // it redirects onto the new extent and the later write wins. + end = start + step.new_len; + } else if step.start < end && start < step.end { + return Err(format!( + "edit overlaps a newer edit to the same region of {file} — refreshing view instead of writing" + )); + } else if step.end <= start { + let delta = step.new_len as i64 - (step.end - step.start) as i64; + start = (start as i64 + delta) as usize; + end = (end as i64 + delta) as usize; + } + at = &step.after; + } + Err(stale(file)) + } +} + +/// The edit that would put `file` back the way it was before its most recent +/// recorded write. Undo is not a special path: this hands back an ordinary +/// intent, which goes through the same gate as anything a person clicks — it +/// has to parse, it has to stay inside the grammar, and it is refused if the +/// file moved underneath. +impl EditJournal { + fn undo_intent(&self, file: &str, current: &str) -> Result { + let entries = self.by_file.get(file).ok_or_else(|| nothing_to_undo(file))?; + let last = entries.back().ok_or_else(|| nothing_to_undo(file))?; + if last.after != current { + // Something else wrote the file after we did. Undoing our write + // would silently discard theirs. + return Err(stale(file)); + } + Ok(EditIntent { + file: file.to_string(), + start: last.start, + end: last.start + last.new_len, + new_text: last.old_text.clone(), + base_hash: Some(current.to_string()), + }) + } + + /// Drop the write an undo has just reversed, so a second undo reaches the + /// one before it rather than bouncing between two states forever. + fn forget_last(&mut self, file: &str) { + if let Some(entries) = self.by_file.get_mut(file) { + entries.pop_back(); + } + } + + fn depth(&self, file: &str) -> usize { + self.by_file.get(file).map(|e| e.len()).unwrap_or(0) + } + + /// The file an unqualified undo should act on. + fn most_recent(&self) -> Option<&str> { + self.last_written.as_deref() + } +} + +fn nothing_to_undo(file: &str) -> String { + // Deliberately not "this serve has not written it": by the time the + // history is walked back to nothing, it usually has. + format!("nothing left to undo for {file}") +} + +#[derive(Deserialize, Debug)] +pub struct OpenRequest { + pub file: String, + #[serde(default = "one")] + pub line: usize, +} + +/// A crumb click: re-scope serve to root/. Same channel discipline as +/// every other intent — a json file in the editor dir, consumed by the loop. +#[derive(Deserialize, Debug)] +pub struct SelectMission { + pub name: String, +} + +fn one() -> usize { + 1 +} + +#[derive(Serialize, Default)] +struct Status { + generation: u64, + ok: bool, + message: String, + /// Absolute missions root, so editor clients can map `path:line` findings + /// in `message` onto real files (VS Code diagnostics). + missions_dir: String, + /// The same findings, structured. `message` stays the human reading; this + /// carries the byte span so an editor can underline the token at fault + /// rather than the whole line. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + findings: Vec, +} + +#[derive(Serialize, Default)] +struct FindingOut { + path: String, + line: usize, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + end: Option, +} + +pub struct Server { + pub missions_dir: PathBuf, + pub editor_dir: PathBuf, + pub editor_cmd: String, + /// When set, the editor follows the game: active_mission.json (published + /// by the bridge) re-scopes missions_dir to root/. + pub missions_root: Option, + generation: u64, + open_seq: u64, + /// Last-seen active_mission.json content: arming is an EVENT, so the + /// editor follows a change, not the standing file — a manual crumb + /// selection must not be overridden by a stale arming from last session. + last_active: String, + journal: EditJournal, +} + +impl Server { + pub fn new( + missions_dir: PathBuf, + editor_dir: PathBuf, + editor_cmd: String, + missions_root: Option, + ) -> Self { + Server { + missions_dir, + editor_dir, + editor_cmd, + missions_root, + generation: 0, + open_seq: 0, + last_active: String::new(), + journal: EditJournal::default(), + } + } + + /// Picker click in-game -> loader stamps mission_name -> bridge publishes + /// active_mission.json -> the whole editor re-scopes (form, probes, Edit + /// target). Only in --missions-root mode, and only when the file CHANGES. + fn follow_active(&mut self) { + let Some(root) = &self.missions_root else { + return; + }; + let Ok(text) = std::fs::read_to_string(self.editor_dir.join("active_mission.json")) else { + return; + }; + if text == self.last_active { + return; + } + self.last_active = text.clone(); + let Ok(value) = serde_json::from_str::(&text) else { + return; + }; + let Some(name) = value.get("name").and_then(|n| n.as_str()) else { + return; + }; + if !valid_mission_name(name) { + return; + } + let candidate = root.join(name); + if candidate.is_dir() && candidate != self.missions_dir { + eprintln!("following armed mission: {name}"); + self.missions_dir = candidate; + } + } + + /// Crumb click in any terminal -> select_mission.json -> re-scope. The + /// game keeps precedence through follow_active: a new arming event is a + /// change and wins the tick. + fn consume_select_mission(&mut self) { + let path = self.editor_dir.join("select_mission.json"); + let Ok(text) = std::fs::read_to_string(&path) else { + return; + }; + std::fs::remove_file(&path).ok(); + let Some(root) = &self.missions_root else { + eprintln!("select_mission ignored: serve is pinned (no --missions-root)"); + return; + }; + let request: SelectMission = match serde_json::from_str(&text) { + Ok(r) => r, + Err(e) => { + eprintln!("bad select_mission: {e}"); + return; + } + }; + if !valid_mission_name(&request.name) { + eprintln!("select_mission refused: {}", request.name); + return; + } + let candidate = root.join(&request.name); + if candidate.is_dir() && candidate != self.missions_dir { + eprintln!("selected mission: {}", request.name); + self.missions_dir = candidate; + } + } + + /// Sibling missions under the root: a mission is a dir with at least one + /// trigger file. Name charset doubles as the markup-safety guarantee for + /// the crumb (view embeds names verbatim). + fn list_missions(&self) -> Vec { + let Some(root) = &self.missions_root else { + return Vec::new(); + }; + let mut names: Vec = std::fs::read_dir(root) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|name| valid_mission_name(name)) + .filter(|name| { + let pattern = format!("{}/**/triggers/*.lua", root.join(name).display()); + glob::glob(&pattern).map(|mut g| g.next().is_some()).unwrap_or(false) + }) + .collect(); + names.sort(); + names + } + + pub fn run(&mut self) -> ! { + std::fs::create_dir_all(self.editor_dir.join("edits")).ok(); + let mut last_fingerprint = String::new(); + eprintln!( + "bar-mission-kit serve: {} -> {} (editor: {})", + self.missions_dir.display(), + self.editor_dir.display(), + self.editor_cmd + ); + loop { + // UI -> file first, so a just-applied edit regenerates in the + // same tick. + self.consume_edits(); + self.consume_open_request(); + self.consume_undo_request(); + + self.consume_select_mission(); + self.follow_active(); + // domains.json (client-published dropdown data) re-renders too; + // the dir itself is part of the print so re-scoping regenerates, + // and the sibling list so a new mission dir appears in the crumb. + let fingerprint = format!( + "{}\n{}\n{}\n{}", + self.missions_dir.display(), + fingerprint_dir(&self.missions_dir), + file_stamp(&self.editor_dir.join("domains.json")), + self.list_missions().join(",") + ); + if fingerprint != last_fingerprint { + last_fingerprint = fingerprint; + self.regenerate(); + } + std::thread::sleep(POLL); + } + } + + fn regenerate(&mut self) { + self.generation += 1; + let (ast, mut findings) = crate::collect_ast(&[self.missions_dir.clone()], self.generation); + // Unit defs are game content, so they can only be checked against what + // the game published; the roster's own names are checked at parse time. + findings.extend(view::unknown_unit_defs(&ast, &self.domains())); + let message = findings + .iter() + .map(|f| format!("{}:{}: {}", f.path, f.line, f.message)) + .collect::>() + .join("\n"); + self.write_ast(&ast); + self.write_view(&ast); + let dot = crate::graph::dot(&ast); + std::fs::write(self.editor_dir.join("mission_graph.dot"), dot).ok(); + self.write_status_with(findings.is_empty(), &message, &findings); + if findings.is_empty() { + eprintln!("[gen {}] AST regenerated", self.generation); + } else { + eprintln!("[gen {}] AST regenerated with findings:\n{message}", self.generation); + } + } + + fn write_ast(&self, ast: &MissionAst) { + let json = serde_json::to_string_pretty(ast).expect("serializable AST"); + let path = self.editor_dir.join("mission_ast.json"); + if let Err(e) = std::fs::write(&path, json) { + eprintln!("cannot write {}: {e}", path.display()); + } + } + + /// What the game published about itself. Absent until a game has run, so + /// every consumer treats an empty set as "no authority", not "nothing valid". + fn domains(&self) -> view::Domains { + std::fs::read_to_string(self.editor_dir.join("domains.json")) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default() + } + + fn write_view(&self, ast: &MissionAst) { + let domains = self.domains(); + let scope = view::Scope { + mission: self + .missions_dir + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()), + missions: self.list_missions(), + }; + let artifact = view::render(ast, &domains, &scope); + let json = serde_json::to_string(&artifact).expect("serializable view"); + let path = self.editor_dir.join("mission_view.json"); + if let Err(e) = std::fs::write(&path, json) { + eprintln!("cannot write {}: {e}", path.display()); + } + } + + fn write_status(&self, ok: bool, message: &str) { + self.write_status_with(ok, message, &[]); + } + + fn write_status_with(&self, ok: bool, message: &str, findings: &[crate::model::Finding]) { + let missions_dir = self + .missions_dir + .canonicalize() + .unwrap_or_else(|_| self.missions_dir.clone()) + .display() + .to_string(); + let status = Status { + generation: self.generation, + ok, + message: message.to_string(), + missions_dir, + findings: findings + .iter() + .map(|f| FindingOut { + path: f.path.clone(), + line: f.line, + message: f.message.clone(), + start: f.span.map(|s| s.0), + end: f.span.map(|s| s.1), + }) + .collect(), + }; + let json = serde_json::to_string_pretty(&status).expect("serializable status"); + std::fs::write(self.editor_dir.join("status.json"), json).ok(); + } + + fn consume_edits(&mut self) { + let edits_dir = self.editor_dir.join("edits"); + let Ok(entries) = std::fs::read_dir(&edits_dir) else { + return; + }; + let mut paths: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().map(|x| x == "json").unwrap_or(false)) + .collect(); + paths.sort_by_key(|p| natural_key(p)); + for path in paths { + let outcome = self.apply_edit_file(&path); + if let Err(message) = outcome { + eprintln!("edit rejected: {message}"); + self.generation += 1; + self.write_status(false, &message); + } + std::fs::remove_file(&path).ok(); + } + } + + fn apply_edit_file(&mut self, path: &Path) -> Result<(), String> { + let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let intent: EditIntent = + serde_json::from_str(&text).map_err(|e| format!("bad edit intent: {e}"))?; + let span = apply_edit_journaled(&self.missions_dir, &intent, &mut self.journal)?; + eprintln!("applied edit to {} [{}..{})", intent.file, span.0, span.1); + Ok(()) + } + + /// Undo the most recent write this serve made, through the same gate as + /// any other edit. Nothing here bypasses the grammar check: the inverse is + /// an ordinary intent, and if putting the old text back would leave the + /// file outside the mission subset it is refused like anything else. + fn consume_undo_request(&mut self) { + let path = self.editor_dir.join("undo_request.json"); + let Ok(text) = std::fs::read_to_string(&path) else { + return; + }; + std::fs::remove_file(&path).ok(); + let requested: Option = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("file").and_then(|f| f.as_str()).map(|s| s.to_string())); + let Some(file) = requested.or_else(|| self.journal.most_recent().map(String::from)) else { + self.write_status(false, "nothing left to undo — this serve has not written anything yet"); + return; + }; + let result = (|| { + let path = resolve_mission_file(&self.missions_dir, &file)?; + let source = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let current = recognizer::fnv1a(source.as_bytes()); + let intent = self.journal.undo_intent(&file, ¤t)?; + apply_edit_journaled(&self.missions_dir, &intent, &mut self.journal)?; + // Two entries now describe the same round trip. Dropping both + // leaves the next undo reaching the write before it, rather than + // flipping between two states forever. + self.journal.forget_last(&file); + self.journal.forget_last(&file); + Ok::<_, String>(()) + })(); + match result { + Ok(()) => { + self.generation += 1; + let left = self.journal.depth(&file); + self.write_status(true, &format!("undid the last edit to {file} ({left} left)")); + eprintln!("undid the last edit to {file}"); + } + Err(message) => { + self.generation += 1; + self.write_status(false, &message); + } + } + } + + /// Open requests become a sequenced open-target artifact (GET + /// /open_request): every VS Code window's extension polls it, and the one + /// whose workspace contains the file acts. A non-empty --editor-cmd + /// additionally shells out, for extension-less setups. + fn consume_open_request(&mut self) { + let path = self.editor_dir.join("open_request.json"); + let Ok(text) = std::fs::read_to_string(&path) else { + return; + }; + std::fs::remove_file(&path).ok(); + let request: OpenRequest = match serde_json::from_str(&text) { + Ok(r) => r, + Err(e) => { + eprintln!("bad open request: {e}"); + return; + } + }; + let Ok(file) = resolve_mission_file(&self.missions_dir, &request.file) else { + eprintln!("open request outside missions dir: {}", request.file); + return; + }; + let file = file.canonicalize().unwrap_or(file); + self.open_seq += 1; + // `ts` lets a freshly started terminal tell a live request from a + // stale artifact: seq alone cannot, since it restarts with serve. + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let target = serde_json::json!({ + "seq": self.open_seq, + "ts": ts, + "file": file.display().to_string(), + "line": request.line, + }); + std::fs::write(self.editor_dir.join("open_target.json"), target.to_string()).ok(); + eprintln!("open target [{}]: {}:{}", self.open_seq, file.display(), request.line); + if !self.editor_cmd.is_empty() { + let cmd = self + .editor_cmd + .replace("{file}", &file.display().to_string()) + .replace("{line}", &request.line.to_string()); + eprintln!("opening: {cmd}"); + let _ = std::process::Command::new("sh").arg("-c").arg(&cmd).spawn(); + } + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +enum Chunk { + Text(String), + Number(u128), +} + +/// Intent filenames carry numbers (`_.json`, `http__.json`) +/// and do not sort lexicographically in submission order: "1000_2" < "900_1". +/// Compare digit runs numerically so the drain order is the write order. +fn natural_key(path: &Path) -> Vec { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let mut chunks = Vec::new(); + let mut rest = name.as_str(); + while !rest.is_empty() { + let digit = rest.starts_with(|c: char| c.is_ascii_digit()); + let split = rest.find(|c: char| c.is_ascii_digit() != digit).unwrap_or(rest.len()); + let (head, tail) = rest.split_at(split); + chunks.push(if digit { + head.parse().map(Chunk::Number).unwrap_or_else(|_| Chunk::Text(head.into())) + } else { + Chunk::Text(head.into()) + }); + rest = tail; + } + chunks +} + +fn valid_mission_name(name: &str) -> bool { + !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// Resolve a mission-relative path defensively (no escaping the tree). +fn resolve_mission_file(missions_dir: &Path, rel: &str) -> Result { + if rel.contains("..") || rel.starts_with('/') { + return Err(format!("suspicious path: {rel}")); + } + let path = missions_dir.join(rel); + if !path.is_file() { + return Err(format!("no such mission file: {rel}")); + } + Ok(path) +} + +/// Inserted text adopts the document's line endings: the UI's templates are +/// LF, and mission files are frequently CRLF — an LF insertion would seed a +/// mixed document and, at EOF, rewrite the file's terminal bytes. A document +/// with no clear convention is left to speak for itself. +fn match_line_endings(source: &str, new_text: &str) -> String { + let crlf = source.matches("\r\n").count(); + let lf = source.matches('\n').count() - crlf; + if crlf > lf { + new_text.replace("\r\n", "\n").replace('\n', "\r\n") + } else if crlf == 0 { + new_text.replace("\r\n", "\n") + } else { + new_text.to_string() + } +} + +/// Apply a span edit to a mission file — but only if the result still +/// parses AND passes the recognizer with zero findings. The grammar is the +/// write gate; a rejected edit changes nothing on disk. An intent stamped +/// with an earlier view generation rebases onto serve's own intervening +/// writes through `journal`. Returns the span actually written. +pub fn apply_edit_journaled( + missions_dir: &Path, + intent: &EditIntent, + journal: &mut EditJournal, +) -> Result { + let path = resolve_mission_file(missions_dir, &intent.file)?; + let source = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let current = recognizer::fnv1a(source.as_bytes()); + let (start, end) = match &intent.base_hash { + Some(base) if base != ¤t => { + journal.rebase(&intent.file, base, ¤t, (intent.start, intent.end))? + } + _ => (intent.start, intent.end), + }; + if start > end || end > source.len() { + return Err(format!( + "edit span [{}, {}) out of bounds for {} ({} bytes)", + start, + end, + intent.file, + source.len() + )); + } + let new_text = match_line_endings(&source, &intent.new_text); + let mut edited = String::with_capacity(source.len() + new_text.len()); + edited.push_str(&source[..start]); + edited.push_str(&new_text); + edited.push_str(&source[end..]); + + // Gate against the same type-derived grammar the view was built from — + // resolved from the FILE, not from the missions dir. A mode preset is + // written in its module's mode vocabulary, so gating it against the trigger + // one made Mode an unknown verb and refused every edit to a preset; and a + // preset in another module answers to that module, not to this one. + let policy = match recognizer::FileKind::of(&intent.file) { + recognizer::FileKind::ModePreset => "mode", + recognizer::FileKind::Statements => "trigger", + }; + let surface = crate::types::TypeSurface::load_near_policy(&[path.clone()], policy); + let recognized = recognizer::recognize_file_with(&intent.file, &edited, &surface) + .map_err(|e| format!("edit rejected — result does not parse: {e}"))?; + if !recognized.findings.is_empty() { + let msgs = recognized + .findings + .iter() + .map(|f| f.message.clone()) + .collect::>() + .join("; "); + return Err(format!("edit rejected — result leaves the mission subset: {msgs}")); + } + + let after = recognizer::fnv1a(edited.as_bytes()); + std::fs::write(&path, edited).map_err(|e| e.to_string())?; + journal.record( + &intent.file, + AppliedEdit { + before: current, + after, + start, + end, + new_len: new_text.len(), + old_text: source[start..end].to_string(), + }, + ); + Ok((start, end)) +} + +fn file_stamp(path: &Path) -> String { + let meta = std::fs::metadata(path).ok(); + let mtime = meta + .as_ref() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_millis()) + .unwrap_or(0); + let size = meta.map(|m| m.len()).unwrap_or(0); + format!("{}|{mtime}|{size}", path.display()) +} + +/// Cheap change detection: every .lua path + mtime + size, concatenated. +fn fingerprint_dir(dir: &Path) -> String { + let mut entries: Vec = Vec::new(); + let mut paths: Vec = Vec::new(); + for pattern in [ + format!("{}/**/triggers/*.lua", dir.display()), + format!("{}/**/units.lua", dir.display()), + ] { + for path in glob::glob(&pattern).into_iter().flatten().flatten() { + paths.push(path); + } + } + let roster = dir.join("units.lua"); + if roster.is_file() { + paths.push(roster); + } + paths.sort(); + paths.dedup(); + for path in paths { + let meta = std::fs::metadata(&path).ok(); + let mtime = meta + .as_ref() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_millis()) + .unwrap_or(0); + let size = meta.map(|m| m.len()).unwrap_or(0); + entries.push(format!("{}|{mtime}|{size}", path.display())); + } + entries.sort(); + entries.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn apply_edit(missions_dir: &Path, intent: &EditIntent) -> Result<(), String> { + apply_edit_journaled(missions_dir, intent, &mut EditJournal::default()).map(|_| ()) + } + + fn setup(dir: &Path) { + std::fs::create_dir_all(dir.join("hello/triggers")).unwrap(); + std::fs::write( + dir.join("hello/triggers/win.lua"), + "When(Team.Player.Has(UnitDef(\"armpw\"), 3))\n\t.Do(Objective(\"x\").Complete())\n", + ) + .unwrap(); + } + + fn tmpdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("bar-mission-kit-test-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + const WIN: &str = + "When(Team.Player.Has(UnitDef(\"armpw\"), 3))\n\t.Do(Objective(\"x\").Complete())\n"; + + /// The documents the editor actually meets: BAR working trees are CRLF in + /// places, and not every file ends with a terminator. + fn documents() -> Vec<(&'static str, String)> { + let crlf = WIN.replace('\n', "\r\n"); + vec![ + ("lf", WIN.to_string()), + ("crlf", crlf.clone()), + ("crlf-no-final-newline", crlf.trim_end_matches("\r\n").to_string()), + ("lf-no-final-newline", WIN.trim_end_matches('\n').to_string()), + ] + } + + fn seed(dir: &Path, source: &str) { + std::fs::create_dir_all(dir.join("hello/triggers")).unwrap(); + std::fs::write(dir.join("hello/triggers/win.lua"), source).unwrap(); + } + + fn edited_text(dir: &Path) -> String { + std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap() + } + + /// A write is the exact splice: every byte outside [start, end) survives, + /// terminal bytes included, and inserted lines keep the document's + /// line-ending convention instead of the template's. + #[test] + fn a_write_touches_only_the_span_it_edits() { + for (name, source) in documents() { + let dir = tmpdir(&format!("splice-{name}")); + let file = "hello/triggers/win.lua"; + + seed(&dir, &source); + let at = source.find(", 3)").unwrap() + 2; + let intent = EditIntent { + file: file.into(), + start: at, + end: at + 1, + new_text: "5".into(), + base_hash: None, + }; + apply_edit(&dir, &intent).unwrap(); + let edited = edited_text(&dir); + assert_eq!(edited[..at], source[..at], "{name}: bytes before the span"); + assert_eq!(edited[at + 1..], source[at + 1..], "{name}: bytes after the span"); + + // an insertion at EOF, exactly as the add-step modal posts it + seed(&dir, &source); + let eof = source.len(); + let intent = EditIntent { + file: file.into(), + start: eof, + end: eof, + new_text: "\t.Do(Objective(\"y\").Complete())\n".into(), + base_hash: None, + }; + apply_edit(&dir, &intent).unwrap(); + let edited = edited_text(&dir); + assert_eq!(edited[..eof], source, "{name}: bytes before the insertion"); + if source.contains("\r\n") { + let bare = edited.matches('\n').count() - edited.matches("\r\n").count(); + assert_eq!(bare, 0, "{name}: CRLF document gained an LF line: {edited:?}"); + } else { + assert_eq!(edited.matches('\r').count(), 0, "{name}: LF document gained a CR"); + } + } + } + + #[test] + fn a_valid_span_edit_applies() { + let dir = tmpdir("valid"); + setup(&dir); + let source = std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap(); + let at = source.find(", 3)").unwrap() + 2; + let intent = EditIntent { + file: "hello/triggers/win.lua".into(), + start: at, + end: at + 1, + new_text: "5".into(), + base_hash: Some(crate::recognizer::fnv1a(source.as_bytes())), + }; + apply_edit(&dir, &intent).unwrap(); + let edited = std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap(); + assert!(edited.contains(", 5)")); + } + + #[test] + fn an_edit_that_breaks_the_grammar_is_rejected_and_leaves_the_file_alone() { + let dir = tmpdir("grammar"); + setup(&dir); + let source = std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap(); + let at = source.find(", 3)").unwrap() + 2; + let intent = EditIntent { + file: "hello/triggers/win.lua".into(), + start: at, + end: at + 1, + new_text: "function() end".into(), + base_hash: None, + }; + let err = apply_edit(&dir, &intent).unwrap_err(); + assert!(err.contains("closure-free"), "{err}"); + let after = std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap(); + assert_eq!(source, after); + } + + #[test] + fn an_edit_that_breaks_the_parse_is_rejected() { + let dir = tmpdir("parse"); + setup(&dir); + let intent = EditIntent { + file: "hello/triggers/win.lua".into(), + start: 0, + end: 1, + new_text: ")(".into(), + base_hash: None, + }; + assert!(apply_edit(&dir, &intent).is_err()); + } + + #[test] + fn a_mode_preset_can_be_edited() { + // The write gate re-checks the result before writing, so it has to use + // the vocabulary the file is written in. Gated against the trigger + // grammar, Mode is an unknown verb and EVERY edit to a preset is + // refused — the editor stops accepting changes and the reason goes only + // to the serve log. + // + // The tree matters: the missions dir must itself sit on a types/ dir + // publishing the TRIGGER surface, or resolution falls back to the + // bundled snapshot — which carries both vocabularies and hides exactly + // this bug, as the first version of this test did. + let dir = tmpdir("mode-edit"); + std::fs::create_dir_all(dir.join("types")).unwrap(); + std::fs::create_dir_all(dir.join("modes")).unwrap(); + std::fs::write(dir.join("module.lua"), "return { name = \"alpha\" }").unwrap(); + std::fs::write( + dir.join("types/trigger_policy.lua"), + "---@meta actions\n---@param c any\n---@return any\nfunction When(c) end\n", + ) + .unwrap(); + std::fs::write( + dir.join("types/mode_policy.lua"), + "---@meta policy mode\n---@class AlphaChain\n---@field Desc fun(d: string): AlphaChain\n---@param name string\n---@return AlphaChain\nfunction Mode(name) end\n", + ) + .unwrap(); + let preset = dir.join("modes/thing.lua"); + std::fs::write(&preset, "return Mode(\"Thing\")\n\t.Desc(\"before\")\n").unwrap(); + + // Precondition: the trigger surface really does NOT know Mode, so a + // pass below means the policy was chosen, not that everything is known. + let trigger = crate::types::TypeSurface::load_near_policy(&[dir.clone()], "trigger"); + assert!(!trigger.globals.contains_key("Mode"), "test tree is not exercising the bug"); + + let source = std::fs::read_to_string(&preset).unwrap(); + let from = source.find("before").unwrap(); + let intent = EditIntent { + file: "modes/thing.lua".into(), + start: from, + end: from + "before".len(), + new_text: "after".into(), + base_hash: None, + }; + apply_edit(&dir, &intent).expect("a preset must be editable"); + assert!(std::fs::read_to_string(&preset).unwrap().contains("after")); + } + + /// Drive one edit through the same path serve does, keeping the journal. + fn edit_with(dir: &Path, journal: &mut EditJournal, intent: &EditIntent) -> Result { + apply_edit_journaled(dir, intent, journal) + } + + #[test] + fn undo_restores_what_the_last_edit_replaced() { + let dir = tmpdir("undo-basic"); + setup(&dir); + let file = "hello/triggers/win.lua"; + let path = dir.join(file); + let before = std::fs::read_to_string(&path).unwrap(); + let at = before.find('3').unwrap(); + + let mut journal = EditJournal::default(); + edit_with(&dir, &mut journal, &EditIntent { + file: file.into(), start: at, end: at + 1, new_text: "5".into(), base_hash: None, + }) + .unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("armpw\"), 5)")); + + let current = recognizer::fnv1a(std::fs::read_to_string(&path).unwrap().as_bytes()); + let inverse = journal.undo_intent(file, ¤t).unwrap(); + edit_with(&dir, &mut journal, &inverse).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), before, "undo must restore the bytes exactly"); + } + + #[test] + fn undo_refuses_when_someone_else_wrote_the_file() { + // Undo puts back what WE replaced. If another hand changed the file + // since, replaying our inverse would quietly discard their work. + let dir = tmpdir("undo-stale"); + setup(&dir); + let file = "hello/triggers/win.lua"; + let path = dir.join(file); + let at = std::fs::read_to_string(&path).unwrap().find('3').unwrap(); + + let mut journal = EditJournal::default(); + edit_with(&dir, &mut journal, &EditIntent { + file: file.into(), start: at, end: at + 1, new_text: "5".into(), base_hash: None, + }) + .unwrap(); + + std::fs::write(&path, WIN.replace('3', "9")).unwrap(); + let current = recognizer::fnv1a(std::fs::read_to_string(&path).unwrap().as_bytes()); + assert!(journal.undo_intent(file, ¤t).is_err(), "undo must not clobber a foreign write"); + } + + #[test] + fn undo_is_gated_like_any_other_edit() { + // Putting the old text back is still an edit, and still has to leave a + // file the grammar accepts. Nothing about undo is privileged. + let dir = tmpdir("undo-gate"); + setup(&dir); + let file = "hello/triggers/win.lua"; + let mut journal = EditJournal::default(); + let intent = EditIntent { + file: file.into(), start: 0, end: 0, new_text: "-- hi\n".into(), base_hash: None, + }; + edit_with(&dir, &mut journal, &intent).unwrap(); + + let path = dir.join(file); + let current = recognizer::fnv1a(std::fs::read_to_string(&path).unwrap().as_bytes()); + let inverse = journal.undo_intent(file, ¤t).unwrap(); + // The inverse is an ordinary intent: same struct, same route. + assert_eq!(inverse.new_text, ""); + edit_with(&dir, &mut journal, &inverse).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), WIN); + } + + #[test] + fn nothing_to_undo_is_a_message_not_a_panic() { + let dir = tmpdir("undo-empty"); + setup(&dir); + let journal = EditJournal::default(); + let err = journal.undo_intent("hello/triggers/win.lua", "deadbeef").unwrap_err(); + assert!(err.contains("nothing left to undo"), "{err}"); + } + + #[test] + fn a_stale_base_hash_is_refused_without_writing() { + let dir = tmpdir("cas"); + setup(&dir); + let source = std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap(); + let at = source.find(", 3)").unwrap() + 2; + let intent = EditIntent { + file: "hello/triggers/win.lua".into(), + start: at, + end: at + 1, + new_text: "5".into(), + base_hash: Some("0000000000000000".into()), + }; + let err = apply_edit(&dir, &intent).unwrap_err(); + assert!(err.contains("changed on disk"), "{err}"); + let after = std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap(); + assert_eq!(source, after); + } + + #[test] + fn an_appended_trigger_chain_passes_the_gate() { + let dir = tmpdir("append"); + setup(&dir); + let source = std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap(); + let at = source.len(); + let intent = EditIntent { + file: "hello/triggers/win.lua".into(), + start: at, + end: at, + new_text: "\nWhen(Objective(\"x\").IsComplete())\n\t.Do(Objective(\"y\").Complete())\n".into(), + base_hash: Some(crate::recognizer::fnv1a(source.as_bytes())), + }; + apply_edit(&dir, &intent).unwrap(); + let edited = std::fs::read_to_string(dir.join("hello/triggers/win.lua")).unwrap(); + let rec = crate::recognizer::recognize_file("t.lua", &edited).unwrap(); + assert!(rec.findings.is_empty()); + assert_eq!(rec.file.groups[0].triggers.len(), 2); + } + + fn intent_json(file: &str, start: usize, end: usize, new_text: &str, hash: &str) -> String { + serde_json::json!({ + "file": file, + "start": start, + "end": end, + "new_text": new_text, + "base_hash": hash, + }) + .to_string() + } + + /// The ordinary way people use the form: change a field, change another. + /// Both intents carry the same view generation's hash, both are drained in + /// one pass, and the second's offsets are stale by the first's length + /// change. Non-overlapping spans — both must land. + #[test] + fn two_edits_from_one_view_generation_both_land() { + let dir = tmpdir("collide"); + setup(&dir); + let editor = tmpdir("collide-editor"); + std::fs::create_dir_all(editor.join("edits")).unwrap(); + let mut server = Server::new(dir.clone(), editor.clone(), String::new(), None); + + let file = "hello/triggers/win.lua"; + let source = std::fs::read_to_string(dir.join(file)).unwrap(); + let hash = crate::recognizer::fnv1a(source.as_bytes()); + let count = source.find(", 3)").unwrap() + 2; + let name = source.find("\"x\"").unwrap(); + + std::fs::write( + editor.join("edits/900_1.json"), + intent_json(file, count, count + 1, "55", &hash), + ) + .unwrap(); + std::fs::write( + editor.join("edits/1000_2.json"), + intent_json(file, name, name + 3, "\"win\"", &hash), + ) + .unwrap(); + + server.consume_edits(); + let edited = std::fs::read_to_string(dir.join(file)).unwrap(); + assert!(edited.contains(", 55)"), "count edit lost: {edited:?}"); + assert!(edited.contains("Objective(\"win\")"), "name edit lost: {edited:?}"); + } + + /// The safety property: an edit whose OWN region moved underneath it is + /// still refused, and the file keeps the newer write. + #[test] + fn an_edit_whose_own_span_moved_is_still_refused() { + let dir = tmpdir("overlap"); + setup(&dir); + let editor = tmpdir("overlap-editor"); + std::fs::create_dir_all(editor.join("edits")).unwrap(); + let mut server = Server::new(dir.clone(), editor.clone(), String::new(), None); + + let file = "hello/triggers/win.lua"; + let source = std::fs::read_to_string(dir.join(file)).unwrap(); + let hash = crate::recognizer::fnv1a(source.as_bytes()); + let chain_start = source.find("When(").unwrap(); + let when_end = source.find(")\n").map(|i| i + 2).unwrap_or(source.len()); + let count = source.find(", 3)").unwrap() + 2; + + // 1: rewrite the whole When line. 2: retune the count inside it. + std::fs::write( + editor.join("edits/900_1.json"), + intent_json( + file, + chain_start, + when_end, + "When(Team.Player.Has(UnitDef(\"armck\"), 9))\n", + &hash, + ), + ) + .unwrap(); + std::fs::write( + editor.join("edits/900_2.json"), + intent_json(file, count, count + 1, "55", &hash), + ) + .unwrap(); + + server.consume_edits(); + let edited = std::fs::read_to_string(dir.join(file)).unwrap(); + assert!(edited.contains("armck\"), 9)"), "first edit lost: {edited:?}"); + assert!(!edited.contains("55"), "stale edit wrote into a moved region: {edited:?}"); + let status = std::fs::read_to_string(editor.join("status.json")).unwrap(); + assert!(status.contains("overlaps"), "{status}"); + } + + /// A rebased edit still faces the grammar gate. + #[test] + fn a_rebased_edit_that_breaks_the_grammar_is_rejected() { + let dir = tmpdir("rebase-grammar"); + setup(&dir); + let editor = tmpdir("rebase-grammar-editor"); + std::fs::create_dir_all(editor.join("edits")).unwrap(); + let mut server = Server::new(dir.clone(), editor.clone(), String::new(), None); + + let file = "hello/triggers/win.lua"; + let source = std::fs::read_to_string(dir.join(file)).unwrap(); + let hash = crate::recognizer::fnv1a(source.as_bytes()); + let count = source.find(", 3)").unwrap() + 2; + let name = source.find("\"x\"").unwrap(); + + std::fs::write( + editor.join("edits/900_1.json"), + intent_json(file, count, count + 1, "55", &hash), + ) + .unwrap(); + std::fs::write( + editor.join("edits/900_2.json"), + intent_json(file, name, name + 3, "function() end", &hash), + ) + .unwrap(); + + server.consume_edits(); + let edited = std::fs::read_to_string(dir.join(file)).unwrap(); + assert!(edited.contains(", 55)"), "{edited:?}"); + assert!(!edited.contains("function"), "{edited:?}"); + } + + /// Intent filenames are `_.json`; drained in submission order, + /// two writes to one field leave the later value. + #[test] + fn the_same_field_edited_twice_keeps_the_later_value() { + let dir = tmpdir("samefield"); + setup(&dir); + let editor = tmpdir("samefield-editor"); + std::fs::create_dir_all(editor.join("edits")).unwrap(); + let mut server = Server::new(dir.clone(), editor.clone(), String::new(), None); + + let file = "hello/triggers/win.lua"; + let source = std::fs::read_to_string(dir.join(file)).unwrap(); + let hash = crate::recognizer::fnv1a(source.as_bytes()); + let count = source.find(", 3)").unwrap() + 2; + + std::fs::write( + editor.join("edits/900_1.json"), + intent_json(file, count, count + 1, "5", &hash), + ) + .unwrap(); + std::fs::write( + editor.join("edits/1000_2.json"), + intent_json(file, count, count + 1, "55", &hash), + ) + .unwrap(); + + server.consume_edits(); + let edited = std::fs::read_to_string(dir.join(file)).unwrap(); + assert!(edited.contains(", 55)"), "later value lost: {edited:?}"); + } + + #[test] + fn intents_drain_in_numeric_submission_order() { + let names = ["1000_2.json", "900_1.json", "900_10.json", "900_9.json", "http_5_2.json"]; + let mut paths: Vec = names.iter().map(PathBuf::from).collect(); + paths.sort_by_key(|p| natural_key(p)); + let sorted: Vec = + paths.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect(); + assert_eq!(sorted, ["http_5_2.json", "900_1.json", "900_9.json", "900_10.json", "1000_2.json"]); + } + + #[test] + fn open_requests_become_routable_targets() { + let dir = tmpdir("open"); + setup(&dir); + let editor = tmpdir("open-editor"); + let mut server = Server::new(dir.clone(), editor.clone(), String::new(), None); + std::fs::write( + editor.join("open_request.json"), + "{\"file\":\"hello/triggers/win.lua\",\"line\":2}", + ) + .unwrap(); + server.consume_open_request(); + let target: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(editor.join("open_target.json")).unwrap()) + .unwrap(); + assert_eq!(target["seq"], 1); + assert_eq!(target["line"], 2); + assert!(target["file"].as_str().unwrap().ends_with("hello/triggers/win.lua")); + assert!(target["file"].as_str().unwrap().starts_with('/'), "absolute path for window routing"); + assert!(!editor.join("open_request.json").exists()); + } + + #[test] + fn the_editor_follows_the_armed_mission() { + let root = tmpdir("follow-root"); + std::fs::create_dir_all(root.join("alpha/triggers")).unwrap(); + std::fs::create_dir_all(root.join("beta/triggers")).unwrap(); + let editor = tmpdir("follow-editor"); + let mut server = Server::new(root.clone(), editor.clone(), String::new(), Some(root.clone())); + + std::fs::write(editor.join("active_mission.json"), "{\"name\":\"beta\"}").unwrap(); + server.follow_active(); + assert_eq!(server.missions_dir, root.join("beta")); + + std::fs::write(editor.join("active_mission.json"), "{\"name\":\"../../etc\"}").unwrap(); + server.follow_active(); + assert_eq!(server.missions_dir, root.join("beta")); + + let mut pinned = Server::new(root.join("alpha"), editor.clone(), String::new(), None); + std::fs::write(editor.join("active_mission.json"), "{\"name\":\"beta\"}").unwrap(); + pinned.follow_active(); + assert_eq!(pinned.missions_dir, root.join("alpha")); + } + + #[test] + fn a_crumb_selection_rescopes_and_survives_the_standing_arm_file() { + let root = tmpdir("select-root"); + std::fs::create_dir_all(root.join("alpha/triggers")).unwrap(); + std::fs::create_dir_all(root.join("beta/triggers")).unwrap(); + let editor = tmpdir("select-editor"); + let mut server = + Server::new(root.join("alpha"), editor.clone(), String::new(), Some(root.clone())); + + std::fs::write(editor.join("active_mission.json"), "{\"name\":\"beta\"}").unwrap(); + server.follow_active(); + assert_eq!(server.missions_dir, root.join("beta")); + + std::fs::write(editor.join("select_mission.json"), "{\"name\":\"alpha\"}").unwrap(); + server.consume_select_mission(); + assert_eq!(server.missions_dir, root.join("alpha")); + assert!(!editor.join("select_mission.json").exists()); + + // a standing (unchanged) arm file must not yank the crumb pick back + server.follow_active(); + assert_eq!(server.missions_dir, root.join("alpha")); + + std::fs::write(editor.join("active_mission.json"), "{\"name\":\"beta\",\"t\":2}").unwrap(); + server.follow_active(); + assert_eq!(server.missions_dir, root.join("beta")); + + std::fs::write(editor.join("select_mission.json"), "{\"name\":\"../../etc\"}").unwrap(); + server.consume_select_mission(); + assert_eq!(server.missions_dir, root.join("beta")); + + let mut pinned = Server::new(root.join("alpha"), editor.clone(), String::new(), None); + std::fs::write(editor.join("select_mission.json"), "{\"name\":\"beta\"}").unwrap(); + pinned.consume_select_mission(); + assert_eq!(pinned.missions_dir, root.join("alpha")); + assert!(!editor.join("select_mission.json").exists()); + } + + #[test] + fn missions_are_listed_by_trigger_presence() { + let root = tmpdir("list-root"); + std::fs::create_dir_all(root.join("alpha/triggers")).unwrap(); + std::fs::write(root.join("alpha/triggers/win.lua"), "x").unwrap(); + std::fs::create_dir_all(root.join("lib")).unwrap(); + std::fs::create_dir_all(root.join("beta/deep/triggers")).unwrap(); + std::fs::write(root.join("beta/deep/triggers/t.lua"), "x").unwrap(); + let server = + Server::new(root.join("alpha"), tmpdir("list-editor"), String::new(), Some(root.clone())); + assert_eq!(server.list_missions(), vec!["alpha".to_string(), "beta".to_string()]); + + let pinned = Server::new(root.join("alpha"), tmpdir("list-editor2"), String::new(), None); + assert!(pinned.list_missions().is_empty()); + } + + #[test] + fn paths_cannot_escape_the_missions_dir() { + let dir = tmpdir("escape"); + setup(&dir); + let intent = EditIntent { + file: "../../etc/passwd".into(), + start: 0, + end: 0, + new_text: "x".into(), + base_hash: None, + }; + assert!(apply_edit(&dir, &intent).is_err()); + } +} diff --git a/bar-mission-kit/src/types.rs b/bar-mission-kit/src/types.rs new file mode 100644 index 00000000..c130867e --- /dev/null +++ b/bar-mission-kit/src/types.rs @@ -0,0 +1,1125 @@ +//! The type surface: the kit's model of the mission DSL, derived from the +//! game's LuaCATS annotation files under types/. Statement heads and chain +//! verbs come from the injected globals and chain classes; alias names +//! become slot semantics (UnitDefName -> unit_def_name), and literal-union +//! aliases become editor enums. Anything untyped stays the opaque exit +//! hatch. The parser reads the line-oriented LuaCATS subset those files +//! use — it is not a full doc-type parser. +//! +//! Embedded fixtures are the snapshot used by tests and as a fallback when +//! no types/ dir is found near the mission. + +use std::collections::BTreeMap; + +/// The game's published DSL types, one file per module exactly as the game +/// ships them — never merged. A merged copy hid a rename once: two modules' +/// vocabulary fused into one file cannot be diffed against either module. +/// `just bar::sync-kit-fixtures --check` fails when these drift. +pub const SNAPSHOTS: &[&str] = &[ + include_str!("../fixtures/modules/missions/types/missions.lua"), + include_str!("../fixtures/modules/missions/types/mode_policy.lua"), + include_str!("../fixtures/modules/missions/types/trigger_policy.lua"), + include_str!("../fixtures/modules/combat/types/actions.lua"), + include_str!("../fixtures/modules/construction/types/actions.lua"), + include_str!("../fixtures/modules/matchflow/types/actions.lua"), + include_str!("../fixtures/modules/scavengers/types/actions.lua"), + include_str!("../fixtures/modules/scavengers/types/mode_policy.lua"), + include_str!("../fixtures/modules/transfer/types/actions.lua"), + include_str!("../fixtures/modules/transfer/types/mode_policy.lua"), + include_str!("../fixtures/modules/waves/types/actions.lua"), +]; + +#[derive(Debug, Clone)] +pub struct FnSig { + /// (name, type) per parameter, in order. + pub params: Vec<(String, String)>, + pub ret: Option, +} + +#[derive(Debug, Clone)] +pub enum Global { + Fn(FnSig), + /// `---@type ClassName` or `---@type { Member: ClassName, ... }`, + /// normalized to member -> type. + Object(BTreeMap), +} + +#[derive(Debug, Default)] +pub struct TypeSurface { + /// alias name -> string literals, for union-of-literals aliases. + pub aliases: BTreeMap>>, + /// class name -> field name -> signature (fun-typed fields only). + pub classes: BTreeMap>, + pub globals: BTreeMap, + /// class name -> field name -> the class that field is typed as. Kept so a + /// field whose type is a CALLABLE class (an action: `---@overload`) can be + /// resolved into a signature once every source has been read. + class_typed_fields: BTreeMap>, +} + +impl TypeSurface { + pub fn parse(sources: &[&str]) -> TypeSurface { + let mut surface = TypeSurface::default(); + for source in sources { + surface.parse_source(source); + } + surface.resolve_callable_fields(); + surface + } + + /// An action is declared as a class that is callable (`---@overload`), and + /// referenced as a field typed with that class. Resolve those fields to + /// the call signature, so one declaration answers both grammars: the mode + /// reads the field, the mission calls it. + fn resolve_callable_fields(&mut self) { + let resolved: Vec<(String, String, FnSig)> = self + .class_typed_fields + .iter() + .flat_map(|(class, fields)| fields.iter().map(move |(f, ty)| (class, f, ty))) + .filter_map(|(class, field, ty)| { + let sig = self.classes.get(ty)?.get(CALLABLE)?; + Some((class.clone(), field.clone(), sig.clone())) + }) + .collect(); + for (class, field, sig) in resolved { + self.classes.entry(class).or_default().insert(field, sig); + } + } + + /// The snapshot surface: tests, and the fallback when the game tree's + /// types/ dir is not found near the mission files. + #[cfg_attr(not(test), allow(dead_code))] + pub fn builtin() -> &'static TypeSurface { + static BUILTIN: std::sync::OnceLock = std::sync::OnceLock::new(); + BUILTIN.get_or_init(|| TypeSurface::parse(SNAPSHOTS)) + } + + /// Load the game's annotation files by walking ancestors of the given + /// paths for a types/ dir with surface-marked files. Falls back to the + /// built-in snapshot when none is found. + pub fn load_near(paths: &[std::path::PathBuf]) -> TypeSurface { + TypeSurface::load_near_policy(paths, "trigger") + } + + /// The same walk, for a named policy. A mode preset is written in the mode + /// vocabulary, not the trigger one — composing "trigger" for it hands the + /// file Spawn/When and no Mode at all, which is not a missing declaration + /// but the wrong dictionary. + pub fn load_near_policy(paths: &[std::path::PathBuf], policy: Policy) -> TypeSurface { + for path in paths { + if let Some(dir) = TypeSurface::types_dir_near_policy(path, policy) { + // A module's surface = its own marked types plus the marked + // types of every module its manifest requires (transitively): + // vocabulary travels with the module that injects it, and the + // manifest graph is what composes the sandbox. + let mut sources = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + let mut queue = vec![dir.clone()]; + while let Some(types_dir) = queue.pop() { + if !seen.insert(types_dir.clone()) { + continue; + } + // Only a mission's vocabulary composes down the requires + // graph. A mode preset is written in ITS OWN module's mode + // vocabulary: every module that has presets declares its own + // Mode chain, so composing the graph merges several + // different Mode heads and an arbitrary one wins. Missions + // requires transfer, and a missions preset was being checked + // against transfer's sharing chain — Own rejected, Tax and + // Gate offered instead. + if policy != "trigger" { + sources.extend(surface_sources(&types_dir, policy)); + continue; + } + for name in manifest_requires(&types_dir) { + if let Some(module_dir) = types_dir.parent().and_then(|m| m.parent()) { + let required = module_dir.join(&name).join("types"); + if !surface_sources(&required, policy).is_empty() { + queue.push(required); + } + } + } + sources.extend(surface_sources(&types_dir, policy)); + } + let refs: Vec<&str> = sources.iter().map(String::as_str).collect(); + return TypeSurface::parse(&refs); + } + } + TypeSurface::parse(SNAPSHOTS) + } + + /// The nearest ancestor types/ dir containing surface-marked files — + /// the per-file surface cache key. + pub fn types_dir_near(path: &std::path::Path) -> Option { + TypeSurface::types_dir_near_policy(path, "trigger") + } + + /// As above, for a named policy: the nearest types/ dir that publishes THAT + /// vocabulary. A module with triggers but no modes must not answer for a + /// preset, or the preset silently gets the wrong dictionary. + pub fn types_dir_near_policy( + path: &std::path::Path, + policy: Policy, + ) -> Option { + let start = if path.is_dir() { Some(path) } else { path.parent() }; + let mut ancestor = start; + while let Some(dir) = ancestor { + let candidate = dir.join("types"); + if !surface_sources(&candidate, policy).is_empty() { + return Some(candidate); + } + ancestor = dir.parent(); + } + None + } + + fn parse_source(&mut self, source: &str) { + let mut current_class: Option = None; + let mut current_alias: Option = None; + let mut pending_params: Vec<(String, String)> = Vec::new(); + let mut pending_ret: Option = None; + let mut pending_type: Option = None; + + for raw in source.lines() { + let line = raw.trim(); + if let Some(rest) = line.strip_prefix("---@") { + let (tag, rest) = split_word(rest); + match tag { + "class" => { + let (name, _) = split_word(rest); + current_class = Some(name.to_string()); + current_alias = None; + self.classes.entry(name.to_string()).or_default(); + } + "field" => { + if let Some(class) = ¤t_class { + let (name, rest) = split_word(rest); + // A literally-named field may be bracket-quoted + // when it collides with a LuaCATS keyword. + let name = name + .strip_prefix("[\"") + .and_then(|n| n.strip_suffix("\"]")) + .unwrap_or(name); + // parse_fun consumes the whole rest (a fun type + // contains spaces); non-fun fields are data, not + // grammar, and are skipped. + if let Some(sig) = parse_fun(rest) { + self.classes + .entry(class.clone()) + .or_default() + .insert(name.to_string(), sig); + } else { + // Not a fun: remember what class it IS, in case + // that class turns out to be callable. + let (ty, _) = split_word(rest); + self.class_typed_fields + .entry(class.clone()) + .or_default() + .insert(name.to_string(), ty.to_string()); + } + } + } + // `---@overload fun(...)` on a class: the class itself is + // callable. Stored under a reserved key so a field typed + // with it resolves to this signature. + "overload" => { + if let Some(class) = ¤t_class { + if let Some(sig) = parse_fun(rest) { + self.classes + .entry(class.clone()) + .or_default() + .insert(CALLABLE.to_string(), sig); + } + } + } + "alias" => { + let (name, rest) = split_word(rest); + let (type_expr, _) = split_type(rest); + let literals = parse_literal_union(type_expr); + current_alias = Some(name.to_string()); + current_class = None; + self.aliases.insert(name.to_string(), literals); + } + "param" => { + let (name, rest) = split_word(rest); + let (type_expr, _) = split_type(rest); + pending_params.push((name.to_string(), type_expr.to_string())); + } + "return" => { + let (type_expr, _) = split_type(rest); + pending_ret = Some(type_expr.to_string()); + } + "type" => { + pending_type = Some(rest.trim().to_string()); + } + _ => {} + } + continue; + } + // `---| "literal"` continuation lines extend the open alias union. + if let Some(rest) = line.strip_prefix("---|") { + if let Some(alias) = ¤t_alias { + if let Some(literal) = parse_string_literal(rest.trim()) { + self.aliases + .entry(alias.clone()) + .or_insert_with(|| Some(Vec::new())) + .get_or_insert_with(Vec::new) + .push(literal); + } + } + continue; + } + if line.starts_with("---") || line.is_empty() { + continue; + } + current_alias = None; + // Global function stub: `function Name(...) end` + if let Some(rest) = line.strip_prefix("function ") { + if let Some(paren) = rest.find('(') { + let name = &rest[..paren]; + if !name.contains('.') && !name.contains(':') { + self.globals.insert( + name.to_string(), + Global::Fn(FnSig { + params: std::mem::take(&mut pending_params), + ret: pending_ret.take(), + }), + ); + } + } + } else if let Some(eq) = line.find(" = ") { + // Global object stub: `Name = {}` with a pending ---@type. + let name = &line[..eq]; + if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + if let Some(type_expr) = pending_type.take() { + self.globals + .insert(name.to_string(), Global::Object(parse_object_type(&type_expr))); + } + } + } + pending_params.clear(); + pending_ret = None; + pending_type = None; + } + } + + /// A chain class: at least one field, every field a fun returning the + /// class itself. These are the builder chains statements are made of. + pub fn is_chain_class(&self, name: &str) -> bool { + self.classes + .get(name) + .map(|fields| { + !fields.is_empty() && fields.values().all(|sig| sig.ret.as_deref() == Some(name)) + }) + .unwrap_or(false) + } + + /// Statement heads: injected globals returning a chain class, mapped to + /// that class's name. + pub fn statement_heads(&self) -> BTreeMap { + let mut heads = BTreeMap::new(); + for (name, global) in &self.globals { + if let Global::Fn(sig) = global { + if let Some(ret) = &sig.ret { + if self.is_chain_class(ret) { + heads.insert(name.clone(), ret.clone()); + } + } + } + } + heads + } + + /// The chain verbs a statement head admits (the chain class's fields). + pub fn chain_verbs(&self, head: &str) -> Vec { + self.statement_heads() + .get(head) + .and_then(|class| self.classes.get(class)) + .map(|fields| fields.keys().cloned().collect()) + .unwrap_or_default() + } + + /// The signature behind one step of a statement: the head fn itself for + /// the first invocation, the chain class's field for later ones. + pub fn step_sig(&self, head: &str, verb: &str) -> Option<&FnSig> { + if verb == head { + match self.globals.get(head) { + Some(Global::Fn(sig)) => Some(sig), + _ => None, + } + } else { + let class = match self.globals.get(head) { + Some(Global::Fn(sig)) => sig.ret.as_deref()?, + _ => return None, + }; + self.classes.get(class)?.get(verb) + } + } + + /// Resolve a dotted path (e.g. Team.Player.Has) to the signature of its + /// final callable member. Returns None when any hop leaves the surface. + pub fn resolve_path(&self, path: &str) -> Option { + let mut segments = path.split('.'); + let first = segments.next()?; + match self.globals.get(first)? { + Global::Fn(sig) => { + // A bare global fn path has no further segments. + if segments.next().is_some() { + return None; + } + Some(sig.clone()) + } + Global::Object(members) => { + // A bare `---@type ClassName` object stores its class under + // the "" marker; member lookups then go through the class. + let mut current_class = members.get("").cloned(); + let mut object_members = if current_class.is_some() { None } else { Some(members) }; + for segment in segments { + if let Some(m) = object_members { + let type_name = m.get(segment)?.clone(); + if self.classes.contains_key(&type_name) { + current_class = Some(type_name); + object_members = None; + } else { + return None; + } + } else { + // Field lookup in the current class ends the path. + let class = current_class.as_deref()?; + return self.classes.get(class)?.get(segment).cloned(); + } + } + None + } + } + } + + /// The signature of `.name(...)` chained after something returning + /// `class` (or of `class`'s member for the first named call). + pub fn member_sig(&self, class: &str, name: &str) -> Option<&FnSig> { + self.classes.get(class)?.get(name) + } + + /// Classify a class's callable fields by what they return, recording them + /// under `prefix` (Combat -> Combat.Protect, Unit -> Unit().IsDestroyed). + fn walk_class(&self, class: &str, prefix: &str, roles: &mut Roles) { + let Some(fields) = self.classes.get(class) else { + roles.nouns.push(prefix.to_string()); + return; + }; + if fields.is_empty() { + roles.nouns.push(prefix.to_string()); + return; + } + for (field, sig) in fields { + if field == CALLABLE { + continue; // the class's own call, reported by whoever names it + } + let name = format!("{prefix}.{field}"); + match sig.ret.as_deref() { + Some(ret) if ret == class => {} + Some("MissionCondition") => roles.conditions.push(name), + Some(ret) if ret.contains("Effect") => roles.effects.push(name), + Some(ret) if self.classes.contains_key(ret) => self.walk_class(ret, &name, roles), + _ => roles.nouns.push(name), + } + } + // Fields typed with a class that is NOT callable: an action's mode + // facet, which a grant is written against. Dropping them would lose + // half of what a single declaration says. + for (field, ty) in self.class_typed_fields.get(class).into_iter().flatten() { + if fields.contains_key(field) { + continue; // already reported through its call signature + } + // Only a declared class is vocabulary; a field typed `integer` or + // `string` is the shape of a handle, not something to name. + let Some(members) = self.classes.get(ty) else { continue }; + let name = format!("{prefix}.{field}"); + if members.is_empty() { + roles.nouns.push(name); + } else { + self.walk_class(ty, &name, roles); + } + } + } + /// The semantic slug a parameter type carries, if any: a declared alias + /// becomes snake_case with the Mission prefix dropped. + pub fn semantic_for(&self, type_name: &str) -> Option { + if self.aliases.contains_key(type_name) { + Some(alias_slug(type_name)) + } else { + None + } + } + + /// semantic slug -> options, for aliases that are literal unions. + pub fn enums(&self) -> BTreeMap> { + let mut out = BTreeMap::new(); + for (name, literals) in &self.aliases { + if let Some(options) = literals { + if !options.is_empty() { + out.insert(alias_slug(name), options.clone()); + } + } + } + out + } +} + +/// Reserved field key for a class's own call signature (`---@overload`). +pub const CALLABLE: &str = "__call"; + +/// Two tiers, and a file opens by naming which one it is. +/// +/// `---@meta actions` — what a module can DO. Read by every policy language, +/// because they are all talking about the same capabilities: this is what +/// makes .Allow(Transfer.Units) and Do(Transfer.Units(...)) one entry rather +/// than two that agree. +/// +/// `---@meta policy ` — how RULES over those actions are written. A +/// trigger says when to perform one, a mode says whether it may be performed. +/// Same tier, different languages, so they are parsed apart — and the language +/// is a parameter, not a list here: hosting a new one costs a module a file +/// and costs the kit nothing. +pub const ACTIONS_MARKER: &str = "---@meta actions"; +pub const POLICY_MARKER: &str = "---@meta policy"; + +/// The policy language a file is written for, named by the file itself. +/// "trigger" is a mission's When ... Do; "mode" is a preset's grants. +pub type Policy<'a> = &'a str; + +/// Whether a file publishes into the given policy language: either it declares +/// actions (which every language reads) or it names that language. +fn publishes_into(source: &str, policy: Policy) -> bool { + source.lines().take(4).any(|line| { + let line = line.trim(); + line == ACTIONS_MARKER + || line + .strip_prefix(POLICY_MARKER) + .map(|rest| rest.trim() == policy) + .unwrap_or(false) + }) +} + +/// The `requires = { "name", ... }` list from the module.lua manifest sitting +/// beside a types/ dir; empty when there is no manifest. +fn manifest_requires(types_dir: &std::path::Path) -> Vec { + let Some(module_dir) = types_dir.parent() else { + return Vec::new(); + }; + let Ok(manifest) = std::fs::read_to_string(module_dir.join("module.lua")) else { + return Vec::new(); + }; + let Some(open) = manifest.find("requires") else { + return Vec::new(); + }; + let Some(start) = manifest[open..].find('{').map(|i| open + i + 1) else { + return Vec::new(); + }; + let Some(end) = manifest[start..].find('}').map(|i| start + i) else { + return Vec::new(); + }; + manifest[start..end] + .split(',') + .filter_map(|entry| { + let entry = entry.trim().trim_matches(|c| c == '"' || c == '\''); + (!entry.is_empty()).then(|| entry.to_string()) + }) + .collect() +} + +fn surface_sources(types_dir: &std::path::Path, policy: Policy) -> Vec { + let mut files: Vec = std::fs::read_dir(types_dir) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().map(|x| x == "lua").unwrap_or(false)) + .collect(); + files.sort(); + files + .into_iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .filter(|source| publishes_into(source, policy)) + .collect() +} + +/// A statement head and the steps that chain onto it. +#[derive(Debug, Clone, serde::Serialize, Default)] +pub struct Statement { + pub name: String, + pub steps: Vec, +} + +/// The roles a module's surface fills, as the author thinks of them. +#[derive(Default)] +pub struct Roles { + pub statements: Vec, + pub conditions: Vec, + pub effects: Vec, + pub nouns: Vec, +} + +/// An example call for a published verb, built from its signature. The palette +/// used to hand-write these, which meant a renamed verb left a template nobody +/// could run — the types are the only authored copy, so derive from them. +impl TypeSurface { + /// Roles derived from return types: a callable returning a condition is + /// something a When can ask, one returning an effect is something a Do can + /// run, a self-returning chain field is a step of its statement. + pub fn roles(&self) -> Roles { + let mut roles = Roles::default(); + for (name, global) in &self.globals { + match global { + Global::Fn(sig) => match sig.ret.as_deref() { + Some(ret) if self.is_chain_class(ret) => { + let mut steps: Vec = self + .classes + .get(ret) + .map(|fields| fields.keys().map(|f| format!(".{f}")).collect()) + .unwrap_or_default(); + steps.sort(); + roles.statements.push(Statement { name: name.clone(), steps }); + } + Some(ret) => self.walk_class(ret, name, &mut roles), + None => {} + }, + Global::Object(members) => { + if let Some(class) = members.get("") { + self.walk_class(class, name, &mut roles); + } else { + for (member, class) in members { + self.walk_class(class, &format!("{name}.{member}"), &mut roles); + } + } + } + } + } + roles + } + + pub fn template_for(&self, path: &str) -> Option { + let mut segments = path.split('.'); + let root = segments.next()?; + let mut out = String::from(root); + let mut class = match self.globals.get(root)? { + Global::Fn(sig) => { + out.push_str(&self.arguments(sig)); + sig.ret.clone()? + } + Global::Object(members) => match members.get("") { + // `---@type Class`: the global IS the class; segments that + // follow are its fields. + Some(class) => class.clone(), + // `---@type { Member: Class }`: the next segment names a member. + None => { + let next = segments.next()?; + out.push('.'); + out.push_str(next); + members.get(next)?.clone() + } + }, + }; + for segment in segments { + let sig = self.classes.get(&class)?.get(segment)?; + out.push('.'); + out.push_str(segment); + // Namespace or call, decided exactly as walk_class decides roles: + // conditions and effects are calls even though their returns are + // classes; anything else returning a class is a namespace. + let namespace = match sig.ret.as_deref() { + Some("MissionCondition") => false, + Some(ret) if ret.contains("Effect") => false, + Some(ret) => self.classes.contains_key(ret), + None => false, + }; + if !namespace { + out.push_str(&self.arguments(sig)); + } + class = sig.ret.clone().unwrap_or_default(); + } + Some(out) + } + + fn arguments(&self, sig: &FnSig) -> String { + let args: Vec = sig.params.iter().map(|(_, ty)| self.example(ty)).collect(); + format!("({})", args.join(", ")) + } + + /// A stand-in value per parameter type. Placeholders are shouted so an + /// author sees what still needs filling in. + fn example(&self, ty: &str) -> String { + match ty.trim_end_matches('?') { + "MissionUnitGroup" => "\"GROUP\"".into(), + "MissionUnitName" => "\"UNIT_NAME\"".into(), + "ObjectiveName" => "\"OBJECTIVE\"".into(), + "MissionUnitGroupName" => "\"GROUP\"".into(), + "UnitDefName" => "\"armpw\"".into(), + "MissionTeam" => "Team.Player".into(), + "MissionTeamRole" => "\"player\"".into(), + "MissionUnitRef" => "Unit(\"UNIT_NAME\")".into(), + "MissionUnitDefRef" => "UnitDef(\"armpw\")".into(), + "MissionObjective" => "Objective(\"OBJECTIVE\")".into(), + "MissionCondition" => "Objective(\"OBJECTIVE\").IsComplete()".into(), + "MissionEffect" => "Objective(\"OBJECTIVE\").Complete()".into(), + "integer" | "number" => "3".into(), + "boolean" => "true".into(), + "string" => "\"TEXT\"".into(), + other => { + // A literal-union alias is its own best example. + match self.aliases.get(other) { + Some(Some(values)) if !values.is_empty() => format!("\"{}\"", values[0]), + _ => "nil".into(), + } + } + } + } +} + +/// One module as the editor shows it: what it is, what it requires, and what +/// vocabulary it puts in the sandbox. Derived from the same marked types the +/// grammar comes from — a module that publishes a surface is explorable, no +/// registration anywhere. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ModuleInfo { + pub name: String, + pub description: String, + pub requires: Vec, + /// Statement heads with the steps each one takes: a step means nothing + /// apart from the statement it belongs to (.At is Spawn's, not Mode's). + pub statements: Vec, + /// Callables returning a condition (what a When can ask). + pub conditions: Vec, + /// Callables returning an effect (what a Do can run). + pub effects: Vec, + /// Nouns a verb takes as its subject (Share.Units, Match.End). + pub nouns: Vec, + /// Mode presets shipped under /modes/. + pub modes: Vec, +} + +/// Read one field out of a module.lua manifest (`name = "x"` / `description = "y"`). +fn manifest_field(manifest: &str, field: &str) -> Option { + let at = manifest.find(&format!("{field}"))?; + let rest = &manifest[at..]; + let eq = rest.find('=')?; + let tail = rest[eq + 1..].trim_start(); + let quote = tail.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let body = &tail[1..]; + let end = body.find(quote)?; + Some(body[..end].to_string()) +} + +/// Every module under the modules root that publishes a marked surface. +pub fn explore_modules(modules_root: &std::path::Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(modules_root) else { + return out; + }; + let mut dirs: Vec = + entries.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); + dirs.sort(); + for dir in dirs { + let mission_sources = surface_sources(&dir.join("types"), "trigger"); + let mode_sources = surface_sources(&dir.join("types"), "mode"); + let manifest = std::fs::read_to_string(dir.join("module.lua")).unwrap_or_default(); + if mission_sources.is_empty() && mode_sources.is_empty() && manifest.is_empty() { + continue; + } + let name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + // Roles are derivable from return types, so derive them: a callable + // returning a condition is something a When can ask, one returning an + // effect is something a Do can run, and a self-returning chain field + // is a step of the statement it belongs to. Each sandbox derives from + // its own parse — the module card shows both, but a mode grant never + // becomes the answer to what a mission verb means. + let roles_of = |sources: &[String]| { + let refs: Vec<&str> = sources.iter().map(String::as_str).collect(); + TypeSurface::parse(&refs).roles() + }; + let mission = roles_of(&mission_sources); + let mode = roles_of(&mode_sources); + let mut statements = mission.statements; + let mut conditions = mission.conditions; + let mut effects = mission.effects; + let mut nouns = mission.nouns; + statements.extend(mode.statements); + conditions.extend(mode.conditions); + effects.extend(mode.effects); + nouns.extend(mode.nouns); + statements.sort_by(|a, b| a.name.cmp(&b.name)); + for list in [&mut conditions, &mut effects, &mut nouns] { + list.sort(); + list.dedup(); + } + let mut modes: Vec = std::fs::read_dir(dir.join("modes")) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let p = e.path(); + (p.extension().map(|x| x == "lua").unwrap_or(false)) + .then(|| p.file_stem()?.to_str().map(|s| s.to_string())) + .flatten() + }) + .collect(); + modes.sort(); + out.push(ModuleInfo { + description: manifest_field(&manifest, "description").unwrap_or_default(), + requires: manifest_requires(&dir.join("types")), + name, + statements, + conditions, + effects, + nouns, + modes, + }); + } + out +} + +/// MissionUnitName -> unit_name, UnitDefName -> unit_def_name. +pub fn alias_slug(name: &str) -> String { + let name = name.strip_prefix("Mission").unwrap_or(name); + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_ascii_uppercase() { + if i > 0 { + out.push('_'); + } + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out +} + +fn split_word(text: &str) -> (&str, &str) { + let text = text.trim_start(); + match text.find(char::is_whitespace) { + Some(at) => (&text[..at], text[at..].trim_start()), + None => (text, ""), + } +} + +/// Split one type expression off the front of `text`. Whitespace ends it, +/// except inside (), <> or {} — so `fun(a: T, b: U): R`, `table` and +/// `{ A: B }` survive; the trailing prose comment does not. +fn split_type(text: &str) -> (&str, &str) { + let text = text.trim_start(); + let mut depth = 0usize; + for (i, c) in text.char_indices() { + match c { + '(' | '<' | '{' | '[' => depth += 1, + ')' | '>' | '}' | ']' => depth = depth.saturating_sub(1), + c if c.is_whitespace() && depth == 0 => { + return (&text[..i], text[i..].trim_start()); + } + _ => {} + } + } + (text, "") +} + +/// `fun(a: T, b: U): R` -> FnSig. None when the expression is not a fun type. +fn parse_fun(type_expr: &str) -> Option { + let rest = type_expr.strip_prefix("fun(")?; + let close = matching_paren(rest)?; + let params_text = &rest[..close]; + let after = &rest[close + 1..]; + let ret = after + .strip_prefix(':') + .map(|r| split_type(r.trim_start()).0.to_string()); + let mut params = Vec::new(); + for part in split_top_level(params_text, ',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + match part.split_once(':') { + Some((name, type_name)) => { + params.push((name.trim().to_string(), type_name.trim().to_string())) + } + None => params.push((part.to_string(), String::new())), + } + } + Some(FnSig { params, ret }) +} + +fn matching_paren(text: &str) -> Option { + let mut depth = 0usize; + for (i, c) in text.char_indices() { + match c { + '(' => depth += 1, + ')' => { + if depth == 0 { + return Some(i); + } + depth -= 1; + } + _ => {} + } + } + None +} + +fn split_top_level(text: &str, separator: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (i, c) in text.char_indices() { + match c { + '(' | '<' | '{' | '[' => depth += 1, + ')' | '>' | '}' | ']' => depth = depth.saturating_sub(1), + c if c == separator && depth == 0 => { + parts.push(&text[start..i]); + start = i + c.len_utf8(); + } + _ => {} + } + } + parts.push(&text[start..]); + parts +} + +/// `"a"|"b"|"c"` -> Some([a, b, c]); anything else -> None. +fn parse_literal_union(type_expr: &str) -> Option> { + let mut literals = Vec::new(); + for part in split_top_level(type_expr, '|') { + literals.push(parse_string_literal(part.trim())?); + } + if literals.is_empty() { + None + } else { + Some(literals) + } +} + +fn parse_string_literal(text: &str) -> Option { + let text = split_type(text).0; + let rest = text.strip_prefix('"')?; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +/// `{ Player: MissionTeam, ... }` -> member map; a bare class name becomes a +/// synthetic object whose members are that class's fields (resolved lazily by +/// callers via classes) — here it is stored as a single "" -> name marker. +fn parse_object_type(type_expr: &str) -> BTreeMap { + let mut members = BTreeMap::new(); + let inner = type_expr + .trim() + .strip_prefix('{') + .and_then(|t| t.strip_suffix('}')); + match inner { + Some(inner) => { + for part in split_top_level(inner, ',') { + if let Some((name, type_name)) = part.split_once(':') { + members.insert(name.trim().to_string(), type_name.trim().to_string()); + } + } + } + None => { + members.insert(String::new(), type_expr.trim().to_string()); + } + } + members +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_snapshot_surface_derives_the_statement_grammar() { + let surface = TypeSurface::builtin(); + let heads = surface.statement_heads(); + assert_eq!(heads.get("When").map(String::as_str), Some("TriggerChain")); + assert_eq!(heads.get("Spawn").map(String::as_str), Some("MissionSpawnChain")); + assert!(!heads.contains_key("Objective")); + assert!(!heads.contains_key("Unit")); + + let mut when_verbs = surface.chain_verbs("When"); + when_verbs.sort(); + // After arrived purely by being declared on TriggerChain in the game's + // types — nothing here names it. That is the grammar being derived + // rather than curated, which is the whole contract of this file. + assert_eq!(when_verbs, vec!["After", "Do", "Once", "When"]); + let mut spawn_verbs = surface.chain_verbs("Spawn"); + spawn_verbs.sort(); + assert_eq!(spawn_verbs, vec!["At", "Grouped", "Named"]); + } + + #[test] + fn semantics_come_from_param_aliases() { + let surface = TypeSurface::builtin(); + let unit_def = match surface.globals.get("UnitDef") { + Some(Global::Fn(sig)) => sig.clone(), + other => panic!("UnitDef should be a global fn, got {other:?}"), + }; + assert_eq!(surface.semantic_for(&unit_def.params[0].1).as_deref(), Some("unit_def_name")); + + let named = surface.step_sig("Spawn", "Named").expect("Named on the spawn chain"); + assert_eq!(surface.semantic_for(&named.params[0].1).as_deref(), Some("unit_name")); + + let spawn = surface.step_sig("Spawn", "Spawn").expect("the head itself"); + assert_eq!(surface.semantic_for(&spawn.params[1].1).as_deref(), Some("team_role")); + + // .After(30) is the case that made the rule worth stating: a bare + // `number` gave the author a nameless box and no way to know whether + // it wanted seconds or frames. The unit lives in the game's types, + // not in a label here. + let after = surface.step_sig("When", "After").expect("After on the trigger chain"); + assert_eq!(surface.semantic_for(&after.params[0].1).as_deref(), Some("seconds")); + } + + #[test] + fn literal_union_aliases_become_editor_enums() { + let enums = TypeSurface::builtin().enums(); + assert_eq!( + enums.get("team_role"), + Some(&vec!["player".to_string(), "enemy".to_string(), "gaia".to_string()]) + ); + } + + #[test] + fn dotted_paths_resolve_through_object_globals() { + let surface = TypeSurface::builtin(); + let has = surface.resolve_path("Team.Player.Has").expect("Team.Player.Has"); + assert_eq!(has.params[1].0, "count"); + assert_eq!(has.ret.as_deref(), Some("MissionCondition")); + assert!(surface.resolve_path("Team.Nobody.Has").is_none()); + } + + #[test] + fn fun_types_with_generic_returns_parse() { + let mut surface = TypeSurface::default(); + surface.parse_source( + "---@class Thing\n---@field Watched fun(): table the watch set\n", + ); + let sig = &surface.classes["Thing"]["Watched"]; + assert_eq!(sig.ret.as_deref(), Some("table")); + } + + #[test] + fn the_surface_is_composed_of_self_declared_files() { + let dir = std::env::temp_dir().join(format!("bmk-marker-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("types")).unwrap(); + std::fs::write( + dir.join("types/dsl.lua"), + "---@meta policy trigger\n\n---@param name UnitDefName\n---@return MissionUnitDefRef\nfunction UnitDef(name) end\n", + ) + .unwrap(); + std::fs::write( + dir.join("types/extra.lua"), + "---@meta policy trigger\n\n---@alias UnitDefName string\n", + ) + .unwrap(); + std::fs::write( + dir.join("types/dsl_proposed.lua"), + "---@meta\n\n---@return TriggerChain\nfunction Ban(name) end\n", + ) + .unwrap(); + + let surface = TypeSurface::load_near(&[dir.join("some_mission")]); + assert!(surface.globals.contains_key("UnitDef")); + assert!(surface.aliases.contains_key("UnitDefName"), "composed from the second file"); + assert!(!surface.globals.contains_key("Ban"), "unmarked scratch must stay out"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_mode_preset_resolves_its_own_module_s_mode_surface() { + // The bug this pins: surface resolution only ever composed "trigger", + // so a preset inside a real modules tree was handed Spawn/When and no + // Mode at all. It passed in tests only because a tree with no types dir + // falls back to the bundled snapshot, which happens to carry both. + let dir = std::env::temp_dir().join(format!("kit_modesurface_{}", std::process::id())); + let alpha = dir.join("modules/alpha"); + std::fs::create_dir_all(alpha.join("types")).unwrap(); + std::fs::create_dir_all(alpha.join("modes")).unwrap(); + std::fs::write( + alpha.join("module.lua"), + "return { name = \"alpha\", requires = { \"beta\" } }", + ) + .unwrap(); + std::fs::write( + alpha.join("types/actions.lua"), + "---@meta actions\n---@return integer\nfunction Spawn(a, b) end\n", + ) + .unwrap(); + std::fs::write( + alpha.join("types/mode_policy.lua"), + "---@meta policy mode\n---@class AlphaChain\n---@field Own fun(): AlphaChain\n---@param name string\n---@return AlphaChain\nfunction Mode(name) end\n", + ) + .unwrap(); + + // A required module with its OWN Mode head. Composing the requires + // graph for modes would merge the two and let an arbitrary one win. + let beta = dir.join("modules/beta"); + std::fs::create_dir_all(beta.join("types")).unwrap(); + std::fs::write( + beta.join("types/mode_policy.lua"), + "---@meta policy mode\n---@class BetaChain\n---@field Tax fun(): BetaChain\n---@param name string\n---@return BetaChain\nfunction Mode(name) end\n", + ) + .unwrap(); + + let preset = alpha.join("modes/thing.lua"); + let surface = TypeSurface::load_near_policy(&[preset], "mode"); + assert!(surface.globals.contains_key("Mode"), "a preset must be given Mode"); + // and it must be ALPHA's Mode, not the one it happens to require. + let heads = surface.statement_heads(); + assert_eq!(heads.get("Mode").map(String::as_str), Some("AlphaChain")); + + // The trigger surface for the same module is untouched by any of this. + let trigger = TypeSurface::load_near_policy(&[alpha.join("triggers/x.lua")], "trigger"); + assert!(trigger.globals.contains_key("Spawn")); + assert!(!trigger.globals.contains_key("Mode"), "a trigger file never sees Mode"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_modules_surface_composes_through_its_manifest_requires() { + let dir = std::env::temp_dir().join(format!("bmk-requires-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for module in ["alpha", "beta"] { + std::fs::create_dir_all(dir.join("modules").join(module).join("types")).unwrap(); + } + std::fs::write( + dir.join("modules/alpha/module.lua"), + "return { name = \"alpha\", requires = { \"beta\" } }\n", + ) + .unwrap(); + std::fs::write( + dir.join("modules/alpha/types/dsl.lua"), + "---@meta policy trigger\n\n---@class AlphaChain\n---@field Do fun(e: table): AlphaChain\n\n---@return AlphaChain\nfunction When(c) end\n", + ) + .unwrap(); + std::fs::write( + dir.join("modules/beta/types/dsl.lua"), + "---@meta policy trigger\n\n---@class BetaVerbs\n---@field Zap fun(): table\n\n---@type BetaVerbs\nBeta = {}\n", + ) + .unwrap(); + + let surface = TypeSurface::load_near(&[dir.join("modules/alpha/some_mission/triggers")]); + assert!(surface.statement_heads().contains_key("When"), "own vocabulary"); + assert!(surface.globals.contains_key("Beta"), "required module's vocabulary composes in"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn alias_slugs_drop_the_mission_prefix() { + assert_eq!(alias_slug("MissionUnitName"), "unit_name"); + assert_eq!(alias_slug("UnitDefName"), "unit_def_name"); + assert_eq!(alias_slug("MissionTeamRole"), "team_role"); + } +} diff --git a/bar-mission-kit/src/view.rs b/bar-mission-kit/src/view.rs new file mode 100644 index 00000000..e4daea6c --- /dev/null +++ b/bar-mission-kit/src/view.rs @@ -0,0 +1,2249 @@ +//! Server-side view rendering: AST -> markup, Dioxus RSX components SSR'd to +//! strings. The markup stays inside the HTML/RML intersection (div, span, +//! button, input, select, class + data-* attributes) so every client is a +//! blind terminal: the game injects it via inner_rml with an RCSS theme, a +//! webview injects it via innerHTML with a CSS theme. Clients route events by +//! data-* attributes only — no mission knowledge outside this file. + +use crate::model::{FileAst, MissionAst, Span, Step, Trigger, Value}; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Domain lists only a client can know (the game owns UnitDefNames). Clients +/// publish this as domains.json in the editor dir; serve folds it into the +/// next generation. +#[derive(Deserialize, Default, Clone)] +pub struct Domains { + #[serde(default)] + pub units: Vec, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct DomainOption { + pub value: String, + pub label: String, +} + +/// Where the served mission sits in the missions tree. Serve owns this (it +/// knows --missions-root); the crumb and mission list render server-side so +/// terminals stay blind. +#[derive(Default, Clone)] +pub struct Scope { + pub mission: Option, + pub missions: Vec, +} + +/// The rendered view artifact (mission_view.json). `generation` first: the +/// widget greps it cheaply before decoding. +#[derive(Serialize)] +pub struct ViewArtifact { + pub generation: u64, + pub first_file: Option, + /// Editable card view (sentence forms with controls in the slots). + pub form: String, + /// Read-only display-notation view for the text-mode billboard. + pub billboard: String, + pub modals: Modals, + /// Live-state probes: the game samples these (state.json / GET /state) + /// and terminals patch the matching [data-live] chips in place. Telemetry + /// deliberately bypasses the generation counter — values change without + /// re-rendering the form. + pub live: Vec, + /// The DSL vocabulary for editor completion: trigger files aren't Lua + /// project members, so their completion comes from here, not a Lua LS. + pub vocabulary: Vocabulary, +} + +#[derive(Serialize)] +pub struct Vocabulary { + pub conditions: Vec, + pub effects: Vec, + pub objectives: Vec, + pub units: Vec, + /// Roster-declared unit and group names (units.lua) — the legal values + /// for Unit()/Units.* slots. + pub unit_names: Vec, + pub groups: Vec, +} + +/// One thing the game should sample. `key` matches a data-live attribute in +/// the form; content-derived so it stays stable across regenerations. +#[derive(Serialize, Clone)] +pub struct LiveProbe { + pub key: String, + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit_def: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub need: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub objective: Option, + /// Roster name for the named-unit kinds (unit_dead, unit_spotted). + #[serde(skip_serializing_if = "Option::is_none")] + pub unit_name: Option, + /// Director name for the wave kinds, lowercased from the pack reference + /// the mission wrote (`Scavengers.Skirmish` -> `scavengers.skirmish`). + /// That is the name the director publishes its counters under. + #[serde(skip_serializing_if = "Option::is_none")] + pub pack: Option, + /// `:` for the trigger kind — the same identity the runtime + /// stamps, minus the mission prefix the game adds back. + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger: Option, +} + +#[derive(Serialize)] +pub struct Modals { + pub add_step: Modal, + pub add_statement: Modal, + /// The roster's own palette: spawn chains, not trigger vocabulary. + pub add_spawn: Modal, + pub swap_conditions: Modal, + pub swap_effects: Modal, +} + +/// Modal content is structured data, not markup: `new_text` carries newlines, +/// which XML attributes cannot. The terminal lists labels and posts the +/// opaque `new_text` at a position chosen by `kind`: +/// group — section header, not clickable +/// trigger — insert at the button's data-insert +/// andwhen — insert at the button's data-insert-cond +/// effect — insert at the button's data-insert-effect +/// swap — replace the button's data-swap-start..data-swap-end +#[derive(Serialize)] +pub struct Modal { + pub title: String, + pub rows: Vec, +} + +#[derive(Serialize)] +pub struct ModalRow { + pub kind: String, + pub label: String, + #[serde(default)] + pub new_text: String, +} + +#[derive(Deserialize, Clone, Default)] +struct StatementInfo { + name: String, + #[serde(default)] + steps: Vec, +} + +#[derive(Deserialize, Clone, Default)] +struct ModuleInfo { + name: String, + #[serde(default)] + description: String, + #[serde(default)] + requires: Vec, + #[serde(default)] + statements: Vec, + #[serde(default)] + conditions: Vec, + #[serde(default)] + effects: Vec, + #[serde(default)] + nouns: Vec, + #[serde(default)] + modes: Vec, +} + +#[derive(Deserialize, Clone, Default)] +struct Surface { + #[serde(default)] + conditions: Vec, + #[serde(default)] + effects: Vec, + /// semantic -> options, derived from literal-union type aliases + /// (collect_ast merges them into the surface overlay). + #[serde(default)] + enums: std::collections::BTreeMap>, + /// Modules publishing a marked DSL surface (the explorer's rows). + #[serde(default)] + modules: Vec, + /// Roster chains. Curated, not derived: a spawn position is a map + /// fraction, and `number` cannot say that. + #[serde(default)] + spawns: Vec, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct SurfaceEntry { + label: String, + template: String, +} + +#[derive(Clone, Copy, PartialEq)] +enum Style { + Ui, + Dsl, +} + +struct Ctx<'a> { + file: &'a str, + hash: &'a str, + editable: bool, + style: Style, + surface: &'a Surface, + domains: &'a Domains, + live: &'a std::cell::RefCell>, +} + +/// Unit def names the game does not publish. The roster's own names are +/// cross-checked against units.lua; these are game content, so the only +/// authority is what the bridge published in domains.json — and when nothing +/// has been published (a headless check, a game that has not run) there is no +/// authority and no finding, rather than a wall of false positives. +pub fn unknown_unit_defs(ast: &MissionAst, domains: &Domains) -> Vec { + if domains.units.is_empty() { + return Vec::new(); + } + let known: std::collections::HashSet<&str> = + domains.units.iter().map(|u| u.value.as_str()).collect(); + fn walk<'a>(value: &'a Value, out: &mut Vec<(&'a str, crate::model::Span)>) { + match value { + Value::String { value, span, semantic: Some(s) } if s == "unit_def_name" => { + out.push((value, *span)) + } + Value::Verb { calls, .. } => { + for c in calls { + for a in &c.args { + walk(a, out); + } + } + } + Value::Table { fields, .. } => { + for f in fields { + walk(&f.value, out); + } + } + _ => {} + } + } + let mut findings = Vec::new(); + for file in &ast.files { + for group in &file.groups { + for trigger in &group.triggers { + for step in &trigger.steps { + let mut names = Vec::new(); + for arg in &step.args { + walk(arg, &mut names); + } + for (name, span) in names { + if !known.contains(name) { + findings.push(crate::model::Finding { + path: file.path.clone(), + line: step.line, + message: format!( + "UnitDef(\"{name}\"): the game publishes no such unit def" + ), + span: Some(span), + }); + } + } + } + } + } + } + findings +} + +pub fn render(ast: &MissionAst, domains: &Domains, scope: &Scope) -> ViewArtifact { + let surface: Surface = serde_json::from_value(ast.surface.clone()).unwrap_or_default(); + let live = std::cell::RefCell::new(Vec::new()); + let triggers = xmlize(&dioxus_ssr::render_element(body(ast, &surface, domains, true, &live, Some(false)))); + let units = xmlize(&dioxus_ssr::render_element(body(ast, &surface, domains, true, &live, Some(true)))); + let billboard = xmlize(&dioxus_ssr::render_element(body(ast, &surface, domains, false, &live, None))); + let nouns = xmlize(&dioxus_ssr::render_element(nouns_body(ast, domains, &live))); + + let sorted_set = |iter: &mut dyn Iterator| -> Vec { + iter.collect::>().into_iter().collect() + }; + let objectives = sorted_set(&mut ast.files.iter().flat_map(|f| f.objectives.iter().cloned())); + let unit_names = sorted_set(&mut ast.files.iter().flat_map(|f| f.unit_defs.iter().cloned())); + let groups = sorted_set(&mut ast.files.iter().flat_map(|f| f.group_defs.iter().cloned())); + let objective_count = objectives.len(); + let objectives_len = objectives.len(); + let unit_names_len = unit_names.len(); + let vocabulary = Vocabulary { + conditions: surface.conditions.clone(), + effects: surface.effects.clone(), + objectives, + units: domains.units.clone(), + unit_names, + groups, + }; + let trigger_files = ast.files.iter().filter(|f| !is_roster(&f.path)).count(); + let spawn_count: usize = ast + .files + .iter() + .filter(|f| is_roster(&f.path)) + .flat_map(|f| f.groups.iter()) + .map(|g| g.triggers.len()) + .sum(); + let trigger_count: usize = ast + .files + .iter() + .filter(|f| !is_roster(&f.path)) + .flat_map(|f| f.groups.iter()) + .map(|g| g.triggers.len()) + .sum(); + let form = [ + format!("
{}{}
", + crumb(scope), + summary(trigger_count, spawn_count, objectives_len, unit_names_len, surface.modules.len())), + section( + "mission", + "Triggers", + &format!("{trigger_count} in {trigger_files} file{}", plural(trigger_files)), + true, + &triggers, + ), + section( + "units", + "Units", + &format!("{spawn_count} spawn{}", plural(spawn_count)), + true, + &units, + ), + section( + "nouns", + "Nouns", + &format!( + "{objective_count} objective{} · {unit_names_len} named unit{}", + plural(objective_count), + plural(unit_names_len) + ), + true, + &nouns, + ), + section( + "graph", + "Graph", + &format!("{} module{}", surface.modules.len(), plural(surface.modules.len())), + true, + &modules_graph(&surface.modules), + ), + section( + "modules", + "Modules", + &format!("{} publishing a DSL surface", surface.modules.len()), + true, + &modules_body(&surface.modules), + ), + ] + .concat(); + + ViewArtifact { + generation: ast.generation, + first_file: ast.files.first().map(|f| f.path.clone()), + form, + billboard, + modals: modals(&surface), + live: live.into_inner(), + vocabulary, + } +} + +/// The module explorer: what the mission's vocabulary is made of. Each row is +/// a module that publishes a marked surface — its verbs, its mode presets, +/// and what it requires. Rows jump to the module's own types file. +/// The dependency graph as the Reference's overview: modules laid out by +/// depth (what nothing depends on sits at the left), each showing what it +/// requires. No SVG in the RML intersection — the layering IS the drawing. +/// Which module published a verb's root namespace. Identity is the module +/// NAME, not a colour: eight modules cannot be told apart by hue (the +/// categorical palette fails all-pairs CVD past three), so selection +/// highlights and labels carry it instead. +fn module_owner<'a>(root: &str, surface: &'a Surface) -> Option<&'a str> { + // A call arrives either bare ("Objective") or dotted + // ("MatchFlow.Started"), and a module publishes the dotted form. Compare + // namespaces, which is the part that identifies the owner either way. + let root = root.split('.').next().unwrap_or(root); + surface + .modules + .iter() + .find(|m| { + m.conditions + .iter() + .chain(m.effects.iter()) + .chain(m.nouns.iter()) + .any(|v| v.split('.').next() == Some(root)) + || m.statements.iter().any(|st| st.name == root) + }) + .map(|m| m.name.as_str()) +} + +/// The module a step belongs to, from the verb it invokes. The card view +/// renders steps as phrases rather than calls, so the row carries the +/// attribution: selecting a module has to light up "the mission has started" +/// as readily as `MatchFlow.Started()`. +fn step_owner<'a>(step: &Step, surface: &'a Surface) -> Option<&'a str> { + step.args + .iter() + .find_map(|arg| match arg { + Value::Verb { path, .. } => module_owner(path, surface), + _ => None, + }) + // Steps whose args are literals (.At(0.5, 0.5)) are identified by the + // statement they belong to instead. + .or_else(|| { + surface + .modules + .iter() + .find(|m| { + m.statements + .iter() + .any(|st| { + // Statement steps are published dotted (".At"). + st.name == step.verb + || st.steps.iter().any(|s| s.trim_start_matches('.') == step.verb) + }) + }) + .map(|m| m.name.as_str()) + }) +} + +fn modules_graph(modules: &[ModuleInfo]) -> String { + use std::collections::HashMap; + let known: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect(); + let requires: HashMap<&str, Vec<&str>> = modules + .iter() + .map(|m| { + let deps = m + .requires + .iter() + .map(String::as_str) + .filter(|d| known.contains(d)) + .collect(); + (m.name.as_str(), deps) + }) + .collect(); + // Depth = longest path to a module that requires nothing (cycle-safe). + fn depth<'a>( + name: &'a str, + requires: &HashMap<&'a str, Vec<&'a str>>, + seen: &mut Vec<&'a str>, + ) -> usize { + if seen.contains(&name) { + return 0; + } + seen.push(name); + let d = requires + .get(name) + .map(|deps| deps.iter().map(|d| depth(d, requires, seen) + 1).max().unwrap_or(0)) + .unwrap_or(0); + seen.pop(); + d + } + let mut rows: Vec<(usize, &ModuleInfo)> = modules + .iter() + .map(|m| (depth(&m.name, &requires, &mut Vec::new()), m)) + .collect(); + rows.sort_by(|a, b| (a.0, &a.1.name).cmp(&(b.0, &b.1.name))); + + // Three readings of the same set. Flat is the default because most of the + // time the question is "what is here"; the diagram answers "what rests on + // what"; the mermaid source is for pasting where mermaid renders (a PR + // body, a design doc). The diagram is drawn here rather than by a client + // library so it needs no network and works in a sandboxed webview. + let mut out = String::from( + "
\ + \ +
", + ); + + // --- flat: the roll-call ------------------------------------------------ + out.push_str("
"); + for &(_, m) in rows.iter() { + let deps = requires.get(m.name.as_str()).cloned().unwrap_or_default(); + let arrows = if deps.is_empty() { + String::new() + } else { + let chips: String = deps + .iter() + .map(|d| format!("{d}")) + .collect(); + format!("needs{chips}") + }; + out.push_str(&format!( + "
\ + \ + {arrows}
", + m.name, m.name + )); + } + out.push_str("
"); + + // --- graph: nodes and edges --------------------------------------------- + // Layered by dependency depth, then ordered within each layer by the mean + // position of what it connects to (a barycentre sweep). Without that the + // rows keep their alphabetical order and the edges cross for no reason. + const COL: usize = 176; + const ROW: usize = 52; + const W: usize = 132; + const H: usize = 30; + + let mut layers: Vec> = Vec::new(); + for &(d, m) in rows.iter() { + while layers.len() <= d { + layers.push(Vec::new()); + } + layers[d].push(m.name.as_str()); + } + // Order within each layer to reduce crossings. A barycentre sweep can make + // a layout worse as easily as better, so sweep both directions and keep the + // best ordering seen — never worse than the alphabetical start. + let edges: Vec<(&str, &str)> = rows + .iter() + .flat_map(|(_, m)| { + requires + .get(m.name.as_str()) + .cloned() + .unwrap_or_default() + .into_iter() + .map(move |d| (m.name.as_str(), d)) + }) + .collect(); + let crossings = |layers: &Vec>| -> usize { + let at: HashMap<&str, (usize, usize)> = layers + .iter() + .enumerate() + .flat_map(|(d, l)| l.iter().enumerate().map(move |(i, n)| (*n, (d, i)))) + .collect(); + let mut count = 0; + for (i, (a, b)) in edges.iter().enumerate() { + for (c, d) in edges.iter().skip(i + 1) { + let (Some(&pa), Some(&pb), Some(&pc), Some(&pd)) = + (at.get(a), at.get(b), at.get(c), at.get(d)) + else { + continue; + }; + // Only edges spanning the same pair of layers can cross. + if pa.0 == pc.0 && pb.0 == pd.0 && a != c && b != d { + let above = (pa.1 as isize - pc.1 as isize) * (pb.1 as isize - pd.1 as isize); + if above < 0 { + count += 1; + } + } + } + } + count + }; + + let mut best = layers.clone(); + let mut best_count = crossings(&layers); + for pass in 0..8 { + let forward = pass % 2 == 0; + let at: HashMap<&str, usize> = layers + .iter() + .flat_map(|l| l.iter().enumerate().map(|(i, n)| (*n, i))) + .collect(); + let order: Vec = if forward { + (1..layers.len()).collect() + } else { + (0..layers.len().saturating_sub(1)).rev().collect() + }; + for d in order { + // Barycentre against the neighbouring layer only, which is what + // makes the sweep converge instead of chasing itself. + let mut keyed: Vec<(f64, &str)> = layers[d] + .iter() + .map(|n| { + let mut ns: Vec = Vec::new(); + for (a, b) in edges.iter() { + if a == n { + ns.extend(at.get(b)); + } else if b == n { + ns.extend(at.get(a)); + } + } + let k = if ns.is_empty() { + at.get(n).copied().unwrap_or(0) as f64 + } else { + ns.iter().map(|i| *i as f64).sum::() / ns.len() as f64 + }; + (k, *n) + }) + .collect(); + keyed.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal).then(a.1.cmp(b.1))); + layers[d] = keyed.into_iter().map(|(_, n)| n).collect(); + } + let c = crossings(&layers); + if c < best_count { + best_count = c; + best = layers.clone(); + } + } + let layers = best; + + let mut slot: HashMap<&str, (usize, usize)> = HashMap::new(); + for (d, layer) in layers.iter().enumerate() { + for (i, name) in layer.iter().enumerate() { + slot.insert(name, (d, i)); + } + } + let width = layers.len() * COL; + let height = layers.iter().map(Vec::len).max().unwrap_or(1) * ROW + 16; + let xy = |d: usize, i: usize| (d * COL + 10, i * ROW + 10); + + out.push_str(&format!( + "
\ + \ + \ + " + )); + // Curved so parallel runs separate instead of overlapping into one line. + for &(_, m) in rows.iter() { + let (d, i) = slot[m.name.as_str()]; + let (x, y) = xy(d, i); + for dep in requires.get(m.name.as_str()).cloned().unwrap_or_default() { + if let Some(&(dd, di)) = slot.get(dep) { + let (dx, dy) = xy(dd, di); + let (x1, y1) = (x as f64, (y + H / 2) as f64); + let (x2, y2) = ((dx + W) as f64, (dy + H / 2) as f64); + let bend = ((x1 - x2).abs() * 0.45).max(26.0); + out.push_str(&format!( + "", + x1 - bend, + x2 + bend + )); + } + } + } + for &(_, m) in rows.iter() { + let (d, i) = slot[m.name.as_str()]; + let (x, y) = xy(d, i); + out.push_str(&format!( + "\ + \ + {name}", + name = m.name, + tx = x + W / 2, + ty = y + H / 2 + 4 + )); + } + // The mermaid source rides along for the copy button rather than taking a + // tab of its own: it is something you paste elsewhere, not something to read here. + let mut mermaid = String::from("graph LR\n"); + for &(_, m) in rows.iter() { + let deps = requires.get(m.name.as_str()).cloned().unwrap_or_default(); + if deps.is_empty() { + mermaid.push_str(&format!(" {}\n", m.name)); + } + for dep in deps { + mermaid.push_str(&format!(" {} --> {}\n", m.name, dep)); + } + } + out.push_str(&format!( + "
", + mermaid.replace('"', """).replace('\n', " ") + )); + out +} + +fn modules_body(modules: &[ModuleInfo]) -> String { + // The module editor's own path: `modules > N publishing a surface`, the + // root toggling a picker that jumps to a module — the same shape the + // mission crumb has, so both editors navigate the same way. + let mut out = format!( + "
\ + \ + \u{25b8}\ + {} publishing a surface
", + modules.len() + ); + if !modules.is_empty() { + out.push_str("
"); + for m in modules { + out.push_str(&format!( + "", + m.name, m.name + )); + } + out.push_str("
"); + } + for m in modules { + out.push_str(&format!( + "
\ + {}\ + {}
", + m.name, m.name, m.description + )); + // Statements first, each with the steps that chain onto it. + for statement in &m.statements { + out.push_str(&format!( + "
statement\ + {}", + statement.name + )); + for step in &statement.steps { + out.push_str(&format!("{step}")); + } + out.push_str("
"); + } + for (key, class, items) in [ + ("conditions", "me-chip", &m.conditions), + ("effects", "me-chip me-chip-effect", &m.effects), + ("nouns", "me-chip me-chip-noun", &m.nouns), + ("modes", "me-chip me-chip-mode", &m.modes), + ("requires", "me-chip me-chip-req", &m.requires), + ] { + if items.is_empty() { + continue; + } + out.push_str(&format!( + "
{key}" + )); + for item in items.iter() { + out.push_str(&format!("{item}")); + } + out.push_str("
"); + } + out.push_str("
"); + } + out +} + +/// The landing dashboard: a KPI row that is also the navigation, plus one +/// part-to-whole bar of the mission's statements. Every tile opens its +/// section — including Modules, the way into the module editor — so the panel +/// can open fully collapsed and still show the shape of the mission. +/// +/// Marks follow the viz rules: two categorical slots (blue/orange, validated +/// against both terminals' dark surfaces), a 2px surface gap between +/// segments, rounded ends, and identity carried by the dots — labels and +/// values stay in text ink, never in a series color. +fn summary(triggers: usize, spawns: usize, objectives: usize, named: usize, modules: usize) -> String { + let total = triggers + spawns; + let pct = |n: usize| if total == 0 { 0.0 } else { (n as f64) * 100.0 / (total as f64) }; + let tile = |slot: &str, label: &str, value: usize, section: &str| { + let dot = if slot.is_empty() { + String::new() + } else { + format!("") + }; + format!( + "" + ) + }; + let bar = if total == 0 { + String::new() + } else { + format!( + "
\ +
\ +
", + pct(triggers), + pct(spawns) + ) + }; + format!( + "
{}{}{}{}{}
{bar}
", + tile("me-series-1", "Triggers", triggers, "mission"), + tile("me-series-2", "Spawns", spawns, "units"), + tile("", "Objectives", objectives, "nouns"), + tile("", "Named units", named, "nouns"), + tile("", "Modules", modules, "modules"), + ) +} + +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +/// The navigable path: `missions ▸ `. The root is a button when +/// there are siblings to navigate to; terminals toggle the list and post the +/// pick as a select_mission intent. Names are dir names already restricted to +/// [A-Za-z0-9_-] by serve, so they embed in markup verbatim. +fn crumb(scope: &Scope) -> String { + let Some(name) = &scope.mission else { + return String::new(); + }; + let navigable = !scope.missions.is_empty(); + let root = if navigable { + "".to_string() + } else { + "missions".to_string() + }; + let mut out = format!( + "
{root}\ + \u{25b8}\ + {name}
" + ); + if navigable { + out.push_str("
"); + for mission in &scope.missions { + let current = if Some(mission) == scope.mission.as_ref() { " me-mission-current" } else { "" }; + out.push_str(&format!( + "" + )); + } + out.push_str("
"); + } + out +} + +/// Collapsible section shell. Static titles/keys only — bodies are already +/// rendered+escaped. Terminals toggle the `collapsed` class client-side and +/// remember the choice across re-renders. +fn section(key: &str, title: &str, hint: &str, collapsed: bool, body: &str) -> String { + // The caret span is empty: glyphs are a stylesheet concern (the game + // font lacks ▾; web draws it via ::before). + format!( + "
\ + \ +
{body}
", + if collapsed { " collapsed" } else { "" } + ) +} + +/// The noun explorer: every objective and unit the mission mentions, wired +/// to the same live keys the form chips use. +fn nouns_body( + ast: &MissionAst, + domains: &Domains, + live: &std::cell::RefCell>, +) -> Element { + let mut objectives: Vec = Vec::new(); + for file in &ast.files { + for objective in &file.objectives { + if !objectives.contains(objective) { + objectives.push(objective.clone()); + } + } + } + objectives.sort(); + { + // Nouns can name objectives the phrases didn't probe; sample them too. + let mut live = live.borrow_mut(); + for objective in &objectives { + let key = format!("obj:{objective}"); + if !live.iter().any(|p| p.key == key) { + live.push(LiveProbe { + key, + kind: "objective".into(), + unit_def: None, + need: None, + objective: Some(objective.clone()), + unit_name: None, + pack: None, + trigger: None, + }); + } + } + } + let units: Vec = live + .borrow() + .iter() + .filter(|p| p.kind == "unit_count") + .cloned() + .collect(); + // Roster-named units, one row each; prefer the dead-latch chip when a + // unit has both destroyed and spotted probes. + let named: Vec = { + let live = live.borrow(); + let mut seen: Vec = Vec::new(); + let mut out: Vec = Vec::new(); + for probe in live.iter().filter(|p| p.kind == "unit_dead").chain(live.iter().filter(|p| p.kind == "unit_spotted")) { + if let Some(name) = &probe.unit_name { + if !seen.contains(name) { + seen.push(name.clone()); + out.push(probe.clone()); + } + } + } + out + }; + let unit_label = |name: &str| { + domains + .units + .iter() + .find(|u| u.value == name) + .map(|u| u.label.clone()) + .unwrap_or_else(|| name.to_string()) + }; + rsx! { + div { class: "me-noun-group", "OBJECTIVES" } + for objective in objectives.iter() { + div { class: "me-noun", + span { class: "me-noun-name", "{objective}" } + span { class: "me-live", "data-live": "obj:{objective}", "–" } + } + } + div { class: "me-noun-group", "UNITS" } + for probe in units.iter() { + div { class: "me-noun", + span { class: "me-noun-name", {unit_label(probe.unit_def.as_deref().unwrap_or_default())} } + span { class: "me-live", "data-live": "{probe.key}", "–" } + } + } + if !named.is_empty() { + div { class: "me-noun-group", "NAMED UNITS" } + for probe in named.iter() { + div { class: "me-noun", + span { class: "me-noun-name", {probe.unit_name.clone().unwrap_or_default()} } + span { class: "me-live", "data-live": "{probe.key}", "–" } + } + } + } + } +} + +/// A mission's files split by what they author: roster (units.lua) or +/// triggers. They get their own sections — a spawn list and a rule list are +/// different work, and mixing them buries both. +fn is_roster(path: &str) -> bool { + path.ends_with("units.lua") +} + +fn body<'a>( + ast: &'a MissionAst, + surface: &'a Surface, + domains: &'a Domains, + editable: bool, + live: &'a std::cell::RefCell>, + roster: Option, +) -> Element { + let style = if editable { Style::Ui } else { Style::Dsl }; + rsx! { + for file in ast.files.iter().filter(|f| roster.is_none_or(|r| is_roster(&f.path) == r)) { + {file_view(file, &Ctx { + file: &file.path, + hash: &file.hash, + editable, + style, + surface, + domains, + live, + })} + } + } +} + +fn file_view(file: &FileAst, ctx: &Ctx) -> Element { + rsx! { + // The file header IS the way into the text: clicking it opens the + // .lua in the editor (the same open-in-editor jump the cards use). + div { + class: "me-file me-jump", + "data-open-file": "{file.path}", + "data-open-line": "1", + span { class: "me-file-name", "{file.path}" } + span { class: "me-file-open", "open" } + } + for group in file.groups.iter() { + if group.label.is_some() { + div { class: "me-group", {group.label.clone().unwrap_or_default()} } + } + for trigger in group.triggers.iter() { + {trigger_card(trigger, ctx)} + } + } + // Two palettes, one per file kind: trigger vocabulary for trigger + // files, spawn chains for the roster. + if ctx.editable && file.path.ends_with("units.lua") && !ctx.surface.spawns.is_empty() { + div { class: "me-add-row me-add-statement-row", + button { + class: "me-button me-add-btn", + "data-add": "spawn", + "data-insert": "{file.insert_trigger_at}", + "data-file": "{ctx.file}", + "data-hash": "{ctx.hash}", + "+ add spawn" + } + } + } + if ctx.editable && !ctx.surface.conditions.is_empty() && !file.path.ends_with("units.lua") { + div { class: "me-add-row me-add-statement-row", + button { + class: "me-button me-add-btn", + "data-add": "statement", + "data-insert": "{file.insert_trigger_at}", + "data-file": "{ctx.file}", + "data-hash": "{ctx.hash}", + "+ add statement" + } + } + } + if !file.opaque.is_empty() { + div { class: "me-opaque", + "{file.opaque.len()} unrecognized span(s) — see bar-mission-kit check" + } + } + } +} + +fn trigger_card(trigger: &Trigger, ctx: &Ctx) -> Element { + // Ghost ordinal is the open-in-editor handle; the file header already + // names the file. + let ordinal = trigger.id.rsplit(':').next().unwrap_or_default(); + let title = trigger.label.clone().unwrap_or_else(|| format!("#{ordinal}")); + let addable = ctx.editable + && !(ctx.surface.conditions.is_empty() && ctx.surface.effects.is_empty()) + && trigger.steps.first().map(|s| s.verb == "When").unwrap_or(false); + // Whole-card progress. The engine's own "has this fired" is the honest + // signal — a once-trigger stays fired after its condition goes false, so + // shading off a live condition would flicker back to unfired. This rides + // its own attribute rather than data-live because the front-ends write + // TEXT into a data-live element, which would erase the card. + // Only the interactive form gets live wiring: the billboard is read-only + // display notation, and None omits the attribute entirely. + let fired_key = (ctx.style == Style::Ui).then(|| format!("trigger:{}", trigger.id)); + if let Some(key) = fired_key.clone() { + let mut live = ctx.live.borrow_mut(); + if !live.iter().any(|p| p.key == key) { + live.push(LiveProbe { + key, + kind: "trigger_fired".into(), + unit_def: None, + need: None, + objective: None, + unit_name: None, + pack: None, + trigger: Some(trigger.id.clone()), + }); + } + } + rsx! { + div { class: "me-card", "data-fired": fired_key, + div { class: "me-card-head", + span { + class: "me-card-title me-jump", + "data-open-file": "{ctx.file}", + "data-open-line": "{trigger.line}", + "{title}" + } + if addable { + button { + class: "me-button me-add-btn me-card-add", + "data-add": "step", + "data-insert-cond": "{trigger.insert_condition_at}", + "data-insert-effect": "{trigger.insert_effect_at}", + "data-file": "{ctx.file}", + "data-hash": "{ctx.hash}", + "+" + } + } + if ctx.editable { + button { + class: "me-button me-x me-card-x", + "data-op": "remove", + "data-remove-start": "{trigger.remove_span.0}", + "data-remove-end": "{trigger.remove_span.1}", + "data-file": "{ctx.file}", + "data-hash": "{ctx.hash}", + "×" + } + } + } + for step in trigger.steps.iter().filter(|s| s.verb != "Register") { + {step_row(step, ctx)} + } + } + } +} + +fn step_row(step: &Step, ctx: &Ctx) -> Element { + let badge = match step.verb.as_str() { + "When" | "AndWhen" => "cond", + "Do" => "effect", + _ => "mod", + }; + let verb = step.verb.to_uppercase(); + let pool = match badge { + "cond" if !ctx.surface.conditions.is_empty() => Some("conditions"), + "effect" if !ctx.surface.effects.is_empty() => Some("effects"), + _ => None, + }; + rsx! { + // The row is the jump: clicking anywhere outside a control opens the + // file at this line. No mode, no button — the text is one click away. + div { + class: "me-step me-jump", + "data-open-file": "{ctx.file}", + "data-open-line": "{step.line}", + "data-owner": "{step_owner(step, ctx.surface).unwrap_or(\"\")}", + span { class: "me-step-verb me-verb-{badge}", "{verb}" } + span { class: "me-step-body", + if let Some(phrase) = step_phrase_for(&step.verb).filter(|_| ctx.style == Style::Ui) { + {step_phrase_view(phrase, &step.args, ctx)} + } else { + {comma_list(step.args.iter().map(|a| arg_view(a, ctx)).collect())} + } + } + {step_live(step, ctx)} + span { class: "me-step-tools", + if ctx.editable && pool.is_some() { + button { + class: "me-button me-x-btn me-swap-btn", + "data-pool": pool.unwrap_or_default(), + "data-swap-start": "{step.span.0}", + "data-swap-end": "{step.span.1}", + "data-file": "{ctx.file}", + "data-hash": "{ctx.hash}", + img { src: "/luaui/images/repeat.png", width: "11", height: "11" } + } + } + if ctx.editable && step.verb == "Do" { + button { + class: "me-button me-x", + "data-op": "remove", + "data-remove-start": "{step.remove_span.0}", + "data-remove-end": "{step.remove_span.1}", + "data-file": "{ctx.file}", + "data-hash": "{ctx.hash}", + "×" + } + } + } + } + } +} + +/// Sentence templates: schema'd verb shapes read as English; {semantic} slots +/// bind to the annotated leaves underneath. +/// Sentence templates for a whole chain STEP, when the step's arguments only +/// make sense together (`Spawn(UnitDef("corlab"), "gaia")` reads as one +/// clause, not as two comma-separated values). Slots resolve across every +/// argument of the step. +fn step_phrase_for(verb: &str) -> Option<&'static str> { + // The verb pill already says SPAWN/AT/NAMED/GROUPED — the phrase carries + // only what the pill cannot. + match verb { + "Spawn" => Some("{unit_def_name} for {team_role}"), + "At" => Some("{fx}, {fz}"), + "Named" => Some("{unit_name}"), + "Grouped" => Some("{unit_group}"), + // AFTER 30 reads as a number with no unit, and the one question an + // author has at that number is which unit it is in. The pill says + // AFTER, so the phrase says the rest. "seconds" is not spelled here + // twice by accident — the slot name comes from MissionSeconds in the + // game's types, and the trailing word is what makes it a sentence. + "After" => Some("{seconds} seconds"), + _ => None, + } +} + +fn phrase_for(key: &str) -> Option<&'static str> { + match key { + "Team.Player.Has" => Some("Player has {count} {unit_def_name}"), + "Objective.IsComplete" => Some("objective {objective_name} is complete"), + "Objective.Complete" => Some("complete objective {objective_name}"), + "MatchFlow.Started" => Some("the mission has started"), + "MatchFlow.Victory" => Some("victory for the player team"), + "MatchFlow.Defeat" => Some("defeat for the player team"), + "Unit.IsDestroyed" => Some("{unit_name} is destroyed"), + "Unit.IsSpotted" => Some("the player has spotted {unit_name}"), + // The receiving team is not a slot: slots fill from string and number + // leaves, and a team arrives as a noun path (Team.Player). Every + // mission hands to the player today, so the sentence says so — a + // handover to anyone else would read wrong until nouns can fill slots. + // "share" vs "give ... outright" is the whole distinction between + // these two, said in the author's terms. It used to read "mode or no + // mode", which is the implementation talking: sharing modes are a + // lobby concept a mission author never sees, so the phrase named the + // one thing that could not help them choose. + "Transfer.Units" => Some("share group {unit_group} with the player"), + "Transfer.Give" => Some("give group {unit_group} to the player outright"), + "Combat.Protect" => Some("protect {unit_name}"), + "Combat.Unprotect" => Some("stop protecting {unit_name}"), + // {until} is not a literal slot: it renders the Until argument's own + // sentence (see slot_view). + "Combat.Protect.Until" => Some("protect {unit_name} until {until}"), + // Waves. The pack is a noun path (Scavengers.Skirmish), and a noun + // cannot fill a slot — same limitation Transfer.Units documents above + // — so these sentences say "the waves" and let the DO row's own + // module attribution carry which flavor it is. Begin's key depends on + // which dial came last, because the chain is order-free, so every + // ending gets the same sentence. + "Waves.Begin" | "Waves.Begin.Against" | "Waves.Begin.From" | "Waves.Begin.Intensity" => { + Some("send waves at the player") + } + "Waves.Intensify" => Some("set the wave intensity to {wave_intensity}"), + "Waves.Surge" => Some("send one wave now"), + "Waves.End" => Some("stop sending waves"), + "Waves.Spawned" => Some("a wave has spawned"), + "Waves.Cleared" => Some("a wave has been cleared"), + "Waves.BossDefeated" => Some("the boss has been defeated"), + _ => None, + } +} + +/// The condition expression inside a `.Until(...)` invocation, if any. +fn until_arg(value: &Value) -> Option<&Value> { + match value { + Value::Verb { calls, .. } => calls + .iter() + .find(|c| c.name.as_deref() == Some("Until")) + .and_then(|c| c.args.first()), + _ => None, + } +} + +fn phrase_key(path: &str, calls: &[crate::model::Invocation]) -> String { + let chained = calls.iter().filter_map(|c| c.name.as_deref()).last(); + match chained { + Some(name) => format!("{path}.{name}"), + None => path.to_string(), + } +} + +/// Full-UI rendering of one step argument: the sentence with controls in the +/// slots, falling back to display notation for shapes no phrase covers. +fn arg_view(value: &Value, ctx: &Ctx) -> Element { + if ctx.style == Style::Ui { + if let Value::Verb { path, calls, .. } = value { + if let Some(phrase) = phrase_for(&phrase_key(path, calls)) { + return phrase_view(phrase, value, ctx); + } + } + } + value_view(value, ctx) +} + +/// The step's live-status chip, pulled into the row's status column: the +/// first probe-able phrase among the args wins. The game fills it via +/// state.json; "–" is the game-not-running placeholder. +fn step_live(step: &Step, ctx: &Ctx) -> Element { + if ctx.style != Style::Ui { + return rsx! {}; + } + for arg in &step.args { + if let Value::Verb { path, calls, .. } = arg { + if let Some(probe) = probe_for(&phrase_key(path, calls), arg) { + { + let mut live = ctx.live.borrow_mut(); + if !live.iter().any(|p| p.key == probe.key) { + live.push(probe.clone()); + } + } + return rsx! { + span { class: "me-live me-step-live", "data-live": "{probe.key}", "–" } + }; + } + } + } + rsx! {} +} + +fn probe_for(phrase_key: &str, value: &Value) -> Option { + match phrase_key { + "Team.Player.Has" => { + let unit = find_semantic_leaf(value, "unit_def_name"); + let count = find_semantic_leaf(value, "count"); + match (unit, count) { + (Some(Value::String { value: unit, .. }), Some(Value::Number { value: need, .. })) => { + Some(LiveProbe { + key: format!("unit:{unit}:{}", fmt_num(*need)), + kind: "unit_count".into(), + unit_def: Some(unit.clone()), + need: Some(*need), + objective: None, + unit_name: None, + pack: None, + trigger: None, + }) + } + _ => None, + } + } + "Objective.IsComplete" | "Objective.Complete" => { + match find_semantic_leaf(value, "objective_name") { + Some(Value::String { value: objective, .. }) => Some(LiveProbe { + key: format!("obj:{objective}"), + kind: "objective".into(), + unit_def: None, + need: None, + objective: Some(objective.clone()), + unit_name: None, + pack: None, + trigger: None, + }), + _ => None, + } + } + "Unit.IsDestroyed" | "Unit.IsSpotted" => { + let (prefix, kind) = if phrase_key == "Unit.IsDestroyed" { + ("unitdead", "unit_dead") + } else { + ("unitspotted", "unit_spotted") + }; + match find_semantic_leaf(value, "unit_name") { + Some(Value::String { value: name, .. }) => Some(LiveProbe { + key: format!("{prefix}:{name}"), + kind: kind.into(), + unit_def: None, + need: None, + objective: None, + unit_name: Some(name.clone()), + pack: None, + trigger: None, + }), + _ => None, + } + } + // Wave conditions are all counters against one director. The pack is + // a bare reference (`Scavengers.Skirmish`), and lowercasing it gives + // the name the director publishes its counters under — a mission + // names a pack and never learns a flavor's rulesparam prefix. + "Waves.Spawned" | "Waves.Cleared" | "Waves.BossDefeated" => { + let pack = find_name_ref(value)?.to_ascii_lowercase(); + let kind = match phrase_key { + "Waves.Spawned" => "waves_spawned", + "Waves.Cleared" => "waves_cleared", + _ => "waves_boss_defeated", + }; + // The count is optional in the DSL and defaults to one. + let need = match find_semantic_leaf(value, "count") { + Some(Value::Number { value: need, .. }) => *need, + _ => 1.0, + }; + Some(LiveProbe { + key: format!("{kind}:{pack}:{}", fmt_num(need)), + kind: kind.into(), + unit_def: None, + need: Some(need), + objective: None, + unit_name: None, + pack: Some(pack), + trigger: None, + }) + } + // The Protect row's chip tracks its lifetime bound: delegate to the + // Until condition's own probe. + "Combat.Protect.Until" => { + let arg = until_arg(value)?; + if let Value::Verb { path, calls, .. } = arg { + probe_for(&self::phrase_key(path, calls), arg) + } else { + None + } + } + _ => None, + } +} + +enum Seg<'p> { + Text(&'p str), + Slot(&'p str), +} + +/// Split a phrase into literal text and {semantic} slots. +fn phrase_segments(phrase: &str) -> Vec> { + let mut segs = Vec::new(); + let mut rest = phrase; + while let Some(open) = rest.find('{') { + if open > 0 { + segs.push(Seg::Text(&rest[..open])); + } + match rest[open..].find('}') { + Some(close) => { + segs.push(Seg::Slot(&rest[open + 1..open + close])); + rest = &rest[open + close + 1..]; + } + None => { + rest = &rest[open..]; + break; + } + } + } + if !rest.is_empty() { + segs.push(Seg::Text(rest)); + } + segs +} + +fn phrase_view(phrase: &'static str, value: &Value, ctx: &Ctx) -> Element { + let segs = phrase_segments(phrase); + rsx! { + for seg in segs.into_iter() { + match seg { + Seg::Text(text) => rsx! { "{text}" }, + Seg::Slot(semantic) => slot_view(value, semantic, ctx), + } + } + } +} + +/// Render a step-level phrase: slots resolve against the step's whole +/// argument list, so a slot may come from any argument. +fn step_phrase_view(phrase: &'static str, args: &[Value], ctx: &Ctx) -> Element { + let segs = phrase_segments(phrase); + rsx! { + for seg in segs.into_iter() { + match seg { + Seg::Text(text) => rsx! { "{text}" }, + Seg::Slot(semantic) => { + match args.iter().find(|a| find_semantic_leaf(a, semantic).is_some()) { + Some(arg) => slot_view(arg, semantic, ctx), + None => rsx! { span { class: "me-lit", "?" } }, + } + } + } + } + } +} + +fn slot_view(value: &Value, semantic: &str, ctx: &Ctx) -> Element { + // The nested-condition slot: render the Until argument as its own + // sentence (recursively phrased), not as a literal control. + if semantic == "until" { + if let Some(arg) = until_arg(value) { + return arg_view(arg, ctx); + } + } + let leaf = find_semantic_leaf(value, semantic); + if let Some(leaf) = leaf { + if let Some(control) = control_for(leaf, ctx) { + return control; + } + let text = match leaf { + Value::Number { value, .. } => fmt_num(*value), + Value::String { value, .. } => value.clone(), + _ => String::new(), + }; + return rsx! { span { class: "me-lit", "{text}" } }; + } + let missing = format!("{{{semantic}}}"); + rsx! { "{missing}" } +} + +/// The first bare dotted reference among a verb's arguments — the shape a +/// noun contributed by another module takes (`Scavengers.Skirmish`). +fn find_name_ref(value: &Value) -> Option<&str> { + match value { + Value::Name { path, .. } => Some(path.as_str()), + Value::Verb { calls, .. } => calls + .iter() + .flat_map(|c| c.args.iter()) + .find_map(find_name_ref), + Value::Table { fields, .. } => fields.iter().find_map(|f| find_name_ref(&f.value)), + _ => None, + } +} + +fn find_semantic_leaf<'a>(value: &'a Value, semantic: &str) -> Option<&'a Value> { + match value { + Value::Number { semantic: Some(s), .. } if s == semantic => Some(value), + Value::String { semantic: Some(s), .. } if s == semantic => Some(value), + Value::Verb { calls, .. } => calls + .iter() + .flat_map(|c| c.args.iter()) + .find_map(|a| find_semantic_leaf(a, semantic)), + Value::Table { fields, .. } => fields + .iter() + .find_map(|f| find_semantic_leaf(&f.value, semantic)), + _ => None, + } +} + +/// Display notation: a Value node back as DSL text; editable literals become +/// controls in place. +fn value_view(value: &Value, ctx: &Ctx) -> Element { + if let Some(control) = control_for(value, ctx) { + return control; + } + match value { + Value::Number { value, .. } => { + let text = fmt_num(*value); + rsx! { span { class: "me-lit", "{text}" } } + } + Value::String { value, .. } => rsx! { span { class: "me-lit", "\"{value}\"" } }, + Value::Boolean { value, .. } => rsx! { span { class: "me-lit", "{value}" } }, + Value::Name { path, .. } => rsx! { span { class: "me-ref", "{path}" } }, + Value::Verb { path, calls, .. } => { + let owner = module_owner(path, ctx.surface).unwrap_or(""); + rsx! { + span { class: "me-verb", "data-owner": "{owner}", "{path}" } + for call in calls.iter() { + if call.name.is_some() { + "." + span { class: "me-verb", {call.name.clone().unwrap_or_default()} } + } + "(" + {comma_list(call.args.iter().map(|a| value_view(a, ctx)).collect())} + ")" + } + } + } + Value::Table { fields, .. } => rsx! { + "{{ " + for (i, field) in fields.iter().enumerate() { + if i > 0 { ", " } + "{field.key} = " + {value_view(&field.value, ctx)} + } + " }}" + }, + Value::Opaque { reason, .. } => rsx! { span { class: "me-opaque", "[{reason}]" } }, + } +} + +/// The control for one literal leaf, chosen by the semantic the annotator +/// stamped: unit_def_name -> unit dropdown, objective_name -> objective +/// field, number -> number field, plain string -> text field. +fn control_for(value: &Value, ctx: &Ctx) -> Option { + if !ctx.editable { + return None; + } + match value { + Value::String { value, span, semantic } => match semantic.as_deref() { + // A literal-union parameter type renders as a picker of exactly + // its literals — the enum came from the LuaCATS alias. + Some(semantic) if ctx.surface.enums.contains_key(semantic) => { + Some(enum_select(value, &ctx.surface.enums[semantic], *span, ctx)) + } + Some("unit_def_name") => Some(unit_select(value, *span, ctx)), + Some("objective_name") => Some(text_input(value, *span, ctx, "me-input me-input-obj")), + _ if !value.contains('"') => Some(text_input(value, *span, ctx, "me-input")), + _ => None, + }, + Value::Number { value, span, .. } => Some(number_input(*value, *span, ctx)), + _ => None, + } +} + +fn enum_select(current: &str, options: &[String], span: Span, ctx: &Ctx) -> Element { + let known = options.iter().any(|o| o == current); + rsx! { + select { + class: "me-select me-select-enum", + "data-file": "{ctx.file}", + "data-start": "{span.0}", + "data-end": "{span.1}", + "data-hash": "{ctx.hash}", + "data-quote": "1", + if !known { + option { value: "{current}", "selected": "true", "{current}" } + } + for opt in options.iter() { + option { + value: "{opt}", + "selected": if opt == current { "true" }, + "{opt}" + } + } + } + } +} + +fn text_input(value: &str, span: Span, ctx: &Ctx, class: &'static str) -> Element { + rsx! { + input { + r#type: "text", + class: class, + value: "{value}", + "data-file": "{ctx.file}", + "data-start": "{span.0}", + "data-end": "{span.1}", + "data-hash": "{ctx.hash}", + "data-quote": "1", + } + } +} + +fn number_input(value: f64, span: Span, ctx: &Ctx) -> Element { + let text = fmt_num(value); + rsx! { + input { + r#type: "text", + class: "me-input me-input-num", + value: "{text}", + "data-file": "{ctx.file}", + "data-start": "{span.0}", + "data-end": "{span.1}", + "data-hash": "{ctx.hash}", + "data-quote": "0", + } + } +} + +fn unit_select(current: &str, span: Span, ctx: &Ctx) -> Element { + let known = ctx.domains.units.iter().any(|u| u.value == current); + rsx! { + select { + class: "me-select me-select-unit", + "data-file": "{ctx.file}", + "data-start": "{span.0}", + "data-end": "{span.1}", + "data-hash": "{ctx.hash}", + "data-quote": "1", + if !known { + option { value: "{current}", "selected": "true", "{current}" } + } + for unit in ctx.domains.units.iter() { + option { + value: "{unit.value}", + "selected": if unit.value == current { "true" }, + "{unit.label}" + } + } + } + } +} + +fn comma_list(items: Vec) -> Element { + rsx! { + for (i, item) in items.into_iter().enumerate() { + if i > 0 { ", " } + {item} + } + } +} + +fn fmt_num(n: f64) -> String { + if n.is_finite() && n == n.floor() { + format!("{}", n as i64) + } else { + format!("{n}") + } +} + +fn modals(surface: &Surface) -> Modals { + let row = |kind: &str, entry: &SurfaceEntry, new_text: String| ModalRow { + kind: kind.to_string(), + label: entry.label.clone(), + new_text, + }; + let group = |label: &str| ModalRow { + kind: "group".to_string(), + label: label.to_string(), + new_text: String::new(), + }; + + let mut add_step = vec![group("WHEN · more conditions (all must hold)")]; + for c in &surface.conditions { + add_step.push(row("andwhen", c, format!("\t.When({})\n", c.template))); + } + add_step.push(group("DO · effects")); + for e in &surface.effects { + add_step.push(row("effect", e, format!("\t.Do({})\n", e.template))); + } + + let mut add_spawn = vec![group("SPAWN")]; + for sp in &surface.spawns { + add_spawn.push(row("spawn", sp, sp.template.clone())); + } + + let mut add_statement = vec![group("STARTS WHEN...")]; + for c in &surface.conditions { + add_statement.push(row( + "trigger", + c, + format!( + "\nWhen({})\n\t.Do(Objective(\"new_objective\").Complete())\n", + c.template + ), + )); + } + + let swap = |entries: &[SurfaceEntry]| { + entries + .iter() + .map(|e| row("swap", e, format!("({})", e.template))) + .collect() + }; + + Modals { + add_step: Modal { title: "Add to this trigger".into(), rows: add_step }, + add_statement: Modal { title: "New statement".into(), rows: add_statement }, + add_spawn: Modal { title: "Add a spawn".into(), rows: add_spawn }, + swap_conditions: Modal { title: "Swap condition".into(), rows: swap(&surface.conditions) }, + swap_effects: Modal { title: "Swap effect".into(), rows: swap(&surface.effects) }, + } +} + +/// RmlUi's parser is XML: void elements must self-close. dioxus-ssr emits +/// HTML5-style ``; close them. +fn xmlize(markup: &str) -> String { + const VOIDS: [&str; 4] = ["input", "img", "br", "hr"]; + let bytes = markup.as_bytes(); + let mut out = String::with_capacity(markup.len() + 32); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'<' { + let rest = &markup[i + 1..]; + let is_void = VOIDS.iter().any(|v| { + rest.starts_with(v) + && rest[v.len()..] + .bytes() + .next() + .map_or(true, |c| c.is_ascii_whitespace() || c == b'>' || c == b'/') + }); + if is_void { + let mut j = i; + let mut quote: Option = None; + while j < bytes.len() { + let c = bytes[j]; + match quote { + Some(q) if c == q => quote = None, + Some(_) => {} + None if c == b'"' || c == b'\'' => quote = Some(c), + None if c == b'>' => break, + None => {} + } + j += 1; + } + let inner = &markup[i..j]; + out.push_str(inner); + if !inner.trim_end().ends_with('/') { + out.push('/'); + } + out.push('>'); + i = j + 1; + continue; + } + } + let ch = markup[i..].chars().next().expect("in-bounds char"); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const WIN: &str = r#" +When(Team.Player.Has(UnitDef("armpw"), 3)) + .Do(Objective("build_pawns").Complete()) + +When(Objective("build_pawns").IsComplete()) + .Do(MatchFlow.Victory(Team.Player)) +"#; + + /// The overlay plus a derived palette — the same shape collect_ast builds, + /// so a test sees what the editor sees rather than the raw overlay. + fn test_surface() -> serde_json::Value { + let mut surface: serde_json::Value = serde_json::from_str(crate::MISSION_SURFACE).unwrap(); + let types = crate::types::TypeSurface::builtin(); + let labels: std::collections::BTreeMap = + serde_json::from_value(surface["labels"].clone()).unwrap_or_default(); + for (role, paths) in [ + ("conditions", vec!["MatchFlow.Started", "Team.Player.Has", "Objective.IsComplete", + "Unit.IsDestroyed", "Unit.IsSpotted"]), + ("effects", vec!["Objective.Complete", "Transfer.Units", "Combat.Protect", + "MatchFlow.Victory", "MatchFlow.Defeat"]), + ] { + let mut entries: Vec = paths + .iter() + .filter_map(|p| { + types.template_for(p).map(|template| { + serde_json::json!({ + "label": labels.get(*p).cloned().unwrap_or_else(|| (*p).to_string()), + "template": template, + }) + }) + }) + .collect(); + if let Some(extra) = surface.get(role).and_then(|v| v.as_array()) { + entries.extend(extra.iter().cloned()); + } + surface[role] = serde_json::Value::Array(entries); + } + surface + } + + fn ast() -> MissionAst { + let rec = crate::recognizer::recognize_file("triggers/win.lua", WIN).unwrap(); + MissionAst { + version: 1, + generation: 7, + files: vec![rec.file], + surface: test_surface(), + } + } + + fn domains() -> Domains { + Domains { + units: vec![ + DomainOption { value: "armck".into(), label: "Construction Kbot [armck]".into() }, + DomainOption { value: "armpw".into(), label: "Pawn [armpw]".into() }, + ], + } + } + + /// XML tag-balance check: what RmlUi's parser must be able to swallow. + fn assert_wellformed(markup: &str) { + let mut stack: Vec = Vec::new(); + let bytes = markup.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'<' { + i += 1; + continue; + } + let closing = bytes.get(i + 1) == Some(&b'/'); + let name_start = if closing { i + 2 } else { i + 1 }; + let mut j = name_start; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'-') { + j += 1; + } + let name = markup[name_start..j].to_string(); + let mut quote: Option = None; + while j < bytes.len() { + let c = bytes[j]; + match quote { + Some(q) if c == q => quote = None, + Some(_) => {} + None if c == b'"' || c == b'\'' => quote = Some(c), + None if c == b'>' => break, + None => {} + } + j += 1; + } + assert!(j < bytes.len(), "unterminated tag <{name}"); + let self_closed = bytes[j - 1] == b'/'; + if closing { + assert_eq!(stack.pop().as_deref(), Some(name.as_str()), "mismatched "); + } else if !self_closed { + stack.push(name); + } + i = j + 1; + } + assert!(stack.is_empty(), "unclosed tags: {stack:?}"); + } + + #[test] + fn the_form_is_wellformed_xml_with_editable_controls() { + let view = render(&ast(), &domains(), &Scope::default()); + assert_wellformed(&view.form); + assert!(!view.form.contains(" + + + +Campaign Editor + + + +
+

Campaign Editor

+
Waiting for bar-mission-kit serve to write the view artifact…
+
+
Mission not armed — values go live once it runs in-game.
+
+
+ + + + diff --git a/just/bar.just b/just/bar.just index c97af0fa..1c5c56ac 100644 --- a/just/bar.just +++ b/just/bar.just @@ -237,3 +237,106 @@ setup-hooks: git -C "$BAR_DIR" config blame.ignoreRevsFile .git-blame-ignore-revs ok "Configured git blame to ignore formatting commits" fi + +# Run bar-mission-kit unit tests (mission DSL recognizer) +mission-kit-test: + #!/usr/bin/env bash + set -euo pipefail + source "$DEVTOOLS_DIR/scripts/common.sh" + step "Testing bar-mission-kit..." + bash "$DEVTOOLS_DIR/scripts/mission-kit-cargo.sh" test + # The terminal's script is embedded HTML: cargo cannot see a syntax error + # in it, only a blank panel at runtime can. + step "Checking the kit's type fixtures against the game..." + bash "$DEVTOOLS_DIR/scripts/sync-kit-fixtures.sh" --check + step "Checking the browser terminal's script..." + enter_distrobox bash "$DEVTOOLS_DIR/bar-mission-kit/check_terminal_js.sh" + ok "All tests passed" + +# Mirror the game's DSL types into the kit's fixtures (--check fails if stale) +sync-kit-fixtures *args: require-bar + @bash "$DEVTOOLS_DIR/scripts/sync-kit-fixtures.sh" {{args}} + +# Install the VS Code extension (copies over any previous install; reload the window after) +mission-editor-install: + #!/usr/bin/env bash + set -euo pipefail + source "$DEVTOOLS_DIR/scripts/common.sh" + src="$DEVTOOLS_DIR/bar-mission-kit/vscode" + version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$src/package.json")" + dest="$HOME/.vscode/extensions/beyond-all-reason.bar-mission-editor-$version" + # Ship the server with the plugin: the whole point is that BAR-Devtools is + # not required to use it. The extension still adopts a running server first. + bash "$DEVTOOLS_DIR/scripts/mission-kit-cargo.sh" build --release + mkdir -p "$src/server" + cp "$DEVTOOLS_DIR/bar-mission-kit/target/release/bar-mission-kit" "$src/server/" + # Replace, never overwrite in place: the extension runs the bundled server + # straight out of this directory, and cp onto a running binary is ETXTBSY. + # Older version dirs go too - they accumulate and VS Code scans each one. + rm -rf "$HOME/.vscode/extensions/beyond-all-reason.bar-mission-editor-"* + mkdir -p "$dest" + cp -r "$src/." "$dest/" + ok "installed to $dest" + info "Reload the VS Code window (Developer: Reload Window) to pick it up." + +# Validate mission trigger files (default: hello_pawns) +mission-check *args: require-bar + #!/usr/bin/env bash + set -euo pipefail + source "$DEVTOOLS_DIR/scripts/common.sh" + args="{{args}}" + bash "$DEVTOOLS_DIR/scripts/mission-kit-cargo.sh" run -q -- check ${args:-"$BAR_DIR/modules/missions/hello_pawns"} + +# Regenerate the mission AST artifact the in-game editor view renders +mission-ast: require-bar + #!/usr/bin/env bash + set -euo pipefail + source "$DEVTOOLS_DIR/scripts/common.sh" + mkdir -p "$BAR_DIR/modules/missions/.editor" + bash "$DEVTOOLS_DIR/scripts/mission-kit-cargo.sh" run -q -- parse "$BAR_DIR/modules/missions/hello_pawns" --out "$BAR_DIR/modules/missions/.editor/mission_ast.json" + ok "Wrote modules/missions/.editor/mission_ast.json" + +# Run the mission editor service: watch triggers, regen AST, apply UI edits, open-in-editor +mission-serve *args: require-bar + #!/usr/bin/env bash + set -euo pipefail + source "$DEVTOOLS_DIR/scripts/common.sh" + bash "$DEVTOOLS_DIR/scripts/mission-kit-cargo.sh" build --release + args="{{args}}" + # The editor dir must live in the ENGINE WRITE DIR (the widget reads the + # artifact and writes intents there via VFS/io; VFS searches it first). + # Override with BAR_WRITE_DIR if your install writes elsewhere. + write_dir="${BAR_WRITE_DIR:-$HOME/.local/state/Beyond All Reason}" + if [ ! -d "$write_dir" ]; then + write_dir="$BAR_DIR" + info "engine write dir not found; falling back to the repo" + fi + if [ -z "$args" ]; then + # Default: follow the game — the editor re-scopes to whatever mission + # the picker arms (active_mission.json from the bridge). + set -- --missions-root "$BAR_DIR/modules/missions" + else + set -- $args + fi + exec "$DEVTOOLS_DIR/bar-mission-kit/target/release/bar-mission-kit" serve \ + "$@" \ + --editor-dir "$write_dir/modules/missions/.editor" + +# Tail the game side of the mission editor (bridge, terminal, loader) from infolog +mission-logs: + #!/usr/bin/env bash + set -euo pipefail + write_dir="${BAR_WRITE_DIR:-$HOME/.local/state/Beyond All Reason}" + exec tail -F "$write_dir/infolog.txt" | grep --line-buffered -Ei 'mission_editor|mission_bridge|mission_loader|mission (armed|reload)' + +# Mission-dev session: mission-serve in the background + game launch; serve dies with the session +mission-dev *args: require-bar + #!/usr/bin/env bash + set -euo pipefail + source "$DEVTOOLS_DIR/scripts/common.sh" + step "Starting mission-serve..." + just bar::mission-serve & + serve_pid=$! + trap 'kill "$serve_pid" 2>/dev/null || true' EXIT + just bar::launch {{args}} + diff --git a/just/repos.just b/just/repos.just index 8c8620e1..94f1fa22 100644 --- a/just/repos.just +++ b/just/repos.just @@ -43,3 +43,9 @@ sync *args="all": source "$DEVTOOLS_DIR/scripts/common.sh" source "$DEVTOOLS_DIR/scripts/repos.sh" cmd_sync {{args}} + +# Inspect or restack a linear branch stack, bottom branch first +# (stack status a b c | stack rebase a b c | stack push a b c). +# STACK_REMOTE / STACK_BASE / STACK_REPO override the derived defaults. +stack cmd *branches: + @cd "{{invocation_directory()}}" && bash "$DEVTOOLS_DIR/scripts/stack.sh" {{cmd}} {{branches}} diff --git a/scripts/mission-kit-cargo.sh b/scripts/mission-kit-cargo.sh new file mode 100755 index 00000000..14caa831 --- /dev/null +++ b/scripts/mission-kit-cargo.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Run cargo for bar-mission-kit inside DEVTOOLS_DISTROBOX (bar-dev has rust/cargo). +set -euo pipefail + +DEVTOOLS_DIR="${DEVTOOLS_DIR:?DEVTOOLS_DIR must be set}" +KIT_DIR="$DEVTOOLS_DIR/bar-mission-kit" + +source "$DEVTOOLS_DIR/scripts/common.sh" + +enter_distrobox "$@" + +cd "$KIT_DIR" +cargo "$@" diff --git a/scripts/stack.sh b/scripts/stack.sh new file mode 100644 index 00000000..61fe76b1 --- /dev/null +++ b/scripts/stack.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Linear branch stacks: inspect, rebase, push. +# +# stack.sh status ... the chain, its PRs, and every push hazard +# stack.sh rebase ... back up each branch, then rebase bottom to top +# stack.sh push ... force-push one branch at a time, bottom to top +# +# The stack is the argument list, bottom first. Nothing about any particular +# stack lives here — pass the branches, and set these when the defaults are +# wrong: +# +# STACK_REMOTE remote to compare and push against (default: upstream, else origin) +# STACK_BASE what the bottom branch rebases onto (default: /HEAD) +# STACK_REPO owner/name for PR lookups (default: derived from the remote URL) +# STACK_DIR the repo to act on (default: $PWD) +# +# rebase and push switch branches in that repo, so it must be clean. +# +# Why push is shaped this way: a batch `git push -f remote a b c` once pushed +# three branches to one coalesced commit, and GitHub read that as the lower PRs +# having merged — one is permanently badged MERGED, another auto-closed, and +# neither can be undone. So: never a batch, always bottom to top, never while +# two branches in the stack share a head commit. +set -euo pipefail + +DEVTOOLS_DIR="${DEVTOOLS_DIR:?DEVTOOLS_DIR must be set}" +source "$DEVTOOLS_DIR/scripts/common.sh" + +[ $# -ge 2 ] || { echo "usage: stack.sh ... (bottom first)" >&2; exit 1; } +CMD="$1"; shift +STACK=("$@") + +cd "${STACK_DIR:-$PWD}" || exit 1 +git rev-parse --git-dir >/dev/null 2>&1 || { err "not a git repository: $PWD"; exit 1; } + +if [ -z "${STACK_REMOTE:-}" ]; then + if git remote get-url upstream >/dev/null 2>&1; then STACK_REMOTE=upstream; else STACK_REMOTE=origin; fi +fi +git remote get-url "$STACK_REMOTE" >/dev/null 2>&1 || { err "no such remote: $STACK_REMOTE"; exit 1; } + +# owner/name from the remote URL, for PR lookups only; absent is not fatal. +if [ -z "${STACK_REPO:-}" ]; then + STACK_REPO="$(git remote get-url "$STACK_REMOTE" \ + | sed -E 's#^(git@|https://|ssh://git@)##; s#^[^/:]+[:/]##; s#\.git$##')" +fi + +STARTING_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +# Branch names collide with directory names ("modes", "combat"), so every +# command that takes a branch gets an explicit `--` or a pre-resolved sha; a +# bare `git checkout modes` checks out the path and silently stays put. +trap 'git checkout -q "$STARTING_BRANCH" -- 2>/dev/null || true' EXIT + +require_clean() { + if [ -n "$(git status --porcelain --ignore-submodules | grep -v '^?? ')" ]; then + err "working tree is dirty - commit or stash first." + exit 1 + fi +} + +assert_exists() { + local missing=0 + for branch in "${STACK[@]}"; do + git rev-parse --verify --quiet "$branch" >/dev/null || { err "branch $branch does not exist"; missing=1; } + done + [ "$missing" -eq 0 ] || exit 1 +} + +# Every branch must be an ancestor of the next. A break means the stack is not +# the one described, so reporting and pushing refuse to act on it — but rebase +# does not check this, because a broken chain is exactly what it repairs: +# amending a branch mid-stack orphans everything above it by definition. +assert_chain() { + assert_exists + local prev="" broken=0 + for branch in "${STACK[@]}"; do + if [ -n "$prev" ] && ! git merge-base --is-ancestor "$prev" "$branch"; then + err "$prev is not an ancestor of $branch - the chain is broken" + broken=1 + fi + prev="$branch" + done + [ "$broken" -eq 0 ] || exit 1 +} + +# The false-merge guard: two branches at one commit means a push coalesces +# them, and GitHub marks the lower PR MERGED, irreversibly. +assert_distinct_heads() { + local dupes + dupes="$(for branch in "${STACK[@]}"; do + printf '%s %s\n' "$(git rev-parse "$branch")" "$branch" + done | sort | awk '{ heads[$1] = heads[$1] " " $2; n[$1]++ } + END { for (sha in n) if (n[sha] > 1) print sha heads[sha] }')" + if [ -n "$dupes" ]; then + err "branches share a head commit - pushing would false-merge a PR:" + echo "$dupes" >&2 + exit 1 + fi +} + +# Know which PRs a push touches before it happens. A remembered PR map is not +# authoritative; ask the host. +show_prs() { + command -v gh >/dev/null || { warn "gh not found - cannot enumerate PRs"; return; } + local branch + for branch in "${STACK[@]}"; do + printf ' %-24s %s\n' "$branch" \ + "$(gh pr list --repo "$STACK_REPO" --head "$branch" --state open \ + --json number,baseRefName,title \ + --template '{{range .}}#{{.number}} -> {{.baseRefName}} {{.title}}{{end}}' 2>/dev/null)" + done +} + +resolve_base() { + if [ -n "${STACK_BASE:-}" ]; then echo "$STACK_BASE"; return; fi + local head + head="$(git symbolic-ref --quiet "refs/remotes/$STACK_REMOTE/HEAD" 2>/dev/null || true)" + if [ -n "$head" ]; then echo "${head#refs/remotes/}"; return; fi + err "cannot resolve a base: set STACK_BASE (or run: git remote set-head $STACK_REMOTE -a)" + exit 1 +} + +cmd_status() { + assert_chain + git fetch "$STACK_REMOTE" --quiet 2>/dev/null || warn "could not fetch $STACK_REMOTE" + echo + printf ' %-24s %-10s %-9s %s\n' BRANCH HEAD VS-REMOTE SUBJECT + local branch sha state ahead + for branch in "${STACK[@]}"; do + sha="$(git rev-parse --short "$branch")" + if git rev-parse --verify --quiet "$STACK_REMOTE/$branch" >/dev/null; then + ahead="$(git rev-list --count "$STACK_REMOTE/$branch".."$branch")" + state=$([ "$ahead" -eq 0 ] && echo synced || echo "ahead $ahead") + else + state="unpushed" + fi + printf ' %-24s %-10s %-9s %s\n' "$branch" "$sha" "$state" \ + "$(git log --format=%s -1 "$branch" -- | cut -c1-46)" + done + echo + step "Open PRs by head branch ($STACK_REPO):" + show_prs + echo + assert_distinct_heads + ok "no two branches share a head commit" +} + +cmd_rebase() { + require_clean + assert_exists + git fetch "$STACK_REMOTE" --quiet + local base stamp branch prev + base="$(resolve_base)" + stamp="$(git rev-parse --short "$base")" + step "Backing up every branch as backup-$stamp-..." + for branch in "${STACK[@]}"; do + git branch -f "backup-$stamp-$branch" "$branch" >/dev/null + done + prev="$base" + for branch in "${STACK[@]}"; do + # --onto with the recorded parent: a plain rebase walks back past an + # amended parent and replays commits onto their own successors. + local onto old_parent + onto="$(git rev-parse "$prev")" + old_parent="$(git rev-parse "$branch^")" + step "Rebasing $branch onto $prev..." + if ! git rebase --onto "$onto" "$old_parent" "$branch" >/dev/null 2>&1; then + err "conflict rebasing $branch:" + git diff --name-only --diff-filter=U >&2 + git rebase --abort 2>/dev/null || true + exit 1 + fi + ok "$branch rebased" + prev="$branch" + done + assert_distinct_heads + ok "stack rebased; backups at backup-$stamp-*" +} + +cmd_push() { + require_clean + assert_chain + assert_distinct_heads + git fetch "$STACK_REMOTE" --quiet + echo + warn "About to FORCE-PUSH ${#STACK[@]} branches to $STACK_REMOTE, one at a time, bottom to top." + warn "These open PRs are affected:" + show_prs + echo + local answer branch + read -r -p "Type the number of branches to confirm (${#STACK[@]}): " answer + if [ "$answer" != "${#STACK[@]}" ]; then + err "not confirmed - nothing pushed" + exit 1 + fi + for branch in "${STACK[@]}"; do + step "Pushing $branch..." + git push -f "$STACK_REMOTE" "$branch" + ok "$branch pushed" + done + ok "stack pushed bottom to top" +} + +case "$CMD" in + status) cmd_status ;; + rebase) cmd_rebase ;; + push) cmd_push ;; + *) echo "usage: stack.sh ... (bottom first)" >&2; exit 1 ;; +esac diff --git a/scripts/sync-kit-fixtures.sh b/scripts/sync-kit-fixtures.sh new file mode 100755 index 00000000..c694201b --- /dev/null +++ b/scripts/sync-kit-fixtures.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Mirror the game's published DSL types into the kit's fixtures, one file per +# module. The kit needs a surface when it runs without a modules tree (its own +# tests, a single-file check), and a hand-maintained copy drifts: a verb renamed +# in the game kept working here because the copy still described the old shape. +# +# sync-kit-fixtures.sh refresh +# sync-kit-fixtures.sh --check fail if stale (CI/test gate) +set -euo pipefail +DEVTOOLS_DIR="${DEVTOOLS_DIR:?DEVTOOLS_DIR must be set}" +BAR_DIR="${BAR_DIR:-$DEVTOOLS_DIR/Beyond-All-Reason}" +DEST="$DEVTOOLS_DIR/bar-mission-kit/fixtures/modules" +check=0 +[ "${1:-}" = "--check" ] && check=1 +stale=0 +while IFS= read -r src; do + rel="${src#"$BAR_DIR"/modules/}" + out="$DEST/$rel" + mkdir -p "$(dirname "$out")" + # Strip CRLF so the fixture is stable regardless of the checkout's endings. + if [ "$check" -eq 1 ]; then + if ! diff -q <(sed 's/\r$//' "$src") "$out" >/dev/null 2>&1; then + echo "stale fixture: $rel" >&2 + stale=1 + fi + else + sed 's/\r$//' "$src" > "$out" + fi +done < <(grep -rlE "@meta (actions|policy )" "$BAR_DIR"/modules/*/types/*.lua | sort) + +# A file still on the old undifferentiated marker publishes into no sandbox at +# all: the kit would simply stop seeing it. Loudly, then. +if orphans="$(grep -rlE "@meta (dsl|(mission|mode)_dsl)$" "$BAR_DIR"/modules/*/types/*.lua 2>/dev/null)"; then + echo "surfaces on a retired marker (use "actions" or "policy "):" >&2 + echo "$orphans" >&2 + stale=1 +fi + +# A surface the game deleted must leave the mirror too: the kit compiles these +# in, so a leftover keeps declaring vocabulary nothing publishes any more. +while IFS= read -r fixture; do + rel="${fixture#"$DEST"/}" + [ -f "$BAR_DIR/modules/$rel" ] && continue + if [ "$check" -eq 1 ]; then + echo "orphaned fixture: $rel" >&2 + stale=1 + else + rm -f "$fixture" + echo "removed orphaned fixture: $rel" + fi +done < <(find "$DEST" -name '*.lua' | sort) + +# The kit compiles the mirror in, so a fixture nobody include_str!s is a file +# the kit cannot see: a module publishing new vocabulary would land here and +# still be missing from the editor. The list is generated, never edited. +if ! DEST="$DEST" python3 - "$DEVTOOLS_DIR/bar-mission-kit/src/types.rs" "$check" <<'PYEOF'; then stale=1; fi +import os, pathlib, re, sys + +path, check = sys.argv[1], sys.argv[2] == "1" +dest = pathlib.Path(os.environ["DEST"]) +files = sorted(str(p.relative_to(dest)) for p in dest.rglob("*.lua")) + + +def order(rel): + module, _, name = rel.partition("/") + # missions declares the base classes every other surface references, and + # the parser resolves forward references positionally. Within a module the + # mission sandbox (dsl.lua) parses last: it and mode_dsl.lua can declare + # the same global — Transfer is both a mode noun and a mission verb — and + # the flat surface keeps whichever came last, which for editing trigger + # files must be the mission one. + return (module != "missions", module, name.endswith("/dsl.lua"), rel) + + +want = "pub const SNAPSHOTS: &[&str] = &[\n" + "".join( + f' include_str!("../fixtures/modules/{rel}"),\n' for rel in sorted(files, key=order) +) + "];" +src = open(path, encoding="utf8").read() +pattern = re.compile(r"pub const SNAPSHOTS: &\[&str\] = &\[.*?\];", re.S) +if pattern.search(src).group(0) == want: + sys.exit(0) +if check: + print("stale SNAPSHOTS list in src/types.rs", file=sys.stderr) + sys.exit(1) +open(path, "w", encoding="utf8").write(pattern.sub(lambda _: want, src, count=1)) +PYEOF + +if [ "$check" -eq 1 ] && [ "$stale" -eq 1 ]; then + echo "run: just bar::sync-kit-fixtures" >&2 + exit 1 +fi +[ "$check" -eq 1 ] && echo "kit fixtures match the game's types" || echo "kit fixtures refreshed"