From e42f44702eec1585b0cb2209a7c1f7c6301fe892 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 20:35:44 -0600 Subject: [PATCH 01/94] bar-mission-kit: the mission DSL recognizer + validator CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor architecture milestone 2 (editor_architecture_plan.md): the subset recognizer, defined over full_moon's lossless CST — the same parse layer bar-lua-codemod already uses, per the plan's own recon instruction (no second parser, no tree-sitter needed). The walk IS the grammar: it classifies statements into trigger chains (T.When ... .Register, dot-only, closure-free), verb expressions (dotted path + chained invocations), literals, named refs, and tables; everything else is an opaque span with a finding. Comment decorators are promoted to structure: ---@group parents following chains into sections, ---@label titles a chain. Every node carries byte spans for the write-back milestone. Two consumers of one walk: bar-mission-kit parse --out ast.json the decorated AST bar-mission-kit check CI validator, nonzero on findings 6 unit tests incl. the real hello_pawns mission shape, chained invocations, closure rejection, decorator grouping. hello_pawns triggers check clean end to end. Co-Authored-By: Claude Fable 5 --- bar-mission-kit/.gitignore | 1 + bar-mission-kit/Cargo.lock | 405 ++++++++++++++++++++++++++++++ bar-mission-kit/Cargo.toml | 11 + bar-mission-kit/src/main.rs | 243 ++++++++++++++++++ bar-mission-kit/src/model.rs | 125 +++++++++ bar-mission-kit/src/recognizer.rs | 385 ++++++++++++++++++++++++++++ 6 files changed, 1170 insertions(+) create mode 100644 bar-mission-kit/.gitignore create mode 100644 bar-mission-kit/Cargo.lock create mode 100644 bar-mission-kit/Cargo.toml create mode 100644 bar-mission-kit/src/main.rs create mode 100644 bar-mission-kit/src/model.rs create mode 100644 bar-mission-kit/src/recognizer.rs diff --git a/bar-mission-kit/.gitignore b/bar-mission-kit/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/bar-mission-kit/.gitignore @@ -0,0 +1 @@ +/target diff --git a/bar-mission-kit/Cargo.lock b/bar-mission-kit/Cargo.lock new file mode 100644 index 00000000..36eece24 --- /dev/null +++ b/bar-mission-kit/Cargo.lock @@ -0,0 +1,405 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "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", + "full_moon", + "glob", + "serde", + "serde_json", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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 = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "full_moon" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a083d6f598bbe9d70e694f2a5cf78f0354678a8c13528da2df6467b089586404" +dependencies = [ + "bytecount", + "cfg-if", + "derive_more", + "full_moon_derive", + "paste", + "serde", + "smol_str", +] + +[[package]] +name = "full_moon_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b4bd12ce56927d1dc5478d21528ea8c4b93ca85ff8f8043b6a5351a2a3c6f7" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "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 = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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 = "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..9ac32d3a --- /dev/null +++ b/bar-mission-kit/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "bar-mission-kit" +version = "0.1.0" +edition = "2021" + +[dependencies] +full_moon = "2.1" +glob = "0.3" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/bar-mission-kit/src/main.rs b/bar-mission-kit/src/main.rs new file mode 100644 index 00000000..9006c56a --- /dev/null +++ b/bar-mission-kit/src/main.rs @@ -0,0 +1,243 @@ +//! 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 model; +mod recognizer; + +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, + }, +} + +fn collect_lua_files(paths: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + for path in paths { + if path.is_dir() { + let pattern = format!("{}/**/*.lua", path.display()); + for entry in glob::glob(&pattern).expect("valid glob").flatten() { + files.push(entry); + } + } else { + files.push(path.clone()); + } + } + files.sort(); + 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() +} + +fn run(paths: &[PathBuf]) -> (model::MissionAst, Vec) { + let files = collect_lua_files(paths); + let mut ast = model::MissionAst { version: 1, files: Vec::new() }; + let mut findings = Vec::new(); + 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}"), + }); + continue; + } + }; + match recognizer::recognize_file(&rel, &source) { + 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}"), + }), + } + } + (ast, findings) +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.command { + Command::Parse { paths, out } => { + let (ast, findings) = run(&paths); + 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) = run(&paths); + for f in &findings { + println!("{}:{}: {}", f.path, f.line, f.message); + } + if findings.is_empty() { + println!("OK"); + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::model::Value; + + const WIN: &str = r#" +T.When(Team.Player.Has(UnitDef("armpw"), 3)) + .Do(Objective("build_pawns").Complete()) + .Register() + +T.When(Objective("build_pawns").IsComplete()) + .Do(MatchFlow.Victory(Team.Player)) + .Register() +"#; + + #[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", "Register"]); + + // When's condition: Team.Player.Has(UnitDef("armpw"), 3) + 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:?}"), + } + + // Do's effect: MatchFlow.Victory(Team.Player) in trigger 2 + 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#" +T.When(Region("north").EnteredBy(Team.Player, { count = 5 })) + .Do(Wave.Define("flank").Route(Path("east")).Spawn()) + .Register() +"#; + 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 = "T.When(C()).Do(function() end).Register()\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") +T.When(C()).Do(E()).Register() +"#; + 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 missing_register_is_a_finding() { + let src = "T.When(C()).Do(E())\n"; + let rec = crate::recognizer::recognize_file("triggers/r.lua", src).unwrap(); + assert!(rec.findings.iter().any(|f| f.message.contains("Register"))); + } +} diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs new file mode 100644 index 00000000..4a9accd6 --- /dev/null +++ b/bar-mission-kit/src/model.rs @@ -0,0 +1,125 @@ +//! 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, + pub files: Vec, +} + +#[derive(Serialize, Debug)] +pub struct FileAst { + /// Path as given on the command line (mission-relative when a dir walk). + pub path: String, + /// 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, + /// From a `---@label("...")` directly above the chain. + pub label: Option, + pub steps: Vec, +} + +#[derive(Serialize, Debug)] +pub struct Step { + /// Chain verb: When, AndWhen, Debounce, Once, Do, Register. + pub verb: String, + pub span: Span, + pub args: Vec, +} + +/// 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, + }, + String { + value: String, + span: Span, + }, + 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, +} + +/// A validator finding, printed `path:line: message` in check mode. +#[derive(Debug)] +pub struct Finding { + pub path: String, + pub line: usize, + pub message: String, +} diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs new file mode 100644 index 00000000..0a6877d7 --- /dev/null +++ b/bar-mission-kit/src/recognizer.rs @@ -0,0 +1,385 @@ +//! The subset recognizer: classifies full_moon CST nodes into the decorated +//! mission AST. This walk IS the DSL grammar in the meaningful sense — the +//! single definition of what's form-editable — defined over a mature lossless +//! parse instead of a bespoke one. Check mode is the same walk with findings. + +use crate::model::*; +use full_moon::ast; +use full_moon::node::Node; +use full_moon::tokenizer::{TokenReference, TokenType}; + +/// Chain verbs the framework grammar admits, in the only legal shape: +/// When first, Register last. +const CHAIN_VERBS: &[&str] = &["When", "AndWhen", "Debounce", "Once", "Do", "Register"]; + +pub struct Recognized { + pub file: FileAst, + pub findings: Vec, +} + +pub fn recognize_file(path: &str, source: &str) -> Result { + let parsed = full_moon::parse(source).map_err(|errors| { + errors + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("; ") + })?; + + let mut rec = Rec { + path: path.to_string(), + source, + groups: vec![Group { label: None, triggers: Vec::new() }], + opaque: Vec::new(), + findings: Vec::new(), + order: 0, + }; + + for stmt in parsed.nodes().stmts() { + rec.statement(stmt); + } + + // 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); + } + + Ok(Recognized { + file: FileAst { path: rec.path, groups, opaque: rec.opaque }, + findings: rec.findings, + }) +} + +struct Rec<'s> { + path: String, + source: &'s str, + groups: Vec, + opaque: Vec, + findings: Vec, + order: usize, +} + +impl<'s> Rec<'s> { + fn line_of(&self, byte: usize) -> usize { + self.source[..byte.min(self.source.len())] + .bytes() + .filter(|&b| b == b'\n') + .count() + + 1 + } + + 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 }); + } + + 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 statement(&mut self, stmt: &ast::Stmt) { + let span = node_span(stmt); + // Decorators ride the statement's leading trivia. + let decorators = leading_decorators(stmt); + 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 stmt { + ast::Stmt::FunctionCall(call) => match self.trigger_chain(call, span, label) { + Some(trigger) => { + self.groups.last_mut().unwrap().triggers.push(trigger) + } + None => {} + }, + _ => { + self.mark_opaque( + span, + "trigger files contain only T.When chains (closure-free surface)", + ); + } + } + } + + /// Recognize `T.When(...).Step(...)...Register()`; None if this call + /// statement is not a trigger chain (already reported). + fn trigger_chain( + &mut self, + call: &ast::FunctionCall, + span: Span, + label: Option, + ) -> Option { + let base = match call.prefix() { + ast::Prefix::Name(token) => token.token().to_string(), + _ => String::new(), + }; + if base != "T" { + self.mark_opaque(span, "statement is not a T.When trigger chain"); + return None; + } + + let mut steps = Vec::new(); + let mut pending: Option<(String, Span)> = None; + for suffix in call.suffixes() { + match suffix { + ast::Suffix::Index(ast::Index::Dot { name, .. }) => { + if pending.is_some() { + self.mark_opaque(span, "chain step without a call — every step needs parens"); + return None; + } + pending = Some((name.token().to_string(), node_span(name))); + } + ast::Suffix::Call(ast::Call::AnonymousCall(args)) => { + let Some((verb, verb_span)) = pending.take() else { + self.mark_opaque(span, "call without a step name in chain"); + return None; + }; + let values = self.call_args(args); + steps.push(Step { verb, span: verb_span, args: values }); + } + _ => { + self.mark_opaque(span, "unsupported call shape in chain (method call?)"); + return None; + } + } + } + + // Grammar checks (also the validator's rules). + if steps.is_empty() || steps[0].verb != "When" { + self.finding(span, "trigger chain must start with T.When(...)".into()); + } + if steps.last().map(|s| s.verb.as_str()) != Some("Register") { + self.finding(span, "trigger chain must end with .Register()".into()); + } + for step in &steps { + if !CHAIN_VERBS.contains(&step.verb.as_str()) { + self.finding( + step.span, + format!("unknown chain verb '{}' (framework grammar: {})", step.verb, CHAIN_VERBS.join("/")), + ); + } + } + + self.order += 1; + Some(Trigger { + id: format!("{}:{}", self.path, self.order), + span, + label, + steps, + }) + } + + fn call_args(&mut self, args: &ast::FunctionArgs) -> Vec { + match args { + ast::FunctionArgs::Parentheses { arguments, .. } => { + arguments.iter().map(|e| self.value(e)).collect() + } + _ => { + let span = node_span(args); + self.finding(span, "call arguments must use parentheses".into()); + vec![Value::Opaque { span, reason: "non-parenthesized call arguments".into() }] + } + } + } + + /// Classify one expression into the subset's value vocabulary. + fn value(&mut self, expr: &ast::Expression) -> Value { + let span = node_span(expr); + match expr { + ast::Expression::Number(token) => { + let value = token.token().to_string().trim().parse::().unwrap_or(f64::NAN); + Value::Number { value, span } + } + ast::Expression::String(token) => Value::String { + value: string_token_value(token), + span, + }, + ast::Expression::Symbol(token) => { + let text = token.token().to_string(); + match text.trim() { + "true" => Value::Boolean { value: true, span }, + "false" => Value::Boolean { value: false, span }, + other => self.opaque_value(span, &format!("unsupported symbol '{other}'")), + } + } + ast::Expression::TableConstructor(table) => self.table(table, span), + ast::Expression::Var(var) => match var { + ast::Var::Name(token) => Value::Name { + path: token.token().to_string(), + span, + }, + ast::Var::Expression(var_expr) => self.dotted(var_expr.prefix(), var_expr.suffixes(), span), + _ => self.opaque_value(span, "unsupported variable shape"), + }, + ast::Expression::FunctionCall(call) => { + self.verb_call(call.prefix(), call.suffixes(), span) + } + ast::Expression::Function(_) => self.opaque_value( + span, + "function body in a trigger file — build the effect with a named verb (closure-free surface)", + ), + _ => 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() } + } + + /// A dotted reference with no call: Team.Player + fn dotted<'a>( + &mut self, + prefix: &ast::Prefix, + suffixes: impl Iterator, + span: Span, + ) -> Value { + let mut path = match prefix { + ast::Prefix::Name(token) => token.token().to_string(), + _ => return self.opaque_value(span, "computed base in reference"), + }; + for suffix in suffixes { + match suffix { + ast::Suffix::Index(ast::Index::Dot { name, .. }) => { + path.push('.'); + path.push_str(&name.token().to_string()); + } + _ => return self.opaque_value(span, "computed index in reference"), + } + } + Value::Name { path, span } + } + + /// A verb expression: dotted path, then invocations, optionally chained. + fn verb_call<'a>( + &mut self, + prefix: &ast::Prefix, + suffixes: impl Iterator, + span: Span, + ) -> Value { + let mut path = match prefix { + ast::Prefix::Name(token) => token.token().to_string(), + _ => return self.opaque_value(span, "computed base in verb call"), + }; + let mut calls: Vec = Vec::new(); + let mut chain_name: Option = None; + for suffix in suffixes { + match suffix { + ast::Suffix::Index(ast::Index::Dot { name, .. }) => { + let segment = name.token().to_string(); + if calls.is_empty() { + path.push('.'); + path.push_str(&segment); + } else if chain_name.is_none() { + chain_name = Some(segment); + } else { + return self.opaque_value(span, "nested index between chained calls"); + } + } + ast::Suffix::Call(ast::Call::AnonymousCall(args)) => { + let arg_span = node_span(args); + let values = self.call_args(args); + calls.push(Invocation { + name: chain_name.take(), + args: values, + span: arg_span, + }); + } + _ => return self.opaque_value(span, "method (colon) call in verb chain — the surface is dot-only"), + } + } + if calls.is_empty() { + return Value::Name { path, span }; + } + Value::Verb { path, calls, span } + } + + fn table(&mut self, table: &ast::TableConstructor, span: Span) -> Value { + let mut fields = Vec::new(); + let mut index = 0usize; + for field in table.fields() { + match field { + ast::Field::NameKey { key, value, .. } => { + let v = self.value(value); + fields.push(Field { key: key.token().to_string(), value: v }); + } + ast::Field::NoKey(value) => { + index += 1; + let v = self.value(value); + fields.push(Field { key: index.to_string(), value: v }); + } + _ => { + let fspan = node_span(field); + let v = self.opaque_value(fspan, "computed table key outside the subset"); + fields.push(Field { key: String::from("?"), value: v }); + } + } + } + Value::Table { fields, span } + } +} + +struct Decorator { + name: String, + args: Vec, +} + +/// Parse `---@group("Wave timing")` style decorators from a statement's +/// leading trivia. Comment decorators are grammar, not hints — but unknown +/// names are ignored here (forward compatibility); the vocabulary lives in +/// one place, the consumers of the AST. +fn leading_decorators(stmt: &ast::Stmt) -> Vec { + let mut out = Vec::new(); + let Some(first_token) = stmt.tokens().next() else { + return out; + }; + for trivia in first_token.leading_trivia() { + if let TokenType::SingleLineComment { comment } = trivia.token_type() { + let text = comment.to_string(); + let Some(rest) = text.trim().strip_prefix("-@") else { + continue; // `---@x` tokenizes as comment text `-@x` + }; + 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 +} + +fn string_token_value(token: &TokenReference) -> String { + let text = token.token().to_string(); + let trimmed = text.trim(); + trimmed + .trim_matches('"') + .trim_matches('\'') + .to_string() +} + +fn node_span(node: &N) -> Span { + let start = node + .start_position() + .map(|p| p.bytes()) + .unwrap_or(0); + let end = node.end_position().map(|p| p.bytes()).unwrap_or(start); + (start, end) +} From f15a9853f18d4d59c2f351e01737cb06e69435fd Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 20:48:25 -0600 Subject: [PATCH 02/94] scripts: cargo wrapper for bar-mission-kit (distrobox), mirroring codemod's Co-Authored-By: Claude Fable 5 --- scripts/mission-kit-cargo.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100755 scripts/mission-kit-cargo.sh 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 "$@" From f76c3a5ad862acb2594cbed66ceae0497ed7ff7d Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 21:02:14 -0600 Subject: [PATCH 03/94] just: bar::mission-kit-test / mission-check / mission-ast recipes Co-Authored-By: Claude Fable 5 --- just/bar.just | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/just/bar.just b/just/bar.just index c97af0fa..e8bb65bd 100644 --- a/just/bar.just +++ b/just/bar.just @@ -237,3 +237,29 @@ 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 + ok "All tests passed" + +# 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" From 1e8960023d77acfff5f8913c823af56584ccc756 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 21:16:04 -0600 Subject: [PATCH 04/94] mission-kit: recognizer moves to emmylua_parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Lua front-end across the stack: the same parser family as emmylua_check/emmylua_ls — the type layer the annotations-as-schema claim rides on, and the language-server runtime the editor track ends at. full_moon is out of the kit (the codemod still uses it; porting that is its own change while bar_fmt is pre-release). The walk inverts with the CST shape (emmylua nests calls in prefixes where full_moon lists suffixes): unroll() flattens call/index chains into path + invocations with explicit pending-name state — which also fixes a latent stash bug the suffix-order version masked (F()() call merging). Literal values now come from the parser's own analyzers (proper string unescaping, int/float discrimination) instead of hand-trimming. Decorators read from source lines above each chain — span-based, no comment-API dependency. Same JSON contract, CLI, and recipes; 6/6 tests, hello_pawns checks clean, artifact regenerated. Co-Authored-By: Claude Fable 5 --- bar-mission-kit/Cargo.lock | 485 ++++++++++++++++++++++++------ bar-mission-kit/Cargo.toml | 2 +- bar-mission-kit/src/recognizer.rs | 381 +++++++++++------------ 3 files changed, 578 insertions(+), 290 deletions(-) diff --git a/bar-mission-kit/Cargo.lock b/bar-mission-kit/Cargo.lock index 36eece24..4d24a88d 100644 --- a/bar-mission-kit/Cargo.lock +++ b/bar-mission-kit/Cargo.lock @@ -2,6 +2,15 @@ # 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" @@ -53,55 +62,46 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.5.1" +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] name = "bar-mission-kit" version = "0.1.0" dependencies = [ "clap", - "full_moon", + "emmylua_parser", "glob", "serde", "serde_json", ] [[package]] -name = "borsh" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" -dependencies = [ - "bytes", - "cfg_aliases", -] - -[[package]] -name = "bytecount" -version = "0.6.9" +name = "base62" +version = "2.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" [[package]] -name = "bytes" -version = "1.12.1" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "cfg-if" -version = "1.0.4" +name = "bstr" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] [[package]] name = "clap" @@ -150,52 +150,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "derive_more" -version = "1.0.0" +name = "countme" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ - "derive_more-impl", + "crossbeam-epoch", + "crossbeam-utils", ] [[package]] -name = "derive_more-impl" -version = "1.0.0" +name = "crossbeam-epoch" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "unicode-xid", + "crossbeam-utils", ] [[package]] -name = "full_moon" -version = "2.2.0" +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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 = "a083d6f598bbe9d70e694f2a5cf78f0354678a8c13528da2df6467b089586404" +checksum = "a0103cc231288ddc9391785db73be15d5928951e54cfe17c73af8c4d371678db" dependencies = [ - "bytecount", - "cfg-if", - "derive_more", - "full_moon_derive", - "paste", + "rowan", + "rust-i18n", "serde", - "smol_str", ] [[package]] -name = "full_moon_derive" -version = "0.11.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99b4bd12ce56927d1dc5478d21528ea8c4b93ca85ff8f8043b6a5351a2a3c6f7" -dependencies = [ - "indexmap", - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "glob" @@ -203,11 +209,41 @@ 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", + "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.12.3" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -215,14 +251,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[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 = "1.9.3" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "autocfg", - "hashbrown", + "equivalent", + "hashbrown 0.17.1", ] [[package]] @@ -231,12 +283,33 @@ 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 = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -244,16 +317,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "normpath" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "paste" -version = "1.0.15" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "proc-macro2" @@ -273,6 +355,128 @@ dependencies = [ "proc-macro2", ] +[[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 = "serde" version = "1.0.229" @@ -317,15 +521,45 @@ dependencies = [ ] [[package]] -name = "smol_str" -version = "0.3.6" +name = "serde_spanned" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ - "borsh", - "serde_core", + "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 = "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" @@ -334,9 +568,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -345,9 +579,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.119" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -355,14 +589,61 @@ dependencies = [ ] [[package]] -name = "syn" -version = "3.0.3" +name = "text-size" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "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 = "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]] @@ -372,10 +653,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unsafe-libyaml" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] name = "utf8parse" @@ -383,6 +664,25 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[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 = "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" @@ -398,6 +698,15 @@ 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" diff --git a/bar-mission-kit/Cargo.toml b/bar-mission-kit/Cargo.toml index 9ac32d3a..5d3ee4cc 100644 --- a/bar-mission-kit/Cargo.toml +++ b/bar-mission-kit/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -full_moon = "2.1" +emmylua_parser = "0.28" glob = "0.3" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index 0a6877d7..90eeb1ce 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -1,12 +1,14 @@ -//! The subset recognizer: classifies full_moon CST nodes into the decorated -//! mission AST. This walk IS the DSL grammar in the meaningful sense — the -//! single definition of what's form-editable — defined over a mature lossless -//! parse instead of a bespoke one. Check mode is the same walk with findings. +//! The subset recognizer: classifies emmylua_parser CST nodes into the +//! decorated mission AST. This walk IS the DSL grammar in the meaningful +//! sense — the single definition of what's form-editable — defined over the +//! same Lua front-end the repo's type checker and language server use +//! (emmylua-analyzer-rust). Check mode is the same walk with findings. use crate::model::*; -use full_moon::ast; -use full_moon::node::Node; -use full_moon::tokenizer::{TokenReference, TokenType}; +use emmylua_parser::{ + LuaAstNode, LuaCallExpr, LuaChunk, LuaExpr, LuaIndexKey, LuaLiteralToken, LuaParser, + LuaStat, LuaTableExpr, ParserConfig, +}; /// Chain verbs the framework grammar admits, in the only legal shape: /// When first, Register last. @@ -18,13 +20,15 @@ pub struct Recognized { } pub fn recognize_file(path: &str, source: &str) -> Result { - let parsed = full_moon::parse(source).map_err(|errors| { - errors + let tree = LuaParser::parse(source, ParserConfig::default()); + let errors = tree.get_errors(); + if !errors.is_empty() { + return Err(errors .iter() - .map(|e| e.to_string()) + .map(|e| e.message.to_string()) .collect::>() - .join("; ") - })?; + .join("; ")); + } let mut rec = Rec { path: path.to_string(), @@ -35,8 +39,11 @@ pub fn recognize_file(path: &str, source: &str) -> Result { order: 0, }; - for stmt in parsed.nodes().stmts() { - rec.statement(stmt); + 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. @@ -60,6 +67,15 @@ struct Rec<'s> { 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 { self.source[..byte.min(self.source.len())] @@ -80,10 +96,10 @@ impl<'s> Rec<'s> { self.opaque.push(Opaque { span, reason: reason.to_string() }); } - fn statement(&mut self, stmt: &ast::Stmt) { - let span = node_span(stmt); - // Decorators ride the statement's leading trivia. - let decorators = leading_decorators(stmt); + 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 { @@ -97,13 +113,16 @@ impl<'s> Rec<'s> { .find(|d| d.name == "label") .and_then(|d| d.args.first().cloned()); - match stmt { - ast::Stmt::FunctionCall(call) => match self.trigger_chain(call, span, label) { - Some(trigger) => { - self.groups.last_mut().unwrap().triggers.push(trigger) + 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.trigger_chain(&call, span, label) { + self.groups.last_mut().unwrap().triggers.push(trigger); } - None => {} - }, + } _ => { self.mark_opaque( span, @@ -117,43 +136,32 @@ impl<'s> Rec<'s> { /// statement is not a trigger chain (already reported). fn trigger_chain( &mut self, - call: &ast::FunctionCall, + call: &LuaCallExpr, span: Span, label: Option, ) -> Option { - let base = match call.prefix() { - ast::Prefix::Name(token) => token.token().to_string(), - _ => String::new(), - }; - if base != "T" { + let unrolled = self.unroll(&LuaExpr::CallExpr(call.clone()), span)?; + if unrolled.path.len() != 2 || unrolled.path[0] != "T" { self.mark_opaque(span, "statement is not a T.When trigger chain"); return None; } + // The first invocation's verb is the second path segment (T.When); + // later invocations carry their own names. let mut steps = Vec::new(); - let mut pending: Option<(String, Span)> = None; - for suffix in call.suffixes() { - match suffix { - ast::Suffix::Index(ast::Index::Dot { name, .. }) => { - if pending.is_some() { - self.mark_opaque(span, "chain step without a call — every step needs parens"); - return None; - } - pending = Some((name.token().to_string(), node_span(name))); - } - ast::Suffix::Call(ast::Call::AnonymousCall(args)) => { - let Some((verb, verb_span)) = pending.take() else { + for (i, invocation) in unrolled.calls.into_iter().enumerate() { + let verb = if i == 0 { + unrolled.path[1].clone() + } else { + match invocation.name.clone() { + Some(name) => name, + None => { self.mark_opaque(span, "call without a step name in chain"); return None; - }; - let values = self.call_args(args); - steps.push(Step { verb, span: verb_span, args: values }); - } - _ => { - self.mark_opaque(span, "unsupported call shape in chain (method call?)"); - return None; + } } - } + }; + steps.push(Step { verb, span: invocation.span, args: invocation.args }); } // Grammar checks (also the validator's rules). @@ -167,7 +175,11 @@ impl<'s> Rec<'s> { if !CHAIN_VERBS.contains(&step.verb.as_str()) { self.finding( step.span, - format!("unknown chain verb '{}' (framework grammar: {})", step.verb, CHAIN_VERBS.join("/")), + format!( + "unknown chain verb '{}' (framework grammar: {})", + step.verb, + CHAIN_VERBS.join("/") + ), ); } } @@ -181,52 +193,102 @@ impl<'s> Rec<'s> { }) } - fn call_args(&mut self, args: &ast::FunctionArgs) -> Vec { - match args { - ast::FunctionArgs::Parentheses { arguments, .. } => { - arguments.iter().map(|e| self.value(e)).collect() + /// 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); + let call_span = node_span(call); + let name = unrolled.pending.take(); + unrolled.calls.push(Invocation { name, args, span: call_span }); + Some(unrolled) } _ => { - let span = node_span(args); - self.finding(span, "call arguments must use parentheses".into()); - vec![Value::Opaque { span, reason: "non-parenthesized call arguments".into() }] + 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: &ast::Expression) -> Value { + fn value(&mut self, expr: &LuaExpr) -> Value { let span = node_span(expr); match expr { - ast::Expression::Number(token) => { - let value = token.token().to_string().trim().parse::().unwrap_or(f64::NAN); - Value::Number { value, span } - } - ast::Expression::String(token) => Value::String { - value: string_token_value(token), - span, - }, - ast::Expression::Symbol(token) => { - let text = token.token().to_string(); - match text.trim() { - "true" => Value::Boolean { value: true, span }, - "false" => Value::Boolean { value: false, span }, - other => self.opaque_value(span, &format!("unsupported symbol '{other}'")), + 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 } } - } - ast::Expression::TableConstructor(table) => self.table(table, span), - ast::Expression::Var(var) => match var { - ast::Var::Name(token) => Value::Name { - path: token.token().to_string(), + Some(LuaLiteralToken::String(string)) => Value::String { + value: string.get_value(), + span, + }, + Some(LuaLiteralToken::Bool(b)) => Value::Boolean { + value: b.is_true(), span, }, - ast::Var::Expression(var_expr) => self.dotted(var_expr.prefix(), var_expr.suffixes(), span), - _ => self.opaque_value(span, "unsupported variable shape"), + _ => self.opaque_value(span, "unsupported literal"), }, - ast::Expression::FunctionCall(call) => { - self.verb_call(call.prefix(), call.suffixes(), span) + 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 } + } } - ast::Expression::Function(_) => self.opaque_value( + LuaExpr::ClosureExpr(_) => self.opaque_value( span, "function body in a trigger file — build the effect with a named verb (closure-free surface)", ), @@ -239,117 +301,41 @@ impl<'s> Rec<'s> { Value::Opaque { span, reason: reason.to_string() } } - /// A dotted reference with no call: Team.Player - fn dotted<'a>( - &mut self, - prefix: &ast::Prefix, - suffixes: impl Iterator, - span: Span, - ) -> Value { - let mut path = match prefix { - ast::Prefix::Name(token) => token.token().to_string(), - _ => return self.opaque_value(span, "computed base in reference"), - }; - for suffix in suffixes { - match suffix { - ast::Suffix::Index(ast::Index::Dot { name, .. }) => { - path.push('.'); - path.push_str(&name.token().to_string()); - } - _ => return self.opaque_value(span, "computed index in reference"), - } - } - Value::Name { path, span } - } - - /// A verb expression: dotted path, then invocations, optionally chained. - fn verb_call<'a>( - &mut self, - prefix: &ast::Prefix, - suffixes: impl Iterator, - span: Span, - ) -> Value { - let mut path = match prefix { - ast::Prefix::Name(token) => token.token().to_string(), - _ => return self.opaque_value(span, "computed base in verb call"), - }; - let mut calls: Vec = Vec::new(); - let mut chain_name: Option = None; - for suffix in suffixes { - match suffix { - ast::Suffix::Index(ast::Index::Dot { name, .. }) => { - let segment = name.token().to_string(); - if calls.is_empty() { - path.push('.'); - path.push_str(&segment); - } else if chain_name.is_none() { - chain_name = Some(segment); - } else { - return self.opaque_value(span, "nested index between chained calls"); - } - } - ast::Suffix::Call(ast::Call::AnonymousCall(args)) => { - let arg_span = node_span(args); - let values = self.call_args(args); - calls.push(Invocation { - name: chain_name.take(), - args: values, - span: arg_span, - }); - } - _ => return self.opaque_value(span, "method (colon) call in verb chain — the surface is dot-only"), - } - } - if calls.is_empty() { - return Value::Name { path, span }; - } - Value::Verb { path, calls, span } - } - - fn table(&mut self, table: &ast::TableConstructor, span: Span) -> Value { + fn table(&mut self, table: &LuaTableExpr, span: Span) -> Value { let mut fields = Vec::new(); let mut index = 0usize; - for field in table.fields() { - match field { - ast::Field::NameKey { key, value, .. } => { - let v = self.value(value); - fields.push(Field { key: key.token().to_string(), value: v }); - } - ast::Field::NoKey(value) => { + 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; - let v = self.value(value); - fields.push(Field { key: index.to_string(), value: v }); - } - _ => { - let fspan = node_span(field); - let v = self.opaque_value(fspan, "computed table key outside the subset"); - fields.push(Field { key: String::from("?"), value: v }); + index.to_string() } - } + _ => String::from("?"), + }; + fields.push(Field { key, value }); } Value::Table { fields, span } } -} - -struct Decorator { - name: String, - args: Vec, -} -/// Parse `---@group("Wave timing")` style decorators from a statement's -/// leading trivia. Comment decorators are grammar, not hints — but unknown -/// names are ignored here (forward compatibility); the vocabulary lives in -/// one place, the consumers of the AST. -fn leading_decorators(stmt: &ast::Stmt) -> Vec { - let mut out = Vec::new(); - let Some(first_token) = stmt.tokens().next() else { - return out; - }; - for trivia in first_token.leading_trivia() { - if let TokenType::SingleLineComment { comment } = trivia.token_type() { - let text = comment.to_string(); - let Some(rest) = text.trim().strip_prefix("-@") else { - continue; // `---@x` tokenizes as comment text `-@x` + /// 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(')')), @@ -362,24 +348,17 @@ fn leading_decorators(stmt: &ast::Stmt) -> Vec { .collect(); out.push(Decorator { name: name.to_string(), args }); } + out.reverse(); + out } - out } -fn string_token_value(token: &TokenReference) -> String { - let text = token.token().to_string(); - let trimmed = text.trim(); - trimmed - .trim_matches('"') - .trim_matches('\'') - .to_string() +struct Decorator { + name: String, + args: Vec, } -fn node_span(node: &N) -> Span { - let start = node - .start_position() - .map(|p| p.bytes()) - .unwrap_or(0); - let end = node.end_position().map(|p| p.bytes()).unwrap_or(start); - (start, end) +fn node_span(node: &N) -> Span { + let range = node.get_range(); + (usize::from(range.start()), usize::from(range.end())) } From 30c7f2484034543a67ba6452e368f909abbc983a Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 22:08:22 -0600 Subject: [PATCH 05/94] =?UTF-8?q?mission-kit:=20serve=20mode=20=E2=80=94?= =?UTF-8?q?=20the=20live=20editor=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language-server role over the filesystem transport (editor_architecture_plan.md: the filesystem is the entire interface): missions/**.lua --watch--> mission_ast.json (file -> UI, generation counter) editor/edits/*.json --apply--> missions/**.lua (UI -> file, span edits) editor/open_request.json --> code -g file:line (mode switch to code) Every write goes through the recognizer first: an edit whose result fails to parse or leaves the mission subset is REJECTED, the file untouched, the reason published in status.json — the grammar is the write gate. Paths are confined to the missions dir. Polling watcher (300ms, mtime+size fingerprint) — no inotify dependency across container mounts. Model grows generation (poll target) and per-trigger line (open-in- editor). just bar::mission-serve builds release and runs on the host (where VS Code lives). 10 tests: 4 new for the write gate — valid span edit applies, grammar-breaking edit rejected byte-identically, parse-breaking edit rejected, path escape rejected. Co-Authored-By: Claude Fable 5 --- bar-mission-kit/src/main.rs | 24 ++- bar-mission-kit/src/model.rs | 4 + bar-mission-kit/src/recognizer.rs | 1 + bar-mission-kit/src/serve.rs | 318 ++++++++++++++++++++++++++++++ just/bar.just | 11 ++ 5 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 bar-mission-kit/src/serve.rs diff --git a/bar-mission-kit/src/main.rs b/bar-mission-kit/src/main.rs index 9006c56a..8d8fa9af 100644 --- a/bar-mission-kit/src/main.rs +++ b/bar-mission-kit/src/main.rs @@ -8,6 +8,7 @@ mod model; mod recognizer; +mod serve; use clap::{Parser, Subcommand}; use std::path::{Path, PathBuf}; @@ -34,6 +35,18 @@ enum Command { 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) + missions_dir: PathBuf, + /// Directory for the artifact + edits/ + open_request.json + #[arg(long)] + editor_dir: PathBuf, + /// Command template for the mode switch to code + #[arg(long, default_value = "code -g {file}:{line}")] + editor_cmd: String, + }, } fn collect_lua_files(paths: &[PathBuf]) -> Vec { @@ -61,9 +74,9 @@ fn display_path(file: &Path, roots: &[PathBuf]) -> String { file.display().to_string() } -fn run(paths: &[PathBuf]) -> (model::MissionAst, Vec) { +pub fn collect_ast(paths: &[PathBuf], generation: u64) -> (model::MissionAst, Vec) { let files = collect_lua_files(paths); - let mut ast = model::MissionAst { version: 1, files: Vec::new() }; + let mut ast = model::MissionAst { version: 1, generation, files: Vec::new() }; let mut findings = Vec::new(); for file in &files { let rel = display_path(file, paths); @@ -97,7 +110,7 @@ fn main() -> ExitCode { let cli = Cli::parse(); match cli.command { Command::Parse { paths, out } => { - let (ast, findings) = run(&paths); + let (ast, findings) = collect_ast(&paths, 1); for f in &findings { eprintln!("{}:{}: {}", f.path, f.line, f.message); } @@ -114,7 +127,7 @@ fn main() -> ExitCode { ExitCode::SUCCESS } Command::Check { paths } => { - let (_ast, findings) = run(&paths); + let (_ast, findings) = collect_ast(&paths, 1); for f in &findings { println!("{}:{}: {}", f.path, f.line, f.message); } @@ -125,6 +138,9 @@ fn main() -> ExitCode { ExitCode::FAILURE } } + Command::Serve { missions_dir, editor_dir, editor_cmd } => { + serve::Server::new(missions_dir, editor_dir, editor_cmd).run() + } } } diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs index 4a9accd6..4bd0a190 100644 --- a/bar-mission-kit/src/model.rs +++ b/bar-mission-kit/src/model.rs @@ -11,6 +11,8 @@ pub type Span = (usize, usize); 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, } @@ -38,6 +40,8 @@ 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, /// From a `---@label("...")` directly above the chain. pub label: Option, pub steps: Vec, diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index 90eeb1ce..cc4a56a3 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -188,6 +188,7 @@ impl<'s> Rec<'s> { Some(Trigger { id: format!("{}:{}", self.path, self.order), span, + line: self.line_of(span.0), label, steps, }) diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs new file mode 100644 index 00000000..5614aff8 --- /dev/null +++ b/bar-mission-kit/src/serve.rs @@ -0,0 +1,318 @@ +//! 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 .lua file stays the source of truth. + +use crate::model::MissionAst; +use crate::recognizer; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +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, +} + +#[derive(Deserialize, Debug)] +pub struct OpenRequest { + pub file: String, + #[serde(default = "one")] + pub line: usize, +} + +fn one() -> usize { + 1 +} + +#[derive(Serialize, Default)] +struct Status { + generation: u64, + ok: bool, + message: String, +} + +pub struct Server { + pub missions_dir: PathBuf, + pub editor_dir: PathBuf, + pub editor_cmd: String, + generation: u64, +} + +impl Server { + pub fn new(missions_dir: PathBuf, editor_dir: PathBuf, editor_cmd: String) -> Self { + Server { missions_dir, editor_dir, editor_cmd, generation: 0 } + } + + 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(); + + let fingerprint = fingerprint_dir(&self.missions_dir); + if fingerprint != last_fingerprint { + last_fingerprint = fingerprint; + self.regenerate(); + } + std::thread::sleep(POLL); + } + } + + fn regenerate(&mut self) { + self.generation += 1; + let (ast, findings) = crate::collect_ast(&[self.missions_dir.clone()], self.generation); + let message = findings + .iter() + .map(|f| format!("{}:{}: {}", f.path, f.line, f.message)) + .collect::>() + .join("\n"); + self.write_ast(&ast); + self.write_status(findings.is_empty(), &message); + 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()); + } + } + + fn write_status(&self, ok: bool, message: &str) { + let status = Status { generation: self.generation, ok, message: message.to_string() }; + 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(); + 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}"))?; + apply_edit(&self.missions_dir, &intent)?; + eprintln!("applied edit to {} [{}..{})", intent.file, intent.start, intent.end); + Ok(()) + } + + fn consume_open_request(&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 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(); + } +} + +/// 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) +} + +/// 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. +pub fn apply_edit(missions_dir: &Path, intent: &EditIntent) -> Result<(), String> { + let path = resolve_mission_file(missions_dir, &intent.file)?; + let source = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + if intent.start > intent.end || intent.end > source.len() { + return Err(format!( + "edit span [{}, {}) out of bounds for {} ({} bytes)", + intent.start, + intent.end, + intent.file, + source.len() + )); + } + let mut edited = String::with_capacity(source.len() + intent.new_text.len()); + edited.push_str(&source[..intent.start]); + edited.push_str(&intent.new_text); + edited.push_str(&source[intent.end..]); + + let recognized = recognizer::recognize_file(&intent.file, &edited) + .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}")); + } + + std::fs::write(&path, edited).map_err(|e| e.to_string()) +} + +/// Cheap change detection: every .lua path + mtime + size, concatenated. +fn fingerprint_dir(dir: &Path) -> String { + let mut entries: Vec = Vec::new(); + let pattern = format!("{}/**/*.lua", dir.display()); + for path in glob::glob(&pattern).into_iter().flatten().flatten() { + 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 setup(dir: &Path) { + std::fs::create_dir_all(dir.join("hello/triggers")).unwrap(); + std::fs::write( + dir.join("hello/triggers/win.lua"), + "T.When(Team.Player.Has(UnitDef(\"armpw\"), 3))\n\t.Do(Objective(\"x\").Complete())\n\t.Register()\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 + } + + #[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(), + }; + 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(), + }; + 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(), + }; + assert!(apply_edit(&dir, &intent).is_err()); + } + + #[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(), + }; + assert!(apply_edit(&dir, &intent).is_err()); + } +} diff --git a/just/bar.just b/just/bar.just index e8bb65bd..0d4541a4 100644 --- a/just/bar.just +++ b/just/bar.just @@ -263,3 +263,14 @@ mission-ast: require-bar 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}}" + exec "$DEVTOOLS_DIR/bar-mission-kit/target/release/bar-mission-kit" serve \ + ${args:-"$BAR_DIR/modules/missions/hello_pawns"} \ + --editor-dir "$BAR_DIR/modules/missions/editor" From 83369c65a3b67f0186ff16e4da46e0d03e10cf97 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 22:49:24 -0600 Subject: [PATCH 06/94] just: mission-serve editor dir lives in the engine write dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget's io writes and VFS reads resolve against the write dir (~/.local/state/Beyond All Reason on Linux), not the repo — serve must meet it there for both the artifact and the intent channel. BAR_WRITE_DIR overrides; repo fallback for headless-style setups. Co-Authored-By: Claude Fable 5 --- just/bar.just | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/just/bar.just b/just/bar.just index 0d4541a4..7f10ae16 100644 --- a/just/bar.just +++ b/just/bar.just @@ -271,6 +271,14 @@ mission-serve *args: require-bar 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 exec "$DEVTOOLS_DIR/bar-mission-kit/target/release/bar-mission-kit" serve \ ${args:-"$BAR_DIR/modules/missions/hello_pawns"} \ - --editor-dir "$BAR_DIR/modules/missions/editor" + --editor-dir "$write_dir/modules/missions/editor" From aa839156ea7fb1bb4715a887604cfb23a08b80aa Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 23:04:04 -0600 Subject: [PATCH 07/94] mission-kit: compare-and-swap precondition on edit intents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode toggle is narrative (who owns the pen); safety is mechanical: FileAst carries an FNV-1a hash of the source it was built from, edit intents send it back as base_hash, and apply_edit refuses to write if the file changed since — the regeneration loop then refreshes the stale view instead of clobbering. VS Code's own dirty-buffer prompt is the third leg. base_hash is optional for tool callers; the form UI will always send it. 11 tests (stale-hash refusal added, byte-identical file preserved). Co-Authored-By: Claude Fable 5 --- bar-mission-kit/src/model.rs | 4 ++++ bar-mission-kit/src/recognizer.rs | 12 +++++++++- bar-mission-kit/src/serve.rs | 37 +++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs index 4bd0a190..3a7416b4 100644 --- a/bar-mission-kit/src/model.rs +++ b/bar-mission-kit/src/model.rs @@ -20,6 +20,10 @@ pub struct MissionAst { 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, /// Sections in file order. Chains before any `---@group` land in an /// unlabeled leading section. pub groups: Vec, diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index cc4a56a3..3be907c2 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -53,7 +53,7 @@ pub fn recognize_file(path: &str, source: &str) -> Result { } Ok(Recognized { - file: FileAst { path: rec.path, groups, opaque: rec.opaque }, + file: FileAst { path: rec.path, hash: fnv1a(source.as_bytes()), groups, opaque: rec.opaque }, findings: rec.findings, }) } @@ -359,6 +359,16 @@ struct Decorator { args: Vec, } +/// 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 index 5614aff8..cf83e09e 100644 --- a/bar-mission-kit/src/serve.rs +++ b/bar-mission-kit/src/serve.rs @@ -25,6 +25,11 @@ pub struct EditIntent { pub start: usize, pub end: usize, pub new_text: String, + /// Hash of the file content the edit was computed against (FileAst.hash). + /// The write is refused if the file changed since — compare-and-swap; + /// the regeneration loop then refreshes the stale view. + #[serde(default)] + pub base_hash: Option, } #[derive(Deserialize, Debug)] @@ -187,6 +192,15 @@ fn resolve_mission_file(missions_dir: &Path, rel: &str) -> Result Result<(), String> { let path = resolve_mission_file(missions_dir, &intent.file)?; let source = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + if let Some(base_hash) = &intent.base_hash { + let current = recognizer::fnv1a(source.as_bytes()); + if ¤t != base_hash { + return Err(format!( + "file changed on disk since the view was built ({}) — refreshing view instead of writing", + intent.file + )); + } + } if intent.start > intent.end || intent.end > source.len() { return Err(format!( "edit span [{}, {}) out of bounds for {} ({} bytes)", @@ -266,6 +280,7 @@ mod tests { 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(); @@ -283,6 +298,7 @@ mod tests { 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}"); @@ -299,10 +315,30 @@ mod tests { start: 0, end: 1, new_text: ")(".into(), + base_hash: None, }; assert!(apply_edit(&dir, &intent).is_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 paths_cannot_escape_the_missions_dir() { let dir = tmpdir("escape"); @@ -312,6 +348,7 @@ mod tests { start: 0, end: 0, new_text: "x".into(), + base_hash: None, }; assert!(apply_edit(&dir, &intent).is_err()); } From 8ececea7aaea0f8fddd17d085a408461f864cbbe Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 23:13:11 -0600 Subject: [PATCH 08/94] mission-kit: trigger-graph artifact per generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mission_graph.dot derived from the AST: triggers as boxes, objectives as ellipses, VICTORY/DEFEAT terminals; edges follow objective state (Complete feeds IsComplete watchers). The editor-plan stage-2 graph as data — renderers attach later without re-deriving. 12 tests. Co-Authored-By: Claude Fable 5 --- bar-mission-kit/src/graph.rs | 114 +++++++++++++++++++++++++++++++++++ bar-mission-kit/src/main.rs | 1 + bar-mission-kit/src/serve.rs | 2 + 3 files changed, 117 insertions(+) create mode 100644 bar-mission-kit/src/graph.rs diff --git a/bar-mission-kit/src/graph.rs b/bar-mission-kit/src/graph.rs new file mode 100644 index 00000000..c21291ab --- /dev/null +++ b/bar-mission-kit/src/graph.rs @@ -0,0 +1,114 @@ +//! 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#" +T.When(Team.Player.Has(UnitDef("armpw"), 3)) + .Do(Objective("build_pawns").Complete()) + .Register() + +T.When(Objective("build_pawns").IsComplete()) + .Do(MatchFlow.Victory(Team.Player)) + .Register() +"#; + let rec = crate::recognizer::recognize_file("triggers/win.lua", source).unwrap(); + let ast = crate::model::MissionAst { version: 1, generation: 1, files: vec![rec.file] }; + 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/main.rs b/bar-mission-kit/src/main.rs index 8d8fa9af..1ef2c786 100644 --- a/bar-mission-kit/src/main.rs +++ b/bar-mission-kit/src/main.rs @@ -6,6 +6,7 @@ //! check — same walk, findings only; nonzero exit on a non-conforming //! mission (CI's validator) +mod graph; mod model; mod recognizer; mod serve; diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs index cf83e09e..d0f93e94 100644 --- a/bar-mission-kit/src/serve.rs +++ b/bar-mission-kit/src/serve.rs @@ -95,6 +95,8 @@ impl Server { .collect::>() .join("\n"); self.write_ast(&ast); + let dot = crate::graph::dot(&ast); + std::fs::write(self.editor_dir.join("mission_graph.dot"), dot).ok(); self.write_status(findings.is_empty(), &message); if findings.is_empty() { eprintln!("[gen {}] AST regenerated", self.generation); From 1ac04dbafedf9214b60bc0f6b3f3f2357a86a2a6 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 23:19:54 -0600 Subject: [PATCH 09/94] mission-kit: domain annotator, surface schema, effect insert points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree walk now knows what things MEAN: UnitDef() string args are stamped unit_def_name, Objective() args objective_name (and collected per file for dropdowns), Has counts count. The artifact embeds the authoring-surface schema (surfaces/missions.json — curated overlay today, the slot annotation-derived data lands in later) and each trigger carries insert_effect_at, the byte offset where an added .Do(...) line belongs. Invocation spans now cover the args list, not the nested prefix chain (emmylua CallExpr includes its prefix — the Register 'call span' was the whole chain). 13 tests. Co-Authored-By: Claude Fable 5 --- bar-mission-kit/src/graph.rs | 7 ++- bar-mission-kit/src/main.rs | 36 +++++++++++- bar-mission-kit/src/model.rs | 14 +++++ bar-mission-kit/src/recognizer.rs | 79 +++++++++++++++++++++++++- bar-mission-kit/surfaces/missions.json | 13 +++++ 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 bar-mission-kit/surfaces/missions.json diff --git a/bar-mission-kit/src/graph.rs b/bar-mission-kit/src/graph.rs index c21291ab..8359bd42 100644 --- a/bar-mission-kit/src/graph.rs +++ b/bar-mission-kit/src/graph.rs @@ -105,7 +105,12 @@ T.When(Objective("build_pawns").IsComplete()) .Register() "#; let rec = crate::recognizer::recognize_file("triggers/win.lua", source).unwrap(); - let ast = crate::model::MissionAst { version: 1, generation: 1, files: vec![rec.file] }; + 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}"); diff --git a/bar-mission-kit/src/main.rs b/bar-mission-kit/src/main.rs index 1ef2c786..35fdbdd7 100644 --- a/bar-mission-kit/src/main.rs +++ b/bar-mission-kit/src/main.rs @@ -75,9 +75,12 @@ fn display_path(file: &Path, roots: &[PathBuf]) -> String { file.display().to_string() } +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); - let mut ast = model::MissionAst { version: 1, generation, files: Vec::new() }; + let surface = serde_json::from_str(MISSION_SURFACE).expect("valid surface overlay"); + let mut ast = model::MissionAst { version: 1, generation, files: Vec::new(), surface }; let mut findings = Vec::new(); for file in &files { let rel = display_path(file, paths); @@ -251,6 +254,37 @@ T.When(C()).Do(E()).Register() ); } + #[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]; + // UnitDef("armpw") arg is unit-typed; the Has count is count-typed + 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:?}"), + } + // insert point sits at the start of the Register line + let at = t1.insert_effect_at; + assert!(WIN[at..].trim_start().starts_with(".Register"), "{}", &WIN[at..at + 20]); + } + #[test] fn missing_register_is_a_finding() { let src = "T.When(C()).Do(E())\n"; diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs index 3a7416b4..cc953e3e 100644 --- a/bar-mission-kit/src/model.rs +++ b/bar-mission-kit/src/model.rs @@ -14,6 +14,9 @@ pub struct MissionAst { /// 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)] @@ -24,6 +27,8 @@ pub struct FileAst { /// 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, /// Sections in file order. Chains before any `---@group` land in an /// unlabeled leading section. pub groups: Vec, @@ -46,6 +51,9 @@ pub struct Trigger { 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 (start of the + /// Register step's line). Insert intents use start == end == this. + pub insert_effect_at: usize, /// From a `---@label("...")` directly above the chain. pub label: Option, pub steps: Vec, @@ -66,10 +74,16 @@ 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, diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index 3be907c2..c84f6d8d 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -52,8 +52,27 @@ pub fn recognize_file(path: &str, source: &str) -> Result { groups.remove(0); } + let mut objectives: Vec = Vec::new(); + for group in &mut groups { + for trigger in &mut group.triggers { + for step in &mut trigger.steps { + for arg in &mut step.args { + annotate(arg, &mut objectives); + } + } + } + } + objectives.sort(); + objectives.dedup(); + Ok(Recognized { - file: FileAst { path: rec.path, hash: fnv1a(source.as_bytes()), groups, opaque: rec.opaque }, + file: FileAst { + path: rec.path, + hash: fnv1a(source.as_bytes()), + objectives, + groups, + opaque: rec.opaque, + }, findings: rec.findings, }) } @@ -185,10 +204,21 @@ impl<'s> Rec<'s> { } self.order += 1; + let insert_effect_at = steps + .last() + .filter(|s| s.verb == "Register") + .map(|s| { + self.source[..s.span.0.min(self.source.len())] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0) + }) + .unwrap_or(span.1); Some(Trigger { id: format!("{}:{}", self.path, self.order), span, line: self.line_of(span.0), + insert_effect_at, label, steps, }) @@ -230,7 +260,12 @@ impl<'s> Rec<'s> { let prefix = call.get_prefix_expr()?; let mut unrolled = self.unroll(&prefix, span)?; let args = self.call_args(call); - let call_span = node_span(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) @@ -262,11 +297,12 @@ impl<'s> Rec<'s> { NumberResult::Float(f) => f, NumberResult::Number => f64::NAN, }; - Value::Number { value, span } + 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(), @@ -359,6 +395,43 @@ struct Decorator { args: Vec, } +/// The domain annotator: stamp literals with what they MEAN, from the verb +/// tree around them. This is where "the tree walk knows that's a unit +/// definition" lives — the UI turns semantics into controls (the game itself +/// supplies the unit list; the server only names the domain). +fn annotate(value: &mut Value, objectives: &mut Vec) { + let Value::Verb { path, calls, .. } = value else { + return; + }; + let first_string_semantic = match path.as_str() { + "UnitDef" => Some("unit_def_name"), + "Objective" => Some("objective_name"), + _ => None, + }; + if let Some(sem) = first_string_semantic { + if let Some(Value::String { value, semantic, .. }) = + calls.first_mut().and_then(|c| c.args.first_mut()) + { + *semantic = Some(sem.to_string()); + if sem == "objective_name" { + objectives.push(value.clone()); + } + } + } + if path.ends_with(".Has") { + if let Some(Value::Number { semantic, .. }) = + calls.first_mut().and_then(|c| c.args.get_mut(1)) + { + *semantic = Some("count".to_string()); + } + } + for call in calls { + for arg in &mut call.args { + annotate(arg, objectives); + } + } +} + /// Stable content hash (FNV-1a 64) for the CAS precondition on edits. pub fn fnv1a(bytes: &[u8]) -> String { let mut hash: u64 = 0xcbf29ce484222325; diff --git a/bar-mission-kit/surfaces/missions.json b/bar-mission-kit/surfaces/missions.json new file mode 100644 index 00000000..f1e07fa6 --- /dev/null +++ b/bar-mission-kit/surfaces/missions.json @@ -0,0 +1,13 @@ +{ + "name": "missions", + "comment": "The mission trigger surface: what the injected env offers. Curated overlay today; fields here get validated against / derived from the LuaCATS annotations when the type-server work lands.", + "conditions": [ + { "label": "Player has units", "template": "Team.Player.Has(UnitDef(\"armpw\"), 3)" }, + { "label": "Objective complete", "template": "Objective(\"OBJECTIVE\").IsComplete()" } + ], + "effects": [ + { "label": "Complete objective", "template": "Objective(\"OBJECTIVE\").Complete()" }, + { "label": "Victory", "template": "MatchFlow.Victory(Team.Player)" }, + { "label": "Defeat", "template": "MatchFlow.Defeat(Team.Player)" } + ] +} From a1c1da9685812187d38c42f2f2e158ed6945bbfb Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 23:43:39 -0600 Subject: [PATCH 10/94] mission-kit: file-level trigger insert point FileAst.insert_trigger_at (EOF) so the form can append whole chains; gate test proves an appended T.When(...).Do(...).Register() statement passes the recognizer and lands as a second trigger. 14 tests. Co-Authored-By: Claude Fable 5 --- bar-mission-kit/src/model.rs | 2 ++ bar-mission-kit/src/recognizer.rs | 1 + bar-mission-kit/src/serve.rs | 20 ++++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs index cc953e3e..002ae707 100644 --- a/bar-mission-kit/src/model.rs +++ b/bar-mission-kit/src/model.rs @@ -29,6 +29,8 @@ pub struct FileAst { pub hash: String, /// Every Objective("name") seen in this file — dropdown fodder. pub objectives: 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, diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index c84f6d8d..660e8b10 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -70,6 +70,7 @@ pub fn recognize_file(path: &str, source: &str) -> Result { path: rec.path, hash: fnv1a(source.as_bytes()), objectives, + insert_trigger_at: source.len(), groups, opaque: rec.opaque, }, diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs index d0f93e94..6890cf05 100644 --- a/bar-mission-kit/src/serve.rs +++ b/bar-mission-kit/src/serve.rs @@ -341,6 +341,26 @@ mod tests { 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: "\nT.When(Objective(\"x\").IsComplete())\n\t.Do(Objective(\"y\").Complete())\n\t.Register()\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); + } + #[test] fn paths_cannot_escape_the_missions_dir() { let dir = tmpdir("escape"); From 14914bf96b71c5230ebf9913acdd6b4a378e9352 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Tue, 21 Jul 2026 23:50:05 -0600 Subject: [PATCH 11/94] mission-kit: removal spans for statements and steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger.remove_span (whole chain's lines) and Step.remove_span (the step's source line) — the spans the form's remove/decomposition controls replace with nothing. The recognizer gate keeps removal legal (deleting the last Do is refused, not silently broken). Co-Authored-By: Claude Fable 5 --- bar-mission-kit/src/model.rs | 8 ++++++++ bar-mission-kit/src/recognizer.rs | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs index 002ae707..bd553792 100644 --- a/bar-mission-kit/src/model.rs +++ b/bar-mission-kit/src/model.rs @@ -56,6 +56,9 @@ pub struct Trigger { /// Byte offset where a new `.Do(...)` line can be inserted (start of the /// Register step's line). Insert intents use start == end == this. pub insert_effect_at: usize, + /// Whole-statement removal span: chain's first line start to past the + /// Register line's newline. Replace with "" to delete the trigger. + pub remove_span: Span, /// From a `---@label("...")` directly above the chain. pub label: Option, pub steps: Vec, @@ -65,8 +68,13 @@ pub struct Trigger { pub struct Step { /// Chain verb: When, AndWhen, Debounce, Once, Do, Register. pub verb: String, + /// 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. diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index 660e8b10..1ba67f61 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -181,7 +181,7 @@ impl<'s> Rec<'s> { } } }; - steps.push(Step { verb, span: invocation.span, args: invocation.args }); + steps.push(Step { verb, span: invocation.span, args: invocation.args, remove_span: (0, 0) }); } // Grammar checks (also the validator's rules). @@ -205,6 +205,9 @@ impl<'s> Rec<'s> { } self.order += 1; + for step in &mut steps { + step.remove_span = line_bounds(self.source, step.span.0, step.span.1); + } let insert_effect_at = steps .last() .filter(|s| s.verb == "Register") @@ -220,6 +223,7 @@ impl<'s> Rec<'s> { span, line: self.line_of(span.0), insert_effect_at, + remove_span: line_bounds(self.source, span.0, span.1), label, steps, }) @@ -396,6 +400,16 @@ struct Decorator { args: Vec, } +/// 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) +} + /// The domain annotator: stamp literals with what they MEAN, from the verb /// tree around them. This is where "the tree walk knows that's a unit /// definition" lives — the UI turns semantics into controls (the game itself From d2febcae02c5a93b5dd79c498d373e7526a7e7c5 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Wed, 22 Jul 2026 00:23:15 -0600 Subject: [PATCH 12/94] mission-kit: condition insert point (AndWhen rows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger.insert_condition_at — past the When line's newline, where an inline '+ and when' adds an .AndWhen(...) row to an existing chain. Co-Authored-By: Claude Fable 5 --- bar-mission-kit/src/model.rs | 3 +++ bar-mission-kit/src/recognizer.rs | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs index bd553792..a210dc7f 100644 --- a/bar-mission-kit/src/model.rs +++ b/bar-mission-kit/src/model.rs @@ -59,6 +59,9 @@ pub struct Trigger { /// Whole-statement removal span: chain's first line start to past the /// Register line's newline. 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, diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index 1ba67f61..1e3f27d1 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -208,6 +208,11 @@ impl<'s> Rec<'s> { 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 == "When") + .map(|s| line_bounds(self.source, s.span.0, s.span.1).1) + .unwrap_or(span.0); let insert_effect_at = steps .last() .filter(|s| s.verb == "Register") @@ -223,6 +228,7 @@ impl<'s> Rec<'s> { 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, From 75b78b816e75a63fd6a1a2970d6eee215c188aa5 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Wed, 22 Jul 2026 10:11:16 -0600 Subject: [PATCH 13/94] =?UTF-8?q?just:=20bar::mission-dev=20=E2=80=94=20se?= =?UTF-8?q?rve=20and=20the=20game=20as=20one=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve belongs to mission-editing sessions, not every launch: the wrapper starts mission-serve in the background, launches the game, and kills serve when the session ends — the start-together die-together lifecycle that prevents the stale-serve panel confusion, without taxing plain bar::launch. Co-Authored-By: Claude Fable 5 --- just/bar.just | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/just/bar.just b/just/bar.just index 7f10ae16..b9c399a7 100644 --- a/just/bar.just +++ b/just/bar.just @@ -282,3 +282,14 @@ mission-serve *args: require-bar exec "$DEVTOOLS_DIR/bar-mission-kit/target/release/bar-mission-kit" serve \ ${args:-"$BAR_DIR/modules/missions/hello_pawns"} \ --editor-dir "$write_dir/modules/missions/editor" + +# 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}} From 3ed3279fd3c992388348d781ce75d75779984d63 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Thu, 23 Jul 2026 14:47:10 -0600 Subject: [PATCH 14/94] mission-kit: the recognizer speaks the terminator-free grammar Chains root at bare When (no T), end at their last call (no Register), and legality means at least one Do. Repeated .When AND-composes as a chain step; a leftover Register gets a named finding pointing at the migration. Effect inserts append past the chain's last line. 15 tests; the live hello_pawns mission checks clean. Co-Authored-By: Claude Fable 5 --- bar-mission-kit/src/graph.rs | 6 ++--- bar-mission-kit/src/main.rs | 32 +++++++++++++---------- bar-mission-kit/src/model.rs | 10 +++---- bar-mission-kit/src/recognizer.rs | 43 +++++++++++++------------------ bar-mission-kit/src/serve.rs | 4 +-- 5 files changed, 45 insertions(+), 50 deletions(-) diff --git a/bar-mission-kit/src/graph.rs b/bar-mission-kit/src/graph.rs index 8359bd42..f8db9505 100644 --- a/bar-mission-kit/src/graph.rs +++ b/bar-mission-kit/src/graph.rs @@ -96,13 +96,11 @@ mod tests { #[test] fn hello_pawns_graph_has_the_objective_cascade() { let source = r#" -T.When(Team.Player.Has(UnitDef("armpw"), 3)) +When(Team.Player.Has(UnitDef("armpw"), 3)) .Do(Objective("build_pawns").Complete()) - .Register() -T.When(Objective("build_pawns").IsComplete()) +When(Objective("build_pawns").IsComplete()) .Do(MatchFlow.Victory(Team.Player)) - .Register() "#; let rec = crate::recognizer::recognize_file("triggers/win.lua", source).unwrap(); let ast = crate::model::MissionAst { diff --git a/bar-mission-kit/src/main.rs b/bar-mission-kit/src/main.rs index 35fdbdd7..018ceca8 100644 --- a/bar-mission-kit/src/main.rs +++ b/bar-mission-kit/src/main.rs @@ -153,13 +153,11 @@ mod tests { use crate::model::Value; const WIN: &str = r#" -T.When(Team.Player.Has(UnitDef("armpw"), 3)) +When(Team.Player.Has(UnitDef("armpw"), 3)) .Do(Objective("build_pawns").Complete()) - .Register() -T.When(Objective("build_pawns").IsComplete()) +When(Objective("build_pawns").IsComplete()) .Do(MatchFlow.Victory(Team.Player)) - .Register() "#; #[test] @@ -171,7 +169,7 @@ T.When(Objective("build_pawns").IsComplete()) 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", "Register"]); + assert_eq!(steps, vec!["When", "Do"]); // When's condition: Team.Player.Has(UnitDef("armpw"), 3) match &triggers[0].steps[0].args[0] { @@ -202,9 +200,8 @@ T.When(Objective("build_pawns").IsComplete()) #[test] fn chained_invocations_survive() { let src = r#" -T.When(Region("north").EnteredBy(Team.Player, { count = 5 })) +When(Region("north").EnteredBy(Team.Player, { count = 5 })) .Do(Wave.Define("flank").Route(Path("east")).Spawn()) - .Register() "#; let rec = crate::recognizer::recognize_file("triggers/w.lua", src).unwrap(); assert!(rec.findings.is_empty(), "findings: {:?}", rec.findings); @@ -222,7 +219,7 @@ T.When(Region("north").EnteredBy(Team.Player, { count = 5 })) #[test] fn function_bodies_are_findings() { - let src = "T.When(C()).Do(function() end).Register()\n"; + let src = "When(C()).Do(function() end)\n"; let rec = crate::recognizer::recognize_file("triggers/bad.lua", src).unwrap(); assert!(rec .findings @@ -243,7 +240,7 @@ T.When(Region("north").EnteredBy(Team.Player, { count = 5 })) let src = r#" ---@group("Waves") ---@label("First blood") -T.When(C()).Do(E()).Register() +When(C()).Do(E()) "#; let rec = crate::recognizer::recognize_file("triggers/d.lua", src).unwrap(); assert_eq!(rec.file.groups.len(), 1); @@ -280,15 +277,22 @@ T.When(C()).Do(E()).Register() } other => panic!("expected Has verb, got {other:?}"), } - // insert point sits at the start of the Register line + // effect insert point appends past the chain's last line let at = t1.insert_effect_at; - assert!(WIN[at..].trim_start().starts_with(".Register"), "{}", &WIN[at..at + 20]); + assert!(WIN[..at].trim_end().ends_with(".Do(Objective(\"build_pawns\").Complete())"), "{}", &WIN[..at]); } #[test] - fn missing_register_is_a_finding() { - let src = "T.When(C()).Do(E())\n"; + 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("Register"))); + 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"))); } } diff --git a/bar-mission-kit/src/model.rs b/bar-mission-kit/src/model.rs index a210dc7f..6af6c1d0 100644 --- a/bar-mission-kit/src/model.rs +++ b/bar-mission-kit/src/model.rs @@ -53,11 +53,11 @@ pub struct Trigger { 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 (start of the - /// Register step's line). Insert intents use start == end == this. + /// 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: chain's first line start to past the - /// Register line's newline. Replace with "" to delete the trigger. + /// 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). @@ -69,7 +69,7 @@ pub struct Trigger { #[derive(Serialize, Debug)] pub struct Step { - /// Chain verb: When, AndWhen, Debounce, Once, Do, Register. + /// Chain verb: When (first and repeated), Do, Once, Debounce. pub verb: String, /// The args list INCLUDING parens — replacing it swaps the step's /// content: new_text = "(" + template + ")". diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index 1e3f27d1..c6694fdf 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -10,9 +10,10 @@ use emmylua_parser::{ LuaStat, LuaTableExpr, ParserConfig, }; -/// Chain verbs the framework grammar admits, in the only legal shape: -/// When first, Register last. -const CHAIN_VERBS: &[&str] = &["When", "AndWhen", "Debounce", "Once", "Do", "Register"]; +/// Chain verbs the framework grammar admits. A statement starts at a bare +/// `When(...)` and ends at its last chained call — no terminator; the chain +/// must contain at least one `Do`. Repeated `.When` AND-composes. +const CHAIN_VERBS: &[&str] = &["When", "Do", "Once", "Debounce"]; pub struct Recognized { pub file: FileAst, @@ -161,17 +162,17 @@ impl<'s> Rec<'s> { label: Option, ) -> Option { let unrolled = self.unroll(&LuaExpr::CallExpr(call.clone()), span)?; - if unrolled.path.len() != 2 || unrolled.path[0] != "T" { - self.mark_opaque(span, "statement is not a T.When trigger chain"); + if unrolled.path.len() != 1 || unrolled.path[0] != "When" { + self.mark_opaque(span, "statement is not a When trigger chain"); return None; } - // The first invocation's verb is the second path segment (T.When); - // later invocations carry their own names. + // The first invocation is the When itself; later invocations carry + // their own names (.When/.Do/.Once). let mut steps = Vec::new(); for (i, invocation) in unrolled.calls.into_iter().enumerate() { let verb = if i == 0 { - unrolled.path[1].clone() + String::from("When") } else { match invocation.name.clone() { Some(name) => name, @@ -184,15 +185,15 @@ impl<'s> Rec<'s> { steps.push(Step { verb, span: invocation.span, args: invocation.args, remove_span: (0, 0) }); } - // Grammar checks (also the validator's rules). - if steps.is_empty() || steps[0].verb != "When" { - self.finding(span, "trigger chain must start with T.When(...)".into()); - } - if steps.last().map(|s| s.verb.as_str()) != Some("Register") { - self.finding(span, "trigger chain must end with .Register()".into()); + // Grammar checks (also the validator's rules). No terminator: the + // chain ends at its last call; legality means at least one Do. + if !steps.iter().any(|s| s.verb == "Do") { + self.finding(span, "trigger chain has no Do — every statement needs at least one effect".into()); } for step in &steps { - if !CHAIN_VERBS.contains(&step.verb.as_str()) { + if step.verb == "Register" { + self.finding(step.span, "Register is gone — chains end at their last Do".into()); + } else if !CHAIN_VERBS.contains(&step.verb.as_str()) { self.finding( step.span, format!( @@ -213,16 +214,8 @@ impl<'s> Rec<'s> { .filter(|s| s.verb == "When") .map(|s| line_bounds(self.source, s.span.0, s.span.1).1) .unwrap_or(span.0); - let insert_effect_at = steps - .last() - .filter(|s| s.verb == "Register") - .map(|s| { - self.source[..s.span.0.min(self.source.len())] - .rfind('\n') - .map(|i| i + 1) - .unwrap_or(0) - }) - .unwrap_or(span.1); + // New .Do 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, diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs index 6890cf05..c96d9307 100644 --- a/bar-mission-kit/src/serve.rs +++ b/bar-mission-kit/src/serve.rs @@ -259,7 +259,7 @@ mod tests { std::fs::create_dir_all(dir.join("hello/triggers")).unwrap(); std::fs::write( dir.join("hello/triggers/win.lua"), - "T.When(Team.Player.Has(UnitDef(\"armpw\"), 3))\n\t.Do(Objective(\"x\").Complete())\n\t.Register()\n", + "When(Team.Player.Has(UnitDef(\"armpw\"), 3))\n\t.Do(Objective(\"x\").Complete())\n", ) .unwrap(); } @@ -351,7 +351,7 @@ mod tests { file: "hello/triggers/win.lua".into(), start: at, end: at, - new_text: "\nT.When(Objective(\"x\").IsComplete())\n\t.Do(Objective(\"y\").Complete())\n\t.Register()\n".into(), + 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(); From 103c14e5cf2c33ffe6bdbb4716ab918fbd688d1d Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Thu, 23 Jul 2026 15:05:07 -0600 Subject: [PATCH 15/94] =?UTF-8?q?scripts:=20mission-stack=20restack=20?= =?UTF-8?q?=E2=80=94=20the=20three-stack=20topology=20as=20one=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit just bar::mission-restack [rebase|iterate|test|push|all]: rebases the mission api stack (hello_pawns -> matchflow_extraction -> bar_editor) from upstream/master with the one known auto-resolution (the extraction layer's matchflow_verdict deletion wins), regenerates modules_iteration as a DERIVED branch (sharing_tab + hello_pawns — rebuilt every time, never hand-edited, the sharing-module philosophy), and gates on the busted suite. Any unexpected conflict aborts loudly. push force-pushes the stack + iteration to upstream. Co-Authored-By: Claude Fable 5 --- just/bar.just | 4 ++ scripts/mission-stack/restack.sh | 109 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100755 scripts/mission-stack/restack.sh diff --git a/just/bar.just b/just/bar.just index b9c399a7..08375bfb 100644 --- a/just/bar.just +++ b/just/bar.just @@ -293,3 +293,7 @@ mission-dev *args: require-bar serve_pid=$! trap 'kill "$serve_pid" 2>/dev/null || true' EXIT just bar::launch {{args}} + +# Keep the mission api stack rebased on master and the iteration merge fresh (restack.sh rebase|iterate|test|push|all) +mission-restack *args: require-bar + @bash "{{DEVTOOLS_DIR}}/scripts/mission-stack/restack.sh" {{args}} diff --git a/scripts/mission-stack/restack.sh b/scripts/mission-stack/restack.sh new file mode 100755 index 00000000..d321ed3f --- /dev/null +++ b/scripts/mission-stack/restack.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Deterministic restack of the mission api stack + the iteration merge. +# +# restack.sh rebase rebase hello_pawns onto upstream/master, then each +# stack layer onto the one below (known auto-resolve: +# matchflow_verdict.lua modify/delete -> keep deletion) +# restack.sh iterate regenerate modules_iteration = sharing_tab (+) hello_pawns +# (the iteration branch is DERIVED - rebuilt, never edited) +# restack.sh test run the busted suite as the gate +# restack.sh push force-push the stack + iteration to upstream +# restack.sh all rebase iterate test +# +# Requires: a clean $BAR_DIR checkout (the script switches branches in it). +# Any conflict other than the known one aborts loudly for manual resolution. +set -euo pipefail + +DEVTOOLS_DIR="${DEVTOOLS_DIR:?DEVTOOLS_DIR must be set}" +BAR_DIR="${BAR_DIR:-$DEVTOOLS_DIR/Beyond-All-Reason}" +source "$DEVTOOLS_DIR/scripts/common.sh" + +BASE="upstream/master" +STACK=(hello_pawns matchflow_extraction bar_editor) +SHARING="sharing_tab" +ITER="modules_iteration" +KNOWN_DELETE="modules/matchflow/gadgets/matchflow_verdict.lua" + +cd "$BAR_DIR" + +require_clean() { + if [ -n "$(git status --porcelain --ignore-submodules | grep -v '^?? ')" ]; then + echo "ERROR: $BAR_DIR checkout is dirty - commit or stash first." >&2 + exit 1 + fi +} + +rebase_one() { + local branch="$1" onto="$2" + step "Rebasing $branch onto $onto..." + git checkout -q "$branch" + if git rebase "$onto" >/dev/null 2>&1; then + ok "$branch rebased clean" + return + fi + # Known resolution: the extraction layer deletes matchflow_verdict.lua; + # anything the lower layers changed in it dies with the file. + while true; do + local unmerged + unmerged="$(git diff --name-only --diff-filter=U)" + if [ "$unmerged" = "$KNOWN_DELETE" ]; then + git rm -q "$KNOWN_DELETE" + if GIT_EDITOR=true git rebase --continue >/dev/null 2>&1; then + ok "$branch rebased (auto-resolved $KNOWN_DELETE deletion)" + return + fi + else + echo "ERROR: unexpected conflict rebasing $branch:" >&2 + git diff --name-only --diff-filter=U >&2 + git rebase --abort + exit 1 + fi + done +} + +cmd_rebase() { + require_clean + git fetch upstream --quiet + rebase_one "${STACK[0]}" "$BASE" + local prev="${STACK[0]}" + for branch in "${STACK[@]:1}"; do + rebase_one "$branch" "$prev" + prev="$branch" + done +} + +cmd_iterate() { + require_clean + step "Regenerating $ITER = $SHARING + ${STACK[0]}..." + git checkout -q -B "$ITER" "$SHARING" + if ! git merge --no-edit "${STACK[0]}" >/dev/null 2>&1; then + echo "ERROR: iteration merge conflicts:" >&2 + git diff --name-only --diff-filter=U >&2 + git merge --abort + exit 1 + fi + ok "$ITER regenerated at $(git rev-parse --short HEAD)" +} + +cmd_test() { + step "Running busted gate..." + lx test + ok "suite green" +} + +cmd_push() { + step "Pushing stack + iteration to upstream (force)..." + git push -f upstream "${STACK[@]}" "$ITER" + ok "pushed" +} + +for cmd in "${@:-all}"; do + case "$cmd" in + rebase) cmd_rebase ;; + iterate) cmd_iterate ;; + test) cmd_test ;; + push) cmd_push ;; + all) cmd_rebase; cmd_iterate; cmd_test ;; + *) echo "usage: restack.sh [rebase|iterate|test|push|all]" >&2; exit 1 ;; + esac +done From 943235fccbf3405cb815222508efef60fa6e905b Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 01:10:17 -0600 Subject: [PATCH 16/94] =?UTF-8?q?mission-kit:=20serve=20renders=20the=20vi?= =?UTF-8?q?ew=20=E2=80=94=20Dioxus=20SSR,=20loopback=20HTTP,=20browser=20t?= =?UTF-8?q?erminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One renderer, three dumb terminals. The view artifact carries the form markup, modal vocabulary, and a live-probe manifest; state.json flows back armed-gated. HTTP is an adapter over the same files. --- bar-mission-kit/Cargo.lock | 558 +++++++++++++++++- bar-mission-kit/Cargo.toml | 2 + bar-mission-kit/src/http.rs | 212 +++++++ bar-mission-kit/src/main.rs | 17 +- bar-mission-kit/src/serve.rs | 99 +++- bar-mission-kit/src/view.rs | 941 ++++++++++++++++++++++++++++++ bar-mission-kit/web/terminal.html | 309 ++++++++++ 7 files changed, 2123 insertions(+), 15 deletions(-) create mode 100644 bar-mission-kit/src/http.rs create mode 100644 bar-mission-kit/src/view.rs create mode 100644 bar-mission-kit/web/terminal.html diff --git a/bar-mission-kit/Cargo.lock b/bar-mission-kit/Cargo.lock index 4d24a88d..0d4ce049 100644 --- a/bar-mission-kit/Cargo.lock +++ b/bar-mission-kit/Cargo.lock @@ -70,11 +70,36 @@ 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", @@ -93,6 +118,12 @@ 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" @@ -103,6 +134,12 @@ dependencies = [ "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" @@ -149,6 +186,36 @@ 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" @@ -180,6 +247,187 @@ 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" @@ -197,12 +445,91 @@ dependencies = [ "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" @@ -228,7 +555,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" dependencies = [ - "bitflags", + "bitflags 1.3.2", "ignore", "walkdir", ] @@ -251,6 +578,12 @@ 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" @@ -298,18 +631,69 @@ 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" @@ -325,6 +709,15 @@ 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" @@ -337,6 +730,55 @@ 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" @@ -346,6 +788,18 @@ 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" @@ -355,6 +809,15 @@ 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" @@ -477,6 +940,12 @@ 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" @@ -548,6 +1017,22 @@ 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" @@ -635,6 +1120,37 @@ 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" @@ -652,6 +1168,18 @@ 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" @@ -664,6 +1192,12 @@ 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" @@ -674,6 +1208,28 @@ dependencies = [ "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" diff --git a/bar-mission-kit/Cargo.toml b/bar-mission-kit/Cargo.toml index 5d3ee4cc..eb78033f 100644 --- a/bar-mission-kit/Cargo.toml +++ b/bar-mission-kit/Cargo.toml @@ -9,3 +9,5 @@ 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/src/http.rs b/bar-mission-kit/src/http.rs new file mode 100644 index 00000000..3df8f092 --- /dev/null +++ b/bar-mission-kit/src/http.rs @@ -0,0 +1,212 @@ +//! 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 /view -> mission_view.json +//! GET /status -> status.json +//! POST /edit -> /edits/http__.json (validated shape) +//! POST /open -> /open_request.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); + +/// Bind and serve on a background thread. Returns the bound port (for tests: +/// pass port 0 to get an ephemeral one). +pub fn spawn(addr: &str, editor_dir: PathBuf) -> Option { + let listener = match TcpListener::bind(addr) { + Ok(l) => l, + Err(e) => { + eprintln!("http: cannot bind {addr}: {e}"); + return None; + } + }; + let port = listener.local_addr().ok()?.port(); + 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); + }); + } + }); + Some(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"), + ), + ("GET", "/view") => respond_file(&mut stream, editor_dir.join("mission_view.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), + ("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}") + } + _ => 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 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 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 index 018ceca8..00cc3eff 100644 --- a/bar-mission-kit/src/main.rs +++ b/bar-mission-kit/src/main.rs @@ -7,9 +7,11 @@ //! mission (CI's validator) mod graph; +mod http; mod model; mod recognizer; mod serve; +mod view; use clap::{Parser, Subcommand}; use std::path::{Path, PathBuf}; @@ -44,9 +46,15 @@ enum Command { /// Directory for the artifact + edits/ + open_request.json #[arg(long)] editor_dir: PathBuf, - /// Command template for the mode switch to code - #[arg(long, default_value = "code -g {file}:{line}")] + /// 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, }, } @@ -75,7 +83,7 @@ fn display_path(file: &Path, roots: &[PathBuf]) -> String { file.display().to_string() } -const MISSION_SURFACE: &str = include_str!("../surfaces/missions.json"); +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); @@ -142,7 +150,8 @@ fn main() -> ExitCode { ExitCode::FAILURE } } - Command::Serve { missions_dir, editor_dir, editor_cmd } => { + Command::Serve { missions_dir, editor_dir, editor_cmd, listen } => { + http::spawn(&listen, editor_dir.clone()); serve::Server::new(missions_dir, editor_dir, editor_cmd).run() } } diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs index c96d9307..93bdd186 100644 --- a/bar-mission-kit/src/serve.rs +++ b/bar-mission-kit/src/serve.rs @@ -11,6 +11,7 @@ use crate::model::MissionAst; use crate::recognizer; +use crate::view; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; @@ -48,6 +49,9 @@ 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, } pub struct Server { @@ -55,11 +59,12 @@ pub struct Server { pub editor_dir: PathBuf, pub editor_cmd: String, generation: u64, + open_seq: u64, } impl Server { pub fn new(missions_dir: PathBuf, editor_dir: PathBuf, editor_cmd: String) -> Self { - Server { missions_dir, editor_dir, editor_cmd, generation: 0 } + Server { missions_dir, editor_dir, editor_cmd, generation: 0, open_seq: 0 } } pub fn run(&mut self) -> ! { @@ -77,7 +82,12 @@ impl Server { self.consume_edits(); self.consume_open_request(); - let fingerprint = fingerprint_dir(&self.missions_dir); + // domains.json (client-published dropdown data) re-renders too + let fingerprint = format!( + "{}\n{}", + fingerprint_dir(&self.missions_dir), + file_stamp(&self.editor_dir.join("domains.json")) + ); if fingerprint != last_fingerprint { last_fingerprint = fingerprint; self.regenerate(); @@ -95,6 +105,7 @@ impl Server { .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(findings.is_empty(), &message); @@ -113,8 +124,27 @@ impl Server { } } + fn write_view(&self, ast: &MissionAst) { + let domains: 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(); + let artifact = view::render(ast, &domains); + 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) { - let status = Status { generation: self.generation, ok, message: message.to_string() }; + 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 }; let json = serde_json::to_string_pretty(&status).expect("serializable status"); std::fs::write(self.editor_dir.join("status.json"), json).ok(); } @@ -150,7 +180,11 @@ impl Server { Ok(()) } - fn consume_open_request(&self) { + /// 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; @@ -167,12 +201,23 @@ impl Server { eprintln!("open request outside missions dir: {}", request.file); return; }; - 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(); + let file = file.canonicalize().unwrap_or(file); + self.open_seq += 1; + let target = serde_json::json!({ + "seq": self.open_seq, + "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(); + } } } @@ -232,6 +277,18 @@ pub fn apply_edit(missions_dir: &Path, intent: &EditIntent) -> Result<(), String std::fs::write(&path, edited).map_err(|e| e.to_string()) } +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(); @@ -361,6 +418,28 @@ mod tests { assert_eq!(rec.file.groups[0].triggers.len(), 2); } + #[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()); + 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 paths_cannot_escape_the_missions_dir() { let dir = tmpdir("escape"); diff --git a/bar-mission-kit/src/view.rs b/bar-mission-kit/src/view.rs new file mode 100644 index 00000000..b9df03c9 --- /dev/null +++ b/bar-mission-kit/src/view.rs @@ -0,0 +1,941 @@ +//! 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, Clone)] +pub struct DomainOption { + pub value: String, + pub label: String, +} + +/// 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, +} + +/// 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, +} + +#[derive(Serialize)] +pub struct Modals { + pub add_step: Modal, + pub add_statement: 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 Surface { + #[serde(default)] + conditions: Vec, + #[serde(default)] + effects: Vec, +} + +#[derive(Deserialize, Clone)] +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>, +} + +pub fn render(ast: &MissionAst, domains: &Domains) -> ViewArtifact { + let surface: Surface = serde_json::from_value(ast.surface.clone()).unwrap_or_default(); + let live = std::cell::RefCell::new(Vec::new()); + let mission = xmlize(&dioxus_ssr::render_element(body(ast, &surface, domains, true, &live))); + let billboard = xmlize(&dioxus_ssr::render_element(body(ast, &surface, domains, false, &live))); + let nouns = xmlize(&dioxus_ssr::render_element(nouns_body(ast, domains, &live))); + + let files = ast.files.len(); + let objective_count = ast + .files + .iter() + .flat_map(|f| f.objectives.iter()) + .collect::>() + .len(); + let unit_count = live.borrow().iter().filter(|p| p.kind == "unit_count").count(); + let form = [ + section("mission", "Mission Editor", &format!("{files} file{}", plural(files)), false, &mission), + section( + "nouns", + "Nouns", + &format!( + "{objective_count} objective{} · {unit_count} unit{}", + plural(objective_count), + plural(unit_count) + ), + false, + &nouns, + ), + ] + .concat(); + + ViewArtifact { + generation: ast.generation, + first_file: ast.files.first().map(|f| f.path.clone()), + form, + billboard, + modals: modals(&surface), + live: live.into_inner(), + } +} + +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +/// 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, unit, and trigger the mission +/// mentions, wired to the same live keys the form chips use. Trigger rows +/// are open-in-editor jumps (data-open-*). +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()), + }); + } + } + } + let units: Vec = live + .borrow() + .iter() + .filter(|p| p.kind == "unit_count") + .cloned() + .collect(); + 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}", "–" } + } + } + } +} + +fn body<'a>( + ast: &'a MissionAst, + surface: &'a Surface, + domains: &'a Domains, + editable: bool, + live: &'a std::cell::RefCell>, +) -> Element { + let style = if editable { Style::Ui } else { Style::Dsl }; + rsx! { + for file in ast.files.iter() { + {file_view(file, &Ctx { + file: &file.path, + hash: &file.hash, + editable, + style, + surface, + domains, + live, + })} + } + } +} + +fn file_view(file: &FileAst, ctx: &Ctx) -> Element { + rsx! { + div { class: "me-file", "{file.path}" } + 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)} + } + } + if ctx.editable && !ctx.surface.conditions.is_empty() { + 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 { + let title = trigger.label.clone().unwrap_or_else(|| trigger.id.clone()); + rsx! { + div { class: "me-card", + div { class: "me-card-head", + span { + class: "me-card-title me-jump", + "data-open-file": "{ctx.file}", + "data-open-line": "{trigger.line}", + "{title}" + } + 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)} + } + if ctx.editable && !(ctx.surface.conditions.is_empty() && ctx.surface.effects.is_empty()) { + div { class: "me-add-row", + button { + class: "me-button me-add-btn", + "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}", + "+ add" + } + } + } + } + } +} + +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! { + div { class: "me-step", + span { class: "me-step-verb me-verb-{badge}", "{verb}" } + span { class: "me-step-body", + {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. +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.Victory" => Some("victory for the player team"), + "MatchFlow.Defeat" => Some("defeat for the player team"), + _ => 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, + }) + } + _ => 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()), + }), + _ => None, + } + } + _ => None, + } +} + +fn phrase_view(phrase: &'static str, value: &Value, ctx: &Ctx) -> Element { + enum Seg<'p> { + Text(&'p str), + Slot(&'p str), + } + 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)); + } + rsx! { + for seg in segs.into_iter() { + match seg { + Seg::Text(text) => rsx! { "{text}" }, + Seg::Slot(semantic) => slot_view(value, semantic, ctx), + } + } + } +} + +fn slot_view(value: &Value, semantic: &str, ctx: &Ctx) -> Element { + 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}" } +} + +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, .. } => rsx! { + span { class: "me-verb", "{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() { + 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 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_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 }, + 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)) +"#; + + fn ast() -> MissionAst { + let rec = crate::recognizer::recognize_file("triggers/win.lua", WIN).unwrap(); + MissionAst { + version: 1, + generation: 7, + files: vec![rec.file], + surface: serde_json::from_str(crate::MISSION_SURFACE).unwrap(), + } + } + + 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()); + 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.
+
+ +
+ + + + From 950885415cc2ec6b612366ec035d41ffe6e96b6e Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 01:10:17 -0600 Subject: [PATCH 17/94] =?UTF-8?q?mission-kit:=20vscode=20terminal=20?= =?UTF-8?q?=E2=80=94=20sidebar=20iframe,=20LS=20diagnostics,=20window-rout?= =?UTF-8?q?ed=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bar-mission-kit/vscode/README.md | 17 +++ .../vscode/bar-mission-editor-0.1.0.vsix | Bin 0 -> 4452 bytes bar-mission-kit/vscode/extension.js | 117 ++++++++++++++++++ bar-mission-kit/vscode/media/icon.svg | 5 + bar-mission-kit/vscode/package.json | 54 ++++++++ 5 files changed, 193 insertions(+) create mode 100644 bar-mission-kit/vscode/README.md create mode 100644 bar-mission-kit/vscode/bar-mission-editor-0.1.0.vsix create mode 100644 bar-mission-kit/vscode/extension.js create mode 100644 bar-mission-kit/vscode/media/icon.svg create mode 100644 bar-mission-kit/vscode/package.json diff --git a/bar-mission-kit/vscode/README.md b/bar-mission-kit/vscode/README.md new file mode 100644 index 00000000..522034c0 --- /dev/null +++ b/bar-mission-kit/vscode/README.md @@ -0,0 +1,17 @@ +# BAR Mission Editor (VS Code) + +A terminal for the `bar-mission-kit serve` view artifact: the whole form is +rendered server-side; this extension injects it into a webview and posts edit +intents back over loopback HTTP. The `.lua` file stays the source of truth. + +## Run + +```bash +just bar::mission-serve # or bar::mission-dev with the game +code --extensionDevelopmentPath="$PWD/bar-mission-kit/vscode" +``` + +Then `Ctrl+Shift+P` → **BAR: Open Mission Editor**. + +Unit dropdowns fill in once the game has published `domains.json` +(automatic when the game runs with the mission bridge widget). diff --git a/bar-mission-kit/vscode/bar-mission-editor-0.1.0.vsix b/bar-mission-kit/vscode/bar-mission-editor-0.1.0.vsix new file mode 100644 index 0000000000000000000000000000000000000000..39b92cef2c4f5bedc6ba71f706f77eb76411b70d GIT binary patch literal 4452 zcmaJ^cT^MK)(s^Aq$w>B5K#y{0R^d|NR=SHcLD?mfdHY4bQF*lYG@LgAPT575vkHd zigct`rAf6=q$oUWFmcYvIMCAOv3T)nmVS(- zVX3p@`hnES*$bf&wyM>NoqU5KZ?#YgH zfwU%OLA^Yb(91Z}^WNgDbdsO==9zu`uu>> zyi8tSkM1W5CG@d5Vl+~?PX&X?R0yE$G%N}XMd|Z%hzll=pfr)IX9)S zq1&6G%FP3z+LkKxc;|B`B%t9wN2md!YBaby*9P?)~yhCeO-W9AJ zJoIFK%x*=6o_}$UmAGc^RIo^=e~;?ua!mY81pEa!Y=qN`f0QjXH%=yLS!Tm57-@7& zy<9PrM>fU&+Sz`QxfPQXlGv#2Oqg|va!M0m%GU)kF38J;`n&h08E{(cEn80;*3jSG z`7W#_>1cFC5p0DxDbBFqH)e~rA@vsMt`D5t?0RdPFXwbYtF*hIS9bB9a)I#rW<<|e z^Cf3^uXm8mAwrfO(cI#_fxtF@T0SvAju*6yU5wt03wkPsqdT%M%JjjR5%zYEUFboG z!dWm?+d54}xt#Pov1b#<6Ry1U2CzQ5FP(k-c!%u{9(h3b(3D)q+B~vI4ma|#bF6tg z^%$i67kinR7-oA;S({A)05G1eW{R%ZQ~r8d>HE2%JS^b8F3tgc&6+KuG@#iZ*_Yli zmkMy4RdDfPts@b!euPbhx#xYLo?F*pD~iHZH=YpWI814t*dU3HL-7{Rm=iTB_#{`K z3jY8HqL$tYFzj+qc25ib9e-(8 z#0_bSK{}w|P9Cnz>$}jMSe=W-m#3CmM468?#T{>HwIuDV z39>^c<~9T@U-D7m&Scb_B*X+E+uyyfkQw3k>0K9N;f8|w1ae9Ubytj9$V>OKaZ6=X??{LeF zVw_cFbMAap#I-&fNrOUMv+QYUe>oP3{Qba57AP z5)c{iCLmFtjDyZasCUD%;9~#Lx049_?3S66?+{CzZB61<-MrxnkkxW71MQgnVvAg` zS3;MqOP#E0;JN)l$u~gCm`s#>AM7mY<(8i51^G2{V8~ugO~$i_|lMR6`sAz*_DNd z8lqm6a7cWtMVmY6-7P`g429T4yzv@(^@osr#vYDZ21BzuqS1o#1A|yg|3lCR5 zh-J~1&yshShIE1+ZQkxcbW1osOD@?j9F3WsckKCUEMZl}e=%1oqbW=&h^JBKqVzBpJj z*Pa&`y$0);qljw}Dy^Jr0%6*RhEfYfB5{ITp{E8JndS`xp>R$_ap) zQP+9L=}athA{!6Qi(bG|+NvC`eU#8-;Crpr!u5d~Nf)FV4crIDaF z`;fs#BGT5TFn?1vS@O2t4Nk4^0_KO`4iB|zmq`S%GAOspaS8(XmCl<>o}@qQ*ftb1 z)?b!|$UmLNy)yAt&0QM}%>>bkitgpAnhZqx@HfjerUsi0$!;Ajp-DGkh>kF(FcPeu z@h(d;lWYf#CF^|^ws5pWNFLH0!l&mELf($RT=8wbib`;W?8GhOYt)R4A7xJ<)*g3k zbB3Abqg#8W9}GLj_l-{*7i~4T%07Saru_CStH!5DRZ&}kFQ=#38GQZOE>SysKJ_%? zrdNo{pa44Z>xZVAdk~kgn0H&`d08CCDU|18m!g74&k{M9#JmW> zDiatrhw1Kc3AK7jMD6joVyE+%6qvUvU|D{>Owr&1spKAs<^$48V=pm7{oOQFLCJ}4 z_}cVZIyzC&vDwVm!hSOh(01kw|XIx<0i~Yn_^^PPnkS~9bFaE{JL`w@bG?LlB zC#h3LyG0GfKBT9rSTyV}yxv@ANXE34IL3<^3%HxD>E7Q)*?0s3Q)M*+{kAVlXR6Rj z`nnQ}%UyL$%h$(V4&LH<6`Vm8)_M=rv1Uuc5TRK4EPc|tWElU(@62&f;{9f+c0He7 zlaZ9p4vLpglN@HOrVjU3rO#flDw~{f8|HcnZ!w5Yy=m*>qhhpE9a$D>RUr4ey?k_X zqt{ZPxyxcvh4~RI?+j*y+TY%jsz_Jun#G;9jUFTV>4+nSm?^50ol3&}Vc&Kr^s^`1 zN5b5@^Hzzn0wo4jvd&kw0I)%JIM}TrJkB7+w&)9#rmnn)6yE#(q&yrH1)N411#u4hx z2nVXmbLT8?Y=v2sAJYeJJqYThvD^r<1vv%2KTyKg5)!+57&gwzvO?^mmXX%&Juq!C zb;+@c0H_Dy_FY!_P36K3M?uZ@q88IVOk04$^*kzKlH1&qPE6A4Rq4xPe6QCTt}*-GjaS@- zwL+p8o|Bf;T&2=K6MLu%$uiDmCQ|TZElJNw>?R##tgvxo1QdXeyR2k!8H;4$F?Z9^M zmx;VLE17~J5do~#Pga5UzMI!SOlAb(Z#fK9E}hW*Y;V4mp8c610AQc)S9|;AdOjz` z`}v;Ej25TO@8{h3yQzubP)Iu$6x_wmBH<=>LiDWi?zJ}6N#*&bchkyXO^3oAltCna^9@PlTy1kY(DL2l+LJk=MFo z$?_+0JG69($OLV}>(9m+U)-?`)8%~J+9dwQVIl09iyR?N2a?gmQUcdIlPr)GT&GL- zm6OHf=1j~$Nz=-$DuEA8o#dR{21O$RIgD3}S-4zcFJelw=3-uBU_2+{{bCcAR(dD>$h#+xvt?{OVgLxK^=fsM| zvFR<#lJv6%sF6BXiQ!Usytp>Eg?eZ8^nAoqpNVJjK495;cF*}T-iu4$6Byo6boH#O zFNRHF)ejH08M{6n*}rTriW_JH!wL61h>YwCsqnXHo~8<9nt1&|6Z(Fk+6E_MZea3F z)Awr8`3S+Os!a`3S99Aej$ZwZq~orARSttsTL{*o3GA6&_J8J(_oaW&A*_FGlncrZ zjTAxKo;p>ot)^vcK(v+sMwQ1hy7!kpshLlVNc*_SNbgyo!d@{usC zhrmHZ`c+MILPndU zD>zfdNP1guuqg(<-~USelfzCe7`EZl!c|Y%s;y2!dV}o0d-v1V{y!>1elEW`{g;#f zcZ`2`Q2#;z04hToPPOqL{M0XiUnb!REB(~`4i)}z<&Y!EaWHE{`}KFsrD-hLWrm5$L~*z{7pP< Vb>L}=008LpN vscode.workspace.getConfiguration("barMissionEditor").get("serverUrl"); + + diagnostics = vscode.languages.createDiagnosticCollection("bar-mission-kit"); + context.subscriptions.push(diagnostics); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider("barMissionEditor.form", { + resolveWebviewView(view) { + view.webview.options = { enableScripts: true }; + view.webview.html = frame(serverUrl()); + }, + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("barMissionEditor.open", () => + vscode.commands.executeCommand("barMissionEditor.form.focus") + ) + ); + + const timer = setInterval(() => { + const server = serverUrl(); + pollDiagnostics(server); + pollOpenTarget(server); + }, 1500); + context.subscriptions.push({ dispose: () => clearInterval(timer) }); +} + +// Window routing: serve publishes a sequenced open target; every window's +// extension polls it, but only the window whose workspace contains the file +// acts. First sighting is recorded, not acted on — a freshly opened window +// must not jump to a stale target. +let lastOpenSeq = null; + +async function pollOpenTarget(server) { + let target; + try { + const response = await fetch(server + "/open_request"); + if (!response.ok) return; + target = await response.json(); + } catch { + return; + } + if (typeof target.seq !== "number") return; + if (lastOpenSeq === null) { + lastOpenSeq = target.seq; + return; + } + if (target.seq === lastOpenSeq) return; + lastOpenSeq = target.seq; + const uri = vscode.Uri.file(target.file); + if (!vscode.workspace.getWorkspaceFolder(uri)) return; // another window's project + const row = Math.max(0, (target.line || 1) - 1); + vscode.window.showTextDocument(uri, { + preview: false, + selection: new vscode.Range(row, 0, row, 0), + }); +} + +function frame(server) { + return ` + + + + +`; +} + +async function pollDiagnostics(server) { + let status; + try { + const response = await fetch(server + "/status"); + if (!response.ok) return; + status = await response.json(); + } catch { + return; // serve not running; keep whatever was last shown + } + diagnostics.clear(); + if (status.ok || !status.missions_dir) { + return; + } + const byFile = new Map(); + for (const line of String(status.message).split("\n")) { + // Recognizer findings are `rel/path.lua:line: message`; other status + // text (rejected edits) doesn't match and stays out of the editor. + const match = line.match(/^(.*?\.lua):(\d+): (.*)$/); + if (!match) continue; + const [, rel, lineNumber, message] = match; + const row = Math.max(0, Number(lineNumber) - 1); + const uri = vscode.Uri.joinPath(vscode.Uri.file(status.missions_dir), rel); + const diagnostic = new vscode.Diagnostic( + new vscode.Range(row, 0, row, 999), + message, + vscode.DiagnosticSeverity.Error + ); + diagnostic.source = "bar-mission-kit"; + const key = uri.toString(); + if (!byFile.has(key)) byFile.set(key, { uri, list: [] }); + byFile.get(key).list.push(diagnostic); + } + for (const { uri, list } of byFile.values()) { + diagnostics.set(uri, list); + } +} + +function deactivate() {} + +module.exports = { activate, deactivate }; diff --git a/bar-mission-kit/vscode/media/icon.svg b/bar-mission-kit/vscode/media/icon.svg new file mode 100644 index 00000000..ed9c01d0 --- /dev/null +++ b/bar-mission-kit/vscode/media/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/bar-mission-kit/vscode/package.json b/bar-mission-kit/vscode/package.json new file mode 100644 index 00000000..cb3bea1d --- /dev/null +++ b/bar-mission-kit/vscode/package.json @@ -0,0 +1,54 @@ +{ + "name": "bar-mission-editor", + "displayName": "BAR Campaign Editor", + "description": "Campaign editor for BAR missions, rendered by bar-mission-kit serve; the .lua files are the source of truth", + "version": "0.1.0", + "publisher": "beyond-all-reason", + "license": "GPL-2.0-or-later", + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Other" + ], + "main": "./extension.js", + "activationEvents": [ + "onStartupFinished" + ], + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "barMission", + "title": "Campaign", + "icon": "media/icon.svg" + } + ] + }, + "views": { + "barMission": [ + { + "type": "webview", + "id": "barMissionEditor.form", + "name": "Campaign Editor" + } + ] + }, + "commands": [ + { + "command": "barMissionEditor.open", + "title": "BAR: Open Campaign Editor" + } + ], + "configuration": { + "title": "BAR Mission Editor", + "properties": { + "barMissionEditor.serverUrl": { + "type": "string", + "default": "http://127.0.0.1:8571", + "description": "bar-mission-kit serve HTTP address (its --listen flag)" + } + } + } + } +} \ No newline at end of file From 0872096a905f82cb6e779b78777605eaa3d8c482 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 01:10:17 -0600 Subject: [PATCH 18/94] editor env: devtools-root emmyrc scope + bar::mission-logs --- .emmyrc.json | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++ just/bar.just | 7 ++++ 2 files changed, 121 insertions(+) create mode 100644 .emmyrc.json 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/just/bar.just b/just/bar.just index 08375bfb..c3dc51e9 100644 --- a/just/bar.just +++ b/just/bar.just @@ -283,6 +283,13 @@ mission-serve *args: require-bar ${args:-"$BAR_DIR/modules/missions/hello_pawns"} \ --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 From 62270762fa2de81b692f70a5d94ad47910d6575c Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 02:06:20 -0600 Subject: [PATCH 19/94] =?UTF-8?q?scripts:=20mission-stack=20restack=20?= =?UTF-8?q?=E2=80=94=20iteration=20branch=20retires,=20sharing-modules=20i?= =?UTF-8?q?s=20the=20tip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/mission-stack/restack.sh | 42 +++++++++++++------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/scripts/mission-stack/restack.sh b/scripts/mission-stack/restack.sh index d321ed3f..024f4078 100755 --- a/scripts/mission-stack/restack.sh +++ b/scripts/mission-stack/restack.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash -# Deterministic restack of the mission api stack + the iteration merge. +# Deterministic restack of the mission api stack. # # restack.sh rebase rebase hello_pawns onto upstream/master, then each # stack layer onto the one below (known auto-resolve: # matchflow_verdict.lua modify/delete -> keep deletion) -# restack.sh iterate regenerate modules_iteration = sharing_tab (+) hello_pawns -# (the iteration branch is DERIVED - rebuilt, never edited) # restack.sh test run the busted suite as the gate -# restack.sh push force-push the stack + iteration to upstream -# restack.sh all rebase iterate test +# restack.sh push force-push the stack + sharing-modules to upstream +# restack.sh all rebase test +# +# sharing-modules is a sibling stack pinned to the fmt substrate ($SHARING_BASE, +# fmt-llm tip); it does not restack until bar-fmt lands upstream, then it rebases +# onto hello_pawns and joins STACK. # # Requires: a clean $BAR_DIR checkout (the script switches branches in it). # Any conflict other than the known one aborts loudly for manual resolution. @@ -20,8 +22,8 @@ source "$DEVTOOLS_DIR/scripts/common.sh" BASE="upstream/master" STACK=(hello_pawns matchflow_extraction bar_editor) -SHARING="sharing_tab" -ITER="modules_iteration" +SHARING="sharing-modules" +SHARING_BASE="570ed55650cc0e3f810010ca095935781226cdd5" KNOWN_DELETE="modules/matchflow/gadgets/matchflow_verdict.lua" cd "$BAR_DIR" @@ -72,19 +74,6 @@ cmd_rebase() { done } -cmd_iterate() { - require_clean - step "Regenerating $ITER = $SHARING + ${STACK[0]}..." - git checkout -q -B "$ITER" "$SHARING" - if ! git merge --no-edit "${STACK[0]}" >/dev/null 2>&1; then - echo "ERROR: iteration merge conflicts:" >&2 - git diff --name-only --diff-filter=U >&2 - git merge --abort - exit 1 - fi - ok "$ITER regenerated at $(git rev-parse --short HEAD)" -} - cmd_test() { step "Running busted gate..." lx test @@ -92,18 +81,21 @@ cmd_test() { } cmd_push() { - step "Pushing stack + iteration to upstream (force)..." - git push -f upstream "${STACK[@]}" "$ITER" + if [ "$(git merge-base "$SHARING_BASE" "$SHARING")" != "$SHARING_BASE" ]; then + echo "ERROR: $SHARING is no longer based on $SHARING_BASE - update SHARING_BASE first." >&2 + exit 1 + fi + step "Pushing stack + $SHARING to upstream (force)..." + git push -f upstream "${STACK[@]}" "$SHARING" ok "pushed" } for cmd in "${@:-all}"; do case "$cmd" in rebase) cmd_rebase ;; - iterate) cmd_iterate ;; test) cmd_test ;; push) cmd_push ;; - all) cmd_rebase; cmd_iterate; cmd_test ;; - *) echo "usage: restack.sh [rebase|iterate|test|push|all]" >&2; exit 1 ;; + all) cmd_rebase; cmd_test ;; + *) echo "usage: restack.sh [rebase|test|push|all]" >&2; exit 1 ;; esac done From 85d9527485e4ec640a75ed8e5185ccba36a91125 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 02:38:59 -0600 Subject: [PATCH 20/94] =?UTF-8?q?mission-kit:=20the=20vocabulary=20rides?= =?UTF-8?q?=20the=20artifact=20=E2=80=94=20DSL=20completion=20for=20trigge?= =?UTF-8?q?r=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bar-mission-kit/.gitignore | 1 + bar-mission-kit/src/view.rs | 41 +++++++++++++++++++---- bar-mission-kit/vscode/extension.js | 51 +++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/bar-mission-kit/.gitignore b/bar-mission-kit/.gitignore index ea8c4bf7..f9170169 100644 --- a/bar-mission-kit/.gitignore +++ b/bar-mission-kit/.gitignore @@ -1 +1,2 @@ /target +*.vsix diff --git a/bar-mission-kit/src/view.rs b/bar-mission-kit/src/view.rs index b9df03c9..e255c11d 100644 --- a/bar-mission-kit/src/view.rs +++ b/bar-mission-kit/src/view.rs @@ -18,7 +18,7 @@ pub struct Domains { pub units: Vec, } -#[derive(Deserialize, Clone)] +#[derive(Deserialize, Serialize, Clone)] pub struct DomainOption { pub value: String, pub label: String, @@ -40,6 +40,17 @@ pub struct ViewArtifact { /// 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, } /// One thing the game should sample. `key` matches a data-live attribute in @@ -94,8 +105,8 @@ struct Surface { effects: Vec, } -#[derive(Deserialize, Clone)] -struct SurfaceEntry { +#[derive(Deserialize, Serialize, Clone)] +pub struct SurfaceEntry { label: String, template: String, } @@ -124,13 +135,21 @@ pub fn render(ast: &MissionAst, domains: &Domains) -> ViewArtifact { let nouns = xmlize(&dioxus_ssr::render_element(nouns_body(ast, domains, &live))); let files = ast.files.len(); - let objective_count = ast + let objectives: Vec = ast .files .iter() - .flat_map(|f| f.objectives.iter()) + .flat_map(|f| f.objectives.iter().cloned()) .collect::>() - .len(); + .into_iter() + .collect(); + let objective_count = objectives.len(); let unit_count = live.borrow().iter().filter(|p| p.kind == "unit_count").count(); + let vocabulary = Vocabulary { + conditions: surface.conditions.clone(), + effects: surface.effects.clone(), + objectives, + units: domains.units.clone(), + }; let form = [ section("mission", "Mission Editor", &format!("{files} file{}", plural(files)), false, &mission), section( @@ -154,6 +173,7 @@ pub fn render(ast: &MissionAst, domains: &Domains) -> ViewArtifact { billboard, modals: modals(&surface), live: live.into_inner(), + vocabulary, } } @@ -917,6 +937,15 @@ When(Objective("build_pawns").IsComplete()) assert!(view.form.contains("value=\"armpw\" selected=\"true\""), "{}", view.form); } + #[test] + fn the_vocabulary_rides_the_artifact_for_editor_completion() { + let view = render(&ast(), &domains()); + assert_eq!(view.vocabulary.conditions.len(), 2); + assert_eq!(view.vocabulary.effects.len(), 3); + assert_eq!(view.vocabulary.objectives, vec!["build_pawns".to_string()]); + assert_eq!(view.vocabulary.units.len(), 2); + } + #[test] fn modal_rows_carry_composed_edits() { let view = render(&ast(), &domains()); diff --git a/bar-mission-kit/vscode/extension.js b/bar-mission-kit/vscode/extension.js index 7cb1846b..e86c08a6 100644 --- a/bar-mission-kit/vscode/extension.js +++ b/bar-mission-kit/vscode/extension.js @@ -33,6 +33,57 @@ function activate(context) { pollOpenTarget(server); }, 1500); context.subscriptions.push({ dispose: () => clearInterval(timer) }); + + // Trigger files are DSL documents, not Lua project members: their + // completion comes from serve's vocabulary (surface + objectives + + // domains), ranked above whatever a Lua LS thinks the world contains. + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { language: "lua", pattern: "**/modules/missions/**/triggers/**" }, + { + async provideCompletionItems() { + const vocab = await vocabulary(serverUrl()); + if (!vocab) return []; + const items = []; + const add = (label, insert, kind, sort, detail) => { + const item = new vscode.CompletionItem(label, kind); + item.insertText = insert; + item.sortText = sort; + item.detail = detail; + items.push(item); + }; + const chain = new vscode.CompletionItem("When … Do …", vscode.CompletionItemKind.Snippet); + chain.insertText = new vscode.SnippetString("When(${1})\n\t.Do(${2})"); + chain.sortText = "00"; + chain.detail = "new trigger chain"; + items.push(chain); + vocab.conditions.forEach((c, i) => + add(`WHEN · ${c.label}`, c.template, vscode.CompletionItemKind.Event, `01${i}`, c.template)); + vocab.effects.forEach((e, i) => + add(`DO · ${e.label}`, e.template, vscode.CompletionItemKind.Method, `02${i}`, e.template)); + vocab.objectives.forEach((o, i) => + add(o, o, vscode.CompletionItemKind.EnumMember, `03${i}`, "objective")); + vocab.units.forEach((u, i) => + add(u.label, u.value, vscode.CompletionItemKind.Constant, `04${String(i).padStart(3, "0")}`, "unit def")); + return items; + }, + } + ) + ); +} + +let vocabCache = { at: 0, value: null }; +async function vocabulary(server) { + if (Date.now() - vocabCache.at < 10000 && vocabCache.value) return vocabCache.value; + try { + const response = await fetch(server + "/view"); + if (!response.ok) return vocabCache.value; + const view = await response.json(); + vocabCache = { at: Date.now(), value: view.vocabulary || null }; + } catch { + // serve down: keep whatever we had + } + return vocabCache.value; } // Window routing: serve publishes a sequenced open target; every window's From 6f07922d79ce8053b167556132ca43b94b77a8e8 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 02:44:56 -0600 Subject: [PATCH 21/94] mission-kit: the vsix is a build artifact, not a tracked file --- .../vscode/bar-mission-editor-0.1.0.vsix | Bin 4452 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 bar-mission-kit/vscode/bar-mission-editor-0.1.0.vsix diff --git a/bar-mission-kit/vscode/bar-mission-editor-0.1.0.vsix b/bar-mission-kit/vscode/bar-mission-editor-0.1.0.vsix deleted file mode 100644 index 39b92cef2c4f5bedc6ba71f706f77eb76411b70d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4452 zcmaJ^cT^MK)(s^Aq$w>B5K#y{0R^d|NR=SHcLD?mfdHY4bQF*lYG@LgAPT575vkHd zigct`rAf6=q$oUWFmcYvIMCAOv3T)nmVS(- zVX3p@`hnES*$bf&wyM>NoqU5KZ?#YgH zfwU%OLA^Yb(91Z}^WNgDbdsO==9zu`uu>> zyi8tSkM1W5CG@d5Vl+~?PX&X?R0yE$G%N}XMd|Z%hzll=pfr)IX9)S zq1&6G%FP3z+LkKxc;|B`B%t9wN2md!YBaby*9P?)~yhCeO-W9AJ zJoIFK%x*=6o_}$UmAGc^RIo^=e~;?ua!mY81pEa!Y=qN`f0QjXH%=yLS!Tm57-@7& zy<9PrM>fU&+Sz`QxfPQXlGv#2Oqg|va!M0m%GU)kF38J;`n&h08E{(cEn80;*3jSG z`7W#_>1cFC5p0DxDbBFqH)e~rA@vsMt`D5t?0RdPFXwbYtF*hIS9bB9a)I#rW<<|e z^Cf3^uXm8mAwrfO(cI#_fxtF@T0SvAju*6yU5wt03wkPsqdT%M%JjjR5%zYEUFboG z!dWm?+d54}xt#Pov1b#<6Ry1U2CzQ5FP(k-c!%u{9(h3b(3D)q+B~vI4ma|#bF6tg z^%$i67kinR7-oA;S({A)05G1eW{R%ZQ~r8d>HE2%JS^b8F3tgc&6+KuG@#iZ*_Yli zmkMy4RdDfPts@b!euPbhx#xYLo?F*pD~iHZH=YpWI814t*dU3HL-7{Rm=iTB_#{`K z3jY8HqL$tYFzj+qc25ib9e-(8 z#0_bSK{}w|P9Cnz>$}jMSe=W-m#3CmM468?#T{>HwIuDV z39>^c<~9T@U-D7m&Scb_B*X+E+uyyfkQw3k>0K9N;f8|w1ae9Ubytj9$V>OKaZ6=X??{LeF zVw_cFbMAap#I-&fNrOUMv+QYUe>oP3{Qba57AP z5)c{iCLmFtjDyZasCUD%;9~#Lx049_?3S66?+{CzZB61<-MrxnkkxW71MQgnVvAg` zS3;MqOP#E0;JN)l$u~gCm`s#>AM7mY<(8i51^G2{V8~ugO~$i_|lMR6`sAz*_DNd z8lqm6a7cWtMVmY6-7P`g429T4yzv@(^@osr#vYDZ21BzuqS1o#1A|yg|3lCR5 zh-J~1&yshShIE1+ZQkxcbW1osOD@?j9F3WsckKCUEMZl}e=%1oqbW=&h^JBKqVzBpJj z*Pa&`y$0);qljw}Dy^Jr0%6*RhEfYfB5{ITp{E8JndS`xp>R$_ap) zQP+9L=}athA{!6Qi(bG|+NvC`eU#8-;Crpr!u5d~Nf)FV4crIDaF z`;fs#BGT5TFn?1vS@O2t4Nk4^0_KO`4iB|zmq`S%GAOspaS8(XmCl<>o}@qQ*ftb1 z)?b!|$UmLNy)yAt&0QM}%>>bkitgpAnhZqx@HfjerUsi0$!;Ajp-DGkh>kF(FcPeu z@h(d;lWYf#CF^|^ws5pWNFLH0!l&mELf($RT=8wbib`;W?8GhOYt)R4A7xJ<)*g3k zbB3Abqg#8W9}GLj_l-{*7i~4T%07Saru_CStH!5DRZ&}kFQ=#38GQZOE>SysKJ_%? zrdNo{pa44Z>xZVAdk~kgn0H&`d08CCDU|18m!g74&k{M9#JmW> zDiatrhw1Kc3AK7jMD6joVyE+%6qvUvU|D{>Owr&1spKAs<^$48V=pm7{oOQFLCJ}4 z_}cVZIyzC&vDwVm!hSOh(01kw|XIx<0i~Yn_^^PPnkS~9bFaE{JL`w@bG?LlB zC#h3LyG0GfKBT9rSTyV}yxv@ANXE34IL3<^3%HxD>E7Q)*?0s3Q)M*+{kAVlXR6Rj z`nnQ}%UyL$%h$(V4&LH<6`Vm8)_M=rv1Uuc5TRK4EPc|tWElU(@62&f;{9f+c0He7 zlaZ9p4vLpglN@HOrVjU3rO#flDw~{f8|HcnZ!w5Yy=m*>qhhpE9a$D>RUr4ey?k_X zqt{ZPxyxcvh4~RI?+j*y+TY%jsz_Jun#G;9jUFTV>4+nSm?^50ol3&}Vc&Kr^s^`1 zN5b5@^Hzzn0wo4jvd&kw0I)%JIM}TrJkB7+w&)9#rmnn)6yE#(q&yrH1)N411#u4hx z2nVXmbLT8?Y=v2sAJYeJJqYThvD^r<1vv%2KTyKg5)!+57&gwzvO?^mmXX%&Juq!C zb;+@c0H_Dy_FY!_P36K3M?uZ@q88IVOk04$^*kzKlH1&qPE6A4Rq4xPe6QCTt}*-GjaS@- zwL+p8o|Bf;T&2=K6MLu%$uiDmCQ|TZElJNw>?R##tgvxo1QdXeyR2k!8H;4$F?Z9^M zmx;VLE17~J5do~#Pga5UzMI!SOlAb(Z#fK9E}hW*Y;V4mp8c610AQc)S9|;AdOjz` z`}v;Ej25TO@8{h3yQzubP)Iu$6x_wmBH<=>LiDWi?zJ}6N#*&bchkyXO^3oAltCna^9@PlTy1kY(DL2l+LJk=MFo z$?_+0JG69($OLV}>(9m+U)-?`)8%~J+9dwQVIl09iyR?N2a?gmQUcdIlPr)GT&GL- zm6OHf=1j~$Nz=-$DuEA8o#dR{21O$RIgD3}S-4zcFJelw=3-uBU_2+{{bCcAR(dD>$h#+xvt?{OVgLxK^=fsM| zvFR<#lJv6%sF6BXiQ!Usytp>Eg?eZ8^nAoqpNVJjK495;cF*}T-iu4$6Byo6boH#O zFNRHF)ejH08M{6n*}rTriW_JH!wL61h>YwCsqnXHo~8<9nt1&|6Z(Fk+6E_MZea3F z)Awr8`3S+Os!a`3S99Aej$ZwZq~orARSttsTL{*o3GA6&_J8J(_oaW&A*_FGlncrZ zjTAxKo;p>ot)^vcK(v+sMwQ1hy7!kpshLlVNc*_SNbgyo!d@{usC zhrmHZ`c+MILPndU zD>zfdNP1guuqg(<-~USelfzCe7`EZl!c|Y%s;y2!dV}o0d-v1V{y!>1elEW`{g;#f zcZ`2`Q2#;z04hToPPOqL{M0XiUnb!REB(~`4i)}z<&Y!EaWHE{`}KFsrD-hLWrm5$L~*z{7pP< Vb>L}=008LpN Date: Fri, 24 Jul 2026 02:52:48 -0600 Subject: [PATCH 22/94] mission-kit: serve follows the armed mission (--missions-root) --- bar-mission-kit/src/main.rs | 17 +++++++-- bar-mission-kit/src/serve.rs | 73 +++++++++++++++++++++++++++++++++--- just/bar.just | 9 ++++- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/bar-mission-kit/src/main.rs b/bar-mission-kit/src/main.rs index 00cc3eff..ddf40452 100644 --- a/bar-mission-kit/src/main.rs +++ b/bar-mission-kit/src/main.rs @@ -41,8 +41,13 @@ enum Command { /// 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) - missions_dir: PathBuf, + /// 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, @@ -150,9 +155,13 @@ fn main() -> ExitCode { ExitCode::FAILURE } } - Command::Serve { missions_dir, editor_dir, editor_cmd, listen } => { + 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; + }; http::spawn(&listen, editor_dir.clone()); - serve::Server::new(missions_dir, editor_dir, editor_cmd).run() + serve::Server::new(initial, editor_dir, editor_cmd, missions_root).run() } } } diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs index 93bdd186..4ee95ff0 100644 --- a/bar-mission-kit/src/serve.rs +++ b/bar-mission-kit/src/serve.rs @@ -58,13 +58,49 @@ 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, } impl Server { - pub fn new(missions_dir: PathBuf, editor_dir: PathBuf, editor_cmd: String) -> Self { - Server { missions_dir, editor_dir, editor_cmd, generation: 0, open_seq: 0 } + 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 } + } + + /// 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. + 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; + }; + 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 name.is_empty() + || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return; + } + let candidate = root.join(name); + if candidate.is_dir() && candidate != self.missions_dir { + eprintln!("following armed mission: {name}"); + self.missions_dir = candidate; + } } pub fn run(&mut self) -> ! { @@ -82,9 +118,12 @@ impl Server { self.consume_edits(); self.consume_open_request(); - // domains.json (client-published dropdown data) re-renders too + self.follow_active(); + // domains.json (client-published dropdown data) re-renders too; + // the dir itself is part of the print so re-scoping regenerates. let fingerprint = format!( - "{}\n{}", + "{}\n{}\n{}", + self.missions_dir.display(), fingerprint_dir(&self.missions_dir), file_stamp(&self.editor_dir.join("domains.json")) ); @@ -423,7 +462,7 @@ mod tests { let dir = tmpdir("open"); setup(&dir); let editor = tmpdir("open-editor"); - let mut server = Server::new(dir.clone(), editor.clone(), String::new()); + 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}", @@ -440,6 +479,30 @@ mod tests { 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")); + + // path-shaped names are refused; scope stays where it was + std::fs::write(editor.join("active_mission.json"), "{\"name\":\"../../etc\"}").unwrap(); + server.follow_active(); + assert_eq!(server.missions_dir, root.join("beta")); + + // without --missions-root nothing moves + 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 paths_cannot_escape_the_missions_dir() { let dir = tmpdir("escape"); diff --git a/just/bar.just b/just/bar.just index c3dc51e9..06d3424c 100644 --- a/just/bar.just +++ b/just/bar.just @@ -279,8 +279,15 @@ mission-serve *args: require-bar 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 \ - ${args:-"$BAR_DIR/modules/missions/hello_pawns"} \ + "$@" \ --editor-dir "$write_dir/modules/missions/editor" # Tail the game side of the mission editor (bridge, terminal, loader) from infolog From 69ca6d2f7f1d7124df4677fc23c37718376fa58a Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 12:53:48 -0600 Subject: [PATCH 23/94] =?UTF-8?q?scripts:=20mission-stack=20restack=20?= =?UTF-8?q?=E2=80=94=20sharing-modules=20is=20a=20sibling=20on=20the=20hel?= =?UTF-8?q?lo=5Fpawns=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/mission-stack/restack.sh | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/mission-stack/restack.sh b/scripts/mission-stack/restack.sh index 024f4078..36bf66d0 100755 --- a/scripts/mission-stack/restack.sh +++ b/scripts/mission-stack/restack.sh @@ -2,15 +2,14 @@ # Deterministic restack of the mission api stack. # # restack.sh rebase rebase hello_pawns onto upstream/master, then each -# stack layer onto the one below (known auto-resolve: +# branch onto its parent (known auto-resolve: # matchflow_verdict.lua modify/delete -> keep deletion) # restack.sh test run the busted suite as the gate # restack.sh push force-push the stack + sharing-modules to upstream # restack.sh all rebase test # -# sharing-modules is a sibling stack pinned to the fmt substrate ($SHARING_BASE, -# fmt-llm tip); it does not restack until bar-fmt lands upstream, then it rebases -# onto hello_pawns and joins STACK. +# Topology: hello_pawns carries the module foundation (mode_builder); on it, +# matchflow_extraction -> bar_editor and sharing-modules are sibling stacks. # # Requires: a clean $BAR_DIR checkout (the script switches branches in it). # Any conflict other than the known one aborts loudly for manual resolution. @@ -23,7 +22,6 @@ source "$DEVTOOLS_DIR/scripts/common.sh" BASE="upstream/master" STACK=(hello_pawns matchflow_extraction bar_editor) SHARING="sharing-modules" -SHARING_BASE="570ed55650cc0e3f810010ca095935781226cdd5" KNOWN_DELETE="modules/matchflow/gadgets/matchflow_verdict.lua" cd "$BAR_DIR" @@ -72,6 +70,7 @@ cmd_rebase() { rebase_one "$branch" "$prev" prev="$branch" done + rebase_one "$SHARING" "${STACK[0]}" } cmd_test() { @@ -81,8 +80,8 @@ cmd_test() { } cmd_push() { - if [ "$(git merge-base "$SHARING_BASE" "$SHARING")" != "$SHARING_BASE" ]; then - echo "ERROR: $SHARING is no longer based on $SHARING_BASE - update SHARING_BASE first." >&2 + if [ "$(git merge-base "${STACK[0]}" "$SHARING")" != "$(git rev-parse "${STACK[0]}")" ]; then + echo "ERROR: $SHARING is not based on ${STACK[0]} - run restack.sh rebase first." >&2 exit 1 fi step "Pushing stack + $SHARING to upstream (force)..." From 006e263df5d05e06c10f071ecae6dbb353e769aa Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 13:08:37 -0600 Subject: [PATCH 24/94] =?UTF-8?q?mission-kit:=20a=20mission=20is=20its=20t?= =?UTF-8?q?riggers=20dir=20=E2=80=94=20root=20mode=20stops=20recognizing?= =?UTF-8?q?=20module=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bar-mission-kit/src/main.rs | 4 +++- bar-mission-kit/src/recognizer.rs | 2 +- bar-mission-kit/src/serve.rs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bar-mission-kit/src/main.rs b/bar-mission-kit/src/main.rs index ddf40452..7e4cc7d3 100644 --- a/bar-mission-kit/src/main.rs +++ b/bar-mission-kit/src/main.rs @@ -67,7 +67,9 @@ fn collect_lua_files(paths: &[PathBuf]) -> Vec { let mut files = Vec::new(); for path in paths { if path.is_dir() { - let pattern = format!("{}/**/*.lua", path.display()); + // The loader's contract: a mission IS its triggers/ dir. Scanning + // wider (missions root mode) must not recognize lib/gadget code. + let pattern = format!("{}/**/triggers/*.lua", path.display()); for entry in glob::glob(&pattern).expect("valid glob").flatten() { files.push(entry); } diff --git a/bar-mission-kit/src/recognizer.rs b/bar-mission-kit/src/recognizer.rs index c6694fdf..14605661 100644 --- a/bar-mission-kit/src/recognizer.rs +++ b/bar-mission-kit/src/recognizer.rs @@ -147,7 +147,7 @@ impl<'s> Rec<'s> { _ => { self.mark_opaque( span, - "trigger files contain only T.When chains (closure-free surface)", + "trigger files contain only When chains (closure-free surface)", ); } } diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs index 4ee95ff0..69bddbc2 100644 --- a/bar-mission-kit/src/serve.rs +++ b/bar-mission-kit/src/serve.rs @@ -331,7 +331,7 @@ fn file_stamp(path: &Path) -> String { /// Cheap change detection: every .lua path + mtime + size, concatenated. fn fingerprint_dir(dir: &Path) -> String { let mut entries: Vec = Vec::new(); - let pattern = format!("{}/**/*.lua", dir.display()); + let pattern = format!("{}/**/triggers/*.lua", dir.display()); for path in glob::glob(&pattern).into_iter().flatten().flatten() { let meta = std::fs::metadata(&path).ok(); let mtime = meta From ae54a02f6ca327628efb46b8ccb6169e44dfaee5 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 20:17:32 -0600 Subject: [PATCH 25/94] =?UTF-8?q?settings:=20read-only=20allowlist=20?= =?UTF-8?q?=E2=80=94=20analyzer,=20lint,=20format=20checks=20stop=20prompt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 *)" ] } } From 9c1c345a93a1051db7828c0d14fee6eb73f44b54 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Fri, 24 Jul 2026 20:17:32 -0600 Subject: [PATCH 26/94] mission-kit: terminal page polish rides in git, not just the binary --- bar-mission-kit/web/terminal.html | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/bar-mission-kit/web/terminal.html b/bar-mission-kit/web/terminal.html index 35781998..bbf6d593 100644 --- a/bar-mission-kit/web/terminal.html +++ b/bar-mission-kit/web/terminal.html @@ -59,10 +59,17 @@ .me-modal-row:hover { background: #2c4158; color: #fff; } #hint { color: #55677a; } #footer { margin-top: 10px; padding-top: 8px; border-top: 1px solid #1d2938; font-size: 0.7em; color: #4c5d70; } - .me-live { margin-left: 8px; padding: 1px 8px; border-radius: 9px; font-size: 0.72em; white-space: nowrap; - background: #16283c; border: 1px solid #33517a; color: #8fbcff; } - .me-live.done { background: #12301c; border-color: #2e6b40; color: #7fd79a; } - body.live-stale .me-live { opacity: 0.35; } + .me-live { margin-left: 8px; padding: 2px 9px; border-radius: 9px; font-size: 0.8em; font-weight: 600; + white-space: nowrap; background: #1b3a5e; border: 1px solid #4f8ce0; color: #b8d6ff; + box-shadow: 0 0 6px rgba(79,140,224,0.35); } + .me-live.done { background: #17452a; border-color: #3fae60; color: #9df0b6; + box-shadow: 0 0 6px rgba(127,215,154,0.4); } + .me-live.tick { animation: me-live-tick 0.6s ease-out; } + @keyframes me-live-tick { + 0% { box-shadow: 0 0 0 3px rgba(143,188,255,0.6); } + 100% { box-shadow: 0 0 6px rgba(79,140,224,0.35); } + } + body.live-stale .me-live { opacity: 0.35; box-shadow: none; } /* Final shape: one flat card, collapsible sections. */ .me-section { border-top: 1px solid #1d2938; margin-top: 4px; } @@ -184,10 +191,15 @@

Campaign Editor

for (const el of document.querySelectorAll("[data-live]")) { const value = state.values && state.values[el.dataset.live]; if (!value) continue; - el.textContent = value.text; + if (el.textContent !== value.text) { + el.textContent = value.text; + el.classList.remove("tick"); + void el.offsetWidth; // restart the animation on rapid updates + el.classList.add("tick"); + } el.classList.toggle("done", value.state === "done"); if (typeof value.pct === "number") { - const fill = value.state === "done" ? "rgba(127,215,154,0.28)" : "rgba(79,140,224,0.22)"; + const fill = value.state === "done" ? "rgba(127,215,154,0.5)" : "rgba(79,140,224,0.45)"; el.style.background = `linear-gradient(90deg, ${fill} ${value.pct * 100}%, transparent 0)`; } } From e87b09852a339efcf5cf47f132b8d511ca5bc2b8 Mon Sep 17 00:00:00 2001 From: Keith Harvey Date: Sat, 25 Jul 2026 03:43:10 -0600 Subject: [PATCH 27/94] =?UTF-8?q?mission-kit:=20the=20crumb=20navigates=20?= =?UTF-8?q?missions=20=E2=80=94=20arming=20is=20an=20event,=20not=20a=20st?= =?UTF-8?q?anding=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bar-mission-kit/src/http.rs | 31 +++++- bar-mission-kit/src/serve.rs | 166 ++++++++++++++++++++++++++++-- bar-mission-kit/src/view.rs | 93 +++++++++++++++-- bar-mission-kit/web/terminal.html | 30 ++++++ 4 files changed, 298 insertions(+), 22 deletions(-) diff --git a/bar-mission-kit/src/http.rs b/bar-mission-kit/src/http.rs index 3df8f092..312b6d6f 100644 --- a/bar-mission-kit/src/http.rs +++ b/bar-mission-kit/src/http.rs @@ -3,10 +3,11 @@ //! the game uses. No shared state with the serve loop — the regeneration //! cycle is the confirmation, same as every other client. //! -//! GET /view -> mission_view.json -//! GET /status -> status.json -//! POST /edit -> /edits/http__.json (validated shape) -//! POST /open -> /open_request.json +//! GET /view -> mission_view.json +//! GET /status -> status.json +//! POST /edit -> /edits/http__.json (validated shape) +//! POST /open -> /open_request.json +//! POST /select_mission -> /select_mission.json use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -86,6 +87,13 @@ fn handle(mut stream: TcpStream, editor_dir: PathBuf) -> std::io::Result<()> { 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"), } } @@ -202,6 +210,21 @@ mod tests { 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"); diff --git a/bar-mission-kit/src/serve.rs b/bar-mission-kit/src/serve.rs index 69bddbc2..afb3f87e 100644 --- a/bar-mission-kit/src/serve.rs +++ b/bar-mission-kit/src/serve.rs @@ -40,6 +40,13 @@ pub struct OpenRequest { 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 } @@ -63,6 +70,10 @@ pub struct Server { 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, } impl Server { @@ -72,12 +83,20 @@ impl Server { editor_cmd: String, missions_root: Option, ) -> Self { - Server { missions_dir, editor_dir, editor_cmd, missions_root, generation: 0, open_seq: 0 } + Server { + missions_dir, + editor_dir, + editor_cmd, + missions_root, + generation: 0, + open_seq: 0, + last_active: String::new(), + } } /// 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. + /// 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; @@ -85,15 +104,17 @@ impl Server { 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 name.is_empty() - || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { + if !valid_mission_name(name) { return; } let candidate = root.join(name); @@ -103,6 +124,60 @@ impl Server { } } + /// 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(); @@ -118,14 +193,17 @@ impl Server { self.consume_edits(); self.consume_open_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. + // 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{}\n{}\n{}", self.missions_dir.display(), fingerprint_dir(&self.missions_dir), - file_stamp(&self.editor_dir.join("domains.json")) + file_stamp(&self.editor_dir.join("domains.json")), + self.list_missions().join(",") ); if fingerprint != last_fingerprint { last_fingerprint = fingerprint; @@ -168,7 +246,15 @@ impl Server { .ok() .and_then(|text| serde_json::from_str(&text).ok()) .unwrap_or_default(); - let artifact = view::render(ast, &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) { @@ -260,6 +346,10 @@ impl Server { } } +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('/') { @@ -503,6 +593,64 @@ mod tests { 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())); + + // arming re-scopes (a change from nothing) + std::fs::write(editor.join("active_mission.json"), "{\"name\":\"beta\"}").unwrap(); + server.follow_active(); + assert_eq!(server.missions_dir, root.join("beta")); + + // a crumb pick wins... + 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()); + + // ...and the STANDING arm file does not yank it back + server.follow_active(); + assert_eq!(server.missions_dir, root.join("alpha")); + + // a NEW arming event wins the tick again + std::fs::write(editor.join("active_mission.json"), "{\"name\":\"beta\",\"t\":2}").unwrap(); + server.follow_active(); + assert_eq!(server.missions_dir, root.join("beta")); + + // path-shaped names are refused + std::fs::write(editor.join("select_mission.json"), "{\"name\":\"../../etc\"}").unwrap(); + server.consume_select_mission(); + assert_eq!(server.missions_dir, root.join("beta")); + + // pinned serve (no --missions-root) ignores selections, consumes the file + 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(); // no triggers -> not a mission + 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"); diff --git a/bar-mission-kit/src/view.rs b/bar-mission-kit/src/view.rs index e255c11d..24c1dffd 100644 --- a/bar-mission-kit/src/view.rs +++ b/bar-mission-kit/src/view.rs @@ -24,6 +24,15 @@ pub struct DomainOption { 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)] @@ -127,7 +136,7 @@ struct Ctx<'a> { live: &'a std::cell::RefCell>, } -pub fn render(ast: &MissionAst, domains: &Domains) -> ViewArtifact { +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 mission = xmlize(&dioxus_ssr::render_element(body(ast, &surface, domains, true, &live))); @@ -151,7 +160,13 @@ pub fn render(ast: &MissionAst, domains: &Domains) -> ViewArtifact { units: domains.units.clone(), }; let form = [ - section("mission", "Mission Editor", &format!("{files} file{}", plural(files)), false, &mission), + section( + "mission", + "Mission Editor", + &format!("{files} file{}", plural(files)), + false, + &format!("{}{mission}", crumb(scope)), + ), section( "nouns", "Nouns", @@ -181,6 +196,38 @@ 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}\ + \ + {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. @@ -868,7 +915,7 @@ When(Objective("build_pawns").IsComplete()) #[test] fn the_form_is_wellformed_xml_with_editable_controls() { - let view = render(&ast(), &domains()); + let view = render(&ast(), &domains(), &Scope::default()); assert_wellformed(&view.form); assert!(!view.form.contains("