Add Rust: minimax, runner, and codelens highlighter

This commit is contained in:
2025-11-01 17:17:13 -07:00
committed by Mark
parent 3494003683
commit 19f523d0ed
24 changed files with 3420 additions and 0 deletions

12
build.sh Normal file
View File

@@ -0,0 +1,12 @@
set -e
cd rust/rhai-codemirror
wasm-pack build --target web --out-dir "../../webui/src/wasm/rhai-codemirror";
cd ../..
cd rust/runner
wasm-pack build --target web --out-dir "../../webui/src/wasm/runner";
cd ../..
cd webui
bun install

615
rust/Cargo.lock generated Normal file
View File

@@ -0,0 +1,615 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if 1.0.4",
"const-random",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if 1.0.4",
"wasm-bindgen",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if 1.0.4",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if 1.0.4",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "itertools"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "memory_units"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3"
[[package]]
name = "minimax"
version = "0.1.0"
dependencies = [
"anyhow",
"itertools",
"parking_lot",
"rand",
"rhai",
"wasm-bindgen",
"web-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.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
dependencies = [
"portable-atomic",
]
[[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 1.0.4",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rhai"
version = "1.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527390cc333a8d2cd8237890e15c36518c26f8b54c903d86fc59f42f08d25594"
dependencies = [
"ahash",
"bitflags",
"getrandom",
"instant",
"num-traits",
"once_cell",
"rhai_codegen",
"smallvec",
"smartstring",
"thin-vec",
]
[[package]]
name = "rhai-codemirror"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
"rhai",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"web-sys",
"wee_alloc",
]
[[package]]
name = "rhai_codegen"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "runner"
version = "0.1.0"
dependencies = [
"anyhow",
"console_error_panic_hook",
"getrandom",
"itertools",
"js-sys",
"minimax",
"rand",
"rhai",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"web-sys",
"wee_alloc",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[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.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3b4c031cd0d9014307d82b8abf653c0290fbdaeb4c02d00c63cf52f728628bf"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "2.0.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thin-vec"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d"
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "unicode-ident"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
dependencies = [
"cfg-if 1.0.4",
"once_cell",
"rustversion",
"serde",
"serde_json",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wee_alloc"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e"
dependencies = [
"cfg-if 0.1.10",
"libc",
"memory_units",
"winapi",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "zerocopy"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

41
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,41 @@
[profile.release]
lto = true
codegen-units = 1
opt-level = 's'
[workspace]
members = ["runner", "minimax", "rhai-codemirror"]
resolver = "2"
[workspace.dependencies]
minimax = { path = "./minimax" }
serde = { version = "1.0", features = ["derive"] }
rand = { version = "0.8.5", features = ["alloc", "small_rng"] }
anyhow = "1.0.80"
itertools = "0.12.1"
rhai = { version = "1.23.4", default-features = false, features = [
"no_time",
"no_module",
"no_custom_syntax",
"only_i64",
"f32_float",
] }
# js ffi
getrandom = "0.2"
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"console",
"Document",
"Element",
"HtmlElement",
"Window",
"Performance",
] }
console_error_panic_hook = "0.1"
wee_alloc = "0.4"
serde-wasm-bindgen = "0.4"
parking_lot = "0.12.5"

17
rust/minimax/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "minimax"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
itertools = { workspace = true }
rand = { workspace = true }
rhai = { workspace = true }
parking_lot = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
rhai = { workspace = true, features = ["wasm-bindgen"] }
web-sys = { workspace = true }
wasm-bindgen = { workspace = true }

View File

@@ -0,0 +1,18 @@
mod rhai;
pub use rhai::RhaiAgent;
use crate::game::{Board, PlayerAction};
pub trait Agent {
type ErrorType;
/// This agent's name
fn name(&self) -> &'static str;
/// Try to minimize the value of a board.
fn step_min(&mut self, board: &Board) -> Result<PlayerAction, Self::ErrorType>;
/// Try to maximize the value of a board.
fn step_max(&mut self, board: &Board) -> Result<PlayerAction, Self::ErrorType>;
}

View File

@@ -0,0 +1,308 @@
use anyhow::Result;
use itertools::Itertools;
use parking_lot::Mutex;
use rand::{seq::SliceRandom, Rng};
use rhai::{
packages::{
ArithmeticPackage, BasicArrayPackage, BasicFnPackage, BasicIteratorPackage,
BasicMathPackage, BasicStringPackage, LanguageCorePackage, LogicPackage, MoreStringPackage,
Package,
},
CallFnOptions, Dynamic, Engine, EvalAltResult, OptimizationLevel, ParseError, Position, Scope,
AST,
};
use std::{sync::Arc, vec::IntoIter};
use super::Agent;
use crate::game::{Board, PlayerAction, Symb};
//
// MARK: WasmTimer
//
// Native impl
#[cfg(not(target_arch = "wasm32"))]
struct WasmTimer {
start: std::time::Instant,
}
#[cfg(not(target_arch = "wasm32"))]
impl WasmTimer {
pub fn new() -> Self {
Self {
start: std::time::Instant::now(),
}
}
pub fn elapsed_ms(&self) -> u128 {
self.start.elapsed().as_millis()
}
}
// Wasm impl
#[cfg(target_arch = "wasm32")]
struct WasmTimer {
start: f64,
}
#[cfg(target_arch = "wasm32")]
impl WasmTimer {
fn performance() -> web_sys::Performance {
use wasm_bindgen::JsCast;
let global = web_sys::js_sys::global();
let performance = web_sys::js_sys::Reflect::get(&global, &"performance".into())
.expect("performance should be available");
performance
.dyn_into::<web_sys::Performance>()
.expect("performance should be available")
}
pub fn new() -> Self {
Self {
start: Self::performance().now(),
}
}
pub fn elapsed_ms(&self) -> u128 {
let performance = Self::performance();
(performance.now() - self.start).round() as u128
}
}
//
// MARK: RhaiAgent
//
pub struct RhaiAgent<R: Rng + 'static> {
#[expect(dead_code)]
rng: Arc<Mutex<R>>,
engine: Engine,
script: AST,
scope: Scope<'static>,
print_callback: Arc<dyn Fn(&str) + 'static>,
}
impl<R: Rng + 'static> RhaiAgent<R> {
pub fn new(
script: &str,
rng: R,
print_callback: impl Fn(&str) + 'static,
debug_callback: impl Fn(&str) + 'static,
) -> Result<Self, ParseError> {
let rng = Arc::new(Mutex::new(rng));
let print_callback = Arc::new(print_callback);
let engine = {
let mut engine = Engine::new_raw();
let start = WasmTimer::new();
let max_secs: u64 = 5;
engine.on_progress(move |ops| {
if ops % 10_000 != 0 {
return None;
}
let elapsed_s = start.elapsed_ms() as u64 / 1000;
if elapsed_s > max_secs {
return Some(
format!("Turn ran for more than {max_secs} seconds, exiting.").into(),
);
}
return None;
});
// Do not use FULL, rand_* functions are not pure
engine.set_optimization_level(OptimizationLevel::Simple);
engine.disable_symbol("eval");
engine.set_max_expr_depths(100, 100);
engine.set_max_strings_interned(1024);
engine.set_strict_variables(false);
engine.on_print({
let callback = print_callback.clone();
move |s| callback(s)
});
engine.on_debug(move |text, source, pos| {
debug_callback(&match (source, pos) {
(Some(source), Position::NONE) => format!("{source} | {text}"),
(Some(source), pos) => format!("{source} @ {pos:?} | {text}"),
(None, Position::NONE) => format!("{text}"),
(None, pos) => format!("{pos:?} | {text}"),
})
});
LanguageCorePackage::new().register_into_engine(&mut engine);
ArithmeticPackage::new().register_into_engine(&mut engine);
BasicIteratorPackage::new().register_into_engine(&mut engine);
LogicPackage::new().register_into_engine(&mut engine);
BasicStringPackage::new().register_into_engine(&mut engine);
MoreStringPackage::new().register_into_engine(&mut engine);
BasicMathPackage::new().register_into_engine(&mut engine);
BasicArrayPackage::new().register_into_engine(&mut engine);
BasicFnPackage::new().register_into_engine(&mut engine);
engine
.register_fn("rand_int", {
let rng = rng.clone();
move |from: i64, to: i64| rng.lock().gen_range(from..=to)
})
.register_fn("rand_bool", {
let rng = rng.clone();
move |p: f32| rng.lock().gen_bool((p as f64).clamp(0.0, 1.0))
})
.register_fn("rand_symb", {
let rng = rng.clone();
move || Symb::new_random(&mut *rng.lock()).to_string()
})
.register_fn("rand_op", {
let rng = rng.clone();
move || Symb::new_random_op(&mut *rng.lock()).to_string()
})
.register_fn("rand_action", {
let rng = rng.clone();
move |board: Board| PlayerAction::new_random(&mut *rng.lock(), &board)
})
.register_fn("rand_shuffle", {
let rng = rng.clone();
move |mut vec: Vec<Dynamic>| {
vec.shuffle(&mut *rng.lock());
vec
}
})
.register_fn("is_op", |s: &str| {
Symb::from_str(s).map(|x| x.is_op()).unwrap_or(false)
})
.register_fn(
"permutations",
|v: Vec<Dynamic>, size: i64| -> Result<Dynamic, Box<EvalAltResult>> {
let size: usize = match size.try_into() {
Ok(x) => x,
Err(_) => {
return Err(format!("Invalid permutation size {size}").into());
}
};
let per = helpers::RhaiPer::new(v.into_iter().permutations(size).into());
Ok(Dynamic::from(per))
},
);
engine
.build_type::<Board>()
.build_type::<PlayerAction>()
.build_type::<helpers::RhaiPer<Dynamic, IntoIter<Dynamic>>>();
engine
};
let script = engine.compile(script)?;
let scope = Scope::new(); // Not used
Ok(Self {
rng,
engine,
script,
scope,
print_callback,
})
}
pub fn print(&self, text: &str) {
(self.print_callback)(text);
}
}
impl<R: Rng + 'static> Agent for RhaiAgent<R> {
type ErrorType = EvalAltResult;
fn name(&self) -> &'static str {
"Rhai"
}
fn step_min(&mut self, board: &Board) -> Result<PlayerAction, Self::ErrorType> {
let res = self.engine.call_fn_with_options::<PlayerAction>(
CallFnOptions::new().eval_ast(false),
&mut self.scope,
&self.script,
"step_min",
(board.clone(),),
);
match res {
Ok(x) => Ok(x),
Err(err) => Err(*err),
}
}
fn step_max(&mut self, board: &Board) -> Result<PlayerAction, Self::ErrorType> {
let res = self.engine.call_fn_with_options::<PlayerAction>(
CallFnOptions::new().eval_ast(false),
&mut self.scope,
&self.script,
"step_max",
(board.clone(),),
);
match res {
Ok(x) => Ok(x),
Err(err) => Err(*err),
}
}
}
//
// MARK: rhai helpers
//
mod helpers {
use std::sync::Arc;
use itertools::Permutations;
use rhai::{CustomType, TypeBuilder};
/// A Rhai iterator that produces all permutations of length `n`
/// of the elements in an array
pub struct RhaiPer<T: Clone, I: Iterator<Item = T>> {
inner: Arc<Permutations<I>>,
}
impl<T: Clone, I: Clone + Iterator<Item = T>> RhaiPer<T, I> {
pub fn new(inner: Permutations<I>) -> Self {
Self {
inner: Arc::new(inner),
}
}
}
impl<T: Clone, I: Clone + Iterator<Item = T>> IntoIterator for RhaiPer<T, I> {
type Item = Vec<T>;
type IntoIter = Permutations<I>;
fn into_iter(self) -> Self::IntoIter {
(*self.inner).clone()
}
}
impl<T: Clone, I: Iterator<Item = T>> Clone for RhaiPer<T, I> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<T: Clone + 'static, I: Clone + Iterator<Item = T> + 'static> CustomType for RhaiPer<T, I> {
fn build(mut builder: TypeBuilder<Self>) {
builder
.with_name("Permutations")
.is_iterable()
.with_fn("to_string", |_s: &mut Self| "Permutation {}".to_owned())
.with_fn("to_debug", |_s: &mut Self| "Permutation {}".to_owned());
}
}
}

View File

@@ -0,0 +1,70 @@
use rand::Rng;
use rhai::{CustomType, EvalAltResult, TypeBuilder};
use std::fmt::Display;
use super::{Board, Symb};
#[derive(Debug, Clone, Copy)]
pub struct PlayerAction {
pub symb: Symb,
pub pos: usize,
}
impl Display for PlayerAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} at {}", self.symb, self.pos)
}
}
impl PlayerAction {
pub fn new_random<R: Rng>(rng: &mut R, board: &Board) -> Self {
let n = board.size();
let pos = rng.gen_range(0..n);
let symb = Symb::new_random(rng);
PlayerAction { symb, pos }
}
}
impl CustomType for PlayerAction {
fn build(mut builder: TypeBuilder<Self>) {
builder
.with_name("Action")
.with_fn(
"Action",
|symb: &str, pos: i64| -> Result<Self, Box<EvalAltResult>> {
let symb = match Symb::from_str(symb) {
Some(x) => x,
None => return Err(format!("Invalid symbol {symb:?}").into()),
};
Ok(Self {
symb,
pos: pos as usize,
})
},
)
.with_fn(
"Action",
|symb: i64, pos: i64| -> Result<Self, Box<EvalAltResult>> {
let symb = symb.to_string();
let symb = match Symb::from_str(&symb) {
Some(x) => x,
None => return Err(format!("Invalid symbol {symb:?}").into()),
};
Ok(Self {
symb,
pos: pos as usize,
})
},
)
.with_fn("to_string", |s: &mut Self| -> String {
format!("Action {{{} at {}}}", s.symb, s.pos)
})
.with_fn("to_debug", |s: &mut Self| -> String {
format!("Action {{{} at {}}}", s.symb, s.pos)
})
.with_get("symb", |s: &mut Self| s.symb.to_string())
.with_get("pos", |s: &mut Self| s.pos);
}
}

View File

@@ -0,0 +1,632 @@
use anyhow::Result;
use itertools::Itertools;
use rhai::Array;
use rhai::CustomType;
use rhai::Dynamic;
use rhai::EvalAltResult;
use rhai::Position;
use rhai::TypeBuilder;
use std::fmt::{Debug, Display, Write};
use super::{PlayerAction, Symb, TreeElement};
#[derive(Debug)]
enum InterTreeElement {
Unprocessed(Token),
Processed(TreeElement),
}
impl InterTreeElement {
fn to_value(&self) -> Option<TreeElement> {
Some(match self {
InterTreeElement::Processed(x) => x.clone(),
InterTreeElement::Unprocessed(Token::Value(s)) => {
if let Some(s) = s.strip_prefix('-') {
TreeElement::Neg {
r: {
if s.contains('_') {
Box::new(TreeElement::Partial(s.to_string()))
} else {
Box::new(TreeElement::Number(match s.parse() {
Ok(x) => x,
_ => return None,
}))
}
},
}
} else if s.contains('_') {
TreeElement::Partial(s.to_string())
} else {
TreeElement::Number(match s.parse() {
Ok(x) => x,
_ => return None,
})
}
}
_ => return None,
})
}
}
#[derive(Debug, PartialEq, Clone)]
enum Token {
Value(String),
OpAdd,
OpSub,
OpMult,
OpDiv,
}
#[derive(Clone)]
pub struct Board {
board: [Option<Symb>; 11],
placed_by: [Option<String>; 11],
/// Number of Nones in `board`
free_spots: usize,
/// Index of the last board index that was changed
last_placed: Option<usize>,
}
impl Display for Board {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for c in self.board {
write!(f, "{}", c.map(|s| s.get_char().unwrap()).unwrap_or('_'))?
}
Ok(())
}
}
impl Debug for Board {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self, f)
}
}
#[allow(dead_code)]
impl Board {
pub fn new() -> Self {
Self {
free_spots: 11,
board: Default::default(),
placed_by: Default::default(),
last_placed: None,
}
}
pub fn get_board(&self) -> &[Option<Symb>; 11] {
&self.board
}
pub fn get_board_mut(&mut self) -> &mut [Option<Symb>; 11] {
&mut self.board
}
/// Get the index of the ith empty slot
pub fn ith_empty_slot(&self, mut idx: usize) -> Option<usize> {
for (i, c) in self.board.iter().enumerate() {
if c.is_none() {
if idx == 0 {
return Some(i);
}
idx -= 1;
}
}
if idx == 0 {
Some(self.board.len() - 1)
} else {
None
}
}
pub fn is_full(&self) -> bool {
self.free_spots == 0
}
pub fn prettyprint(&self) -> String {
const RESET: &str = "\x1b[0m";
const MAGENTA: &str = "\x1b[35m";
let mut s = String::new();
// Print board
for (i, (symb, _)) in self.board.iter().zip(self.placed_by.iter()).enumerate() {
match symb {
Some(symb) => write!(
s,
"{}{}{RESET}",
// If index matches last placed, draw symbol in red.
// If last_placed is None, this check will always fail
// since self.board.len is always greater than i.
if self.last_placed.unwrap_or(self.board.len()) == i {
MAGENTA
} else {
RESET
},
symb,
)
.unwrap(),
None => write!(s, "_").unwrap(),
}
}
s
}
pub fn size(&self) -> usize {
self.board.len()
}
pub fn get_last_placed(&self) -> Option<usize> {
self.last_placed
}
pub fn contains(&self, s: Symb) -> bool {
self.board.iter().contains(&Some(s))
}
/// Is the given action valid?
pub fn can_play(&self, action: &PlayerAction) -> bool {
match &self.board[action.pos] {
Some(_) => return false,
None => {
// Check for duplicate symbols
if self.contains(action.symb) {
return false;
}
// Check syntax
match action.symb {
Symb::Minus => {
if action.pos == self.board.len() - 1 {
return false;
}
let r = &self.board[action.pos + 1];
if r.is_some_and(|s| s.is_op() && !s.is_minus()) {
return false;
}
}
Symb::Zero => {
if action.pos != 0 {
let l = &self.board[action.pos - 1];
if l == &Some(Symb::Div) {
return false;
}
}
}
Symb::Div | Symb::Plus | Symb::Times => {
if action.pos == 0 || action.pos == self.board.len() - 1 {
return false;
}
let l = &self.board[action.pos - 1];
let r = &self.board[action.pos + 1];
if action.symb == Symb::Div && r == &Some(Symb::Zero) {
return false;
}
if l.is_some_and(|s| s.is_op())
|| r.is_some_and(|s| s.is_op() && !s.is_minus())
{
return false;
}
}
_ => {}
}
}
}
true
}
/// Place the marked symbol at the given position.
/// Returns true for valid moves and false otherwise.
pub fn play(&mut self, action: PlayerAction, player: impl Into<String>) -> bool {
if !self.can_play(&action) {
return false;
}
self.board[action.pos] = Some(action.symb);
self.placed_by[action.pos] = Some(player.into());
self.free_spots -= 1;
self.last_placed = Some(action.pos);
true
}
fn tokenize(&self) -> Vec<Token> {
let mut tokens = Vec::new();
let mut is_neg = true; // if true, - is negative. if false, subtract.
let mut current_num = String::new();
for s in self.board.iter() {
match s {
Some(Symb::Div) => {
if !current_num.is_empty() {
tokens.push(Token::Value(current_num.clone()));
current_num.clear();
}
tokens.push(Token::OpDiv);
is_neg = true;
}
Some(Symb::Minus) => {
if is_neg {
current_num = format!("-{}", current_num);
} else {
if !current_num.is_empty() {
tokens.push(Token::Value(current_num.clone()));
current_num.clear();
}
tokens.push(Token::OpSub);
is_neg = true;
}
}
Some(Symb::Plus) => {
if !current_num.is_empty() {
tokens.push(Token::Value(current_num.clone()));
current_num.clear();
}
tokens.push(Token::OpAdd);
is_neg = true;
}
Some(Symb::Times) => {
if !current_num.is_empty() {
tokens.push(Token::Value(current_num.clone()));
current_num.clear();
}
tokens.push(Token::OpMult);
is_neg = true;
}
Some(Symb::Zero) => {
current_num.push('0');
is_neg = false;
}
Some(Symb::Number(x)) => {
current_num.push_str(&x.to_string());
is_neg = false;
}
None => {
current_num.push('_');
is_neg = false;
}
}
}
if !current_num.is_empty() {
tokens.push(Token::Value(current_num.clone()));
}
tokens
}
pub fn to_tree(&self) -> Option<TreeElement> {
let tokens = self.tokenize();
let mut tree: Vec<_> = tokens
.iter()
.map(|x| InterTreeElement::Unprocessed(x.clone()))
.collect();
let mut priority_level = 0;
let mut did_something;
while tree.len() > 1 {
did_something = false;
for i in 0..tree.len() {
if match priority_level {
0 => matches!(
tree[i],
InterTreeElement::Unprocessed(Token::OpMult)
| InterTreeElement::Unprocessed(Token::OpDiv)
),
1 => matches!(
tree[i],
InterTreeElement::Unprocessed(Token::OpAdd)
| InterTreeElement::Unprocessed(Token::OpSub)
),
_ => false,
} {
did_something = true;
if i == 0 || i + 1 >= tree.len() {
return None;
}
let l = tree[i - 1].to_value()?;
let r = tree[i + 1].to_value()?;
let v = match tree[i] {
InterTreeElement::Unprocessed(Token::OpAdd) => TreeElement::Add {
l: Box::new(l),
r: Box::new(r),
},
InterTreeElement::Unprocessed(Token::OpDiv) => TreeElement::Div {
l: Box::new(l),
r: Box::new(r),
},
InterTreeElement::Unprocessed(Token::OpMult) => TreeElement::Mul {
l: Box::new(l),
r: Box::new(r),
},
InterTreeElement::Unprocessed(Token::OpSub) => TreeElement::Sub {
l: Box::new(l),
r: Box::new(r),
},
_ => unreachable!(),
};
tree.remove(i - 1);
tree.remove(i - 1);
tree[i - 1] = InterTreeElement::Processed(v);
break;
}
}
if !did_something {
priority_level += 1;
}
}
Some(match tree.into_iter().next().unwrap() {
InterTreeElement::Processed(x) => x,
x => x.to_value()?,
})
}
pub fn evaluate(&self) -> Option<f32> {
self.to_tree()?.evaluate()
}
pub fn from_board(board: [Option<Symb>; 11]) -> Self {
let free_spots = board.iter().filter(|x| x.is_none()).count();
Self {
board,
placed_by: Default::default(),
free_spots,
last_placed: None,
}
}
/// Parse a board from a string
pub fn from_string(s: &str) -> Option<Self> {
if s.len() != 11 {
return None;
}
let x = s
.chars()
.filter_map(|c| {
if c == '_' {
Some(None)
} else {
Symb::from_char(c).map(Some)
}
})
.collect::<Vec<_>>();
if x.len() != 11 {
return None;
}
let mut free_spots = 11;
let mut board = [None; 11];
for i in 0..x.len() {
board[i] = x[i];
if x[i].is_some() {
free_spots -= 1;
}
}
Some(Self {
board,
placed_by: Default::default(),
free_spots,
last_placed: None,
})
}
/// If true, this board is not done and has no valid moves
pub fn is_stuck(&self) -> bool {
if self.is_full() {
return false;
}
// This can only happen in a few cases,
// enumerated below.
// `9614807523_` (all numbers, one spot left for op)
// Note that this is _not_ a problem if the left spot is empty.
if self.free_spots == 1
&& self.board[10].is_none()
&& self
.board
.iter()
.filter_map(|x| x.as_ref())
.all(|x| !x.is_op())
{
return true;
}
// `961487523/_` (forced division by zero)
if self.free_spots == 1
&& self.board[10].is_none()
&& self.board[9] == Some(Symb::Div)
&& self.board[0..8]
.iter()
.filter_map(|x| x.as_ref())
.all(|x| !x.is_op() && *x != Symb::Zero)
{
return true;
}
return false;
}
}
impl IntoIterator for Board {
type Item = String;
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.board
.iter()
.map(|x| x.map(|x| x.to_string()).unwrap_or_default())
.collect::<Vec<_>>()
.into_iter()
}
}
impl CustomType for Board {
fn build(mut builder: TypeBuilder<Self>) {
builder
.with_name("Board")
.is_iterable()
.with_fn("to_string", |s: &mut Self| format!("{}", s))
.with_fn("to_debug", |s: &mut Self| format!("{:?}", s))
.with_fn("size", |s: &mut Self| s.board.len() as i64)
.with_fn("len", |s: &mut Self| s.board.len() as i64)
.with_fn("is_full", |s: &mut Self| s.is_full())
.with_fn("free_spots", |s: &mut Self| s.free_spots)
.with_fn("play", |s: &mut Self, act: PlayerAction| {
s.play(act, "NONE".to_owned()) // Player doesn't matter
})
.with_fn("ith_free_slot", |s: &mut Self, idx: usize| {
s.ith_empty_slot(idx).map(|x| x as i64).unwrap_or(-1)
})
.with_fn("can_play", |s: &mut Self, act: PlayerAction| {
s.can_play(&act)
})
.with_fn("contains", |s: &mut Self, sym: &str| {
match Symb::from_str(sym) {
None => false,
Some(x) => s.contains(x),
}
})
.with_fn("contains", |s: &mut Self, sym: i64| {
let sym = sym.to_string();
match Symb::from_str(&sym) {
None => false,
Some(x) => s.contains(x),
}
})
.with_fn("evaluate", |s: &mut Self| -> Dynamic {
s.evaluate().map(|x| x.into()).unwrap_or(().into())
})
.with_fn("free_spots_idx", |s: &mut Self| -> Array {
s.board
.iter()
.enumerate()
.filter(|(_, x)| x.is_none())
.map(|(i, _)| i as i64)
.map(|x| x.into())
.collect::<Vec<Dynamic>>()
})
.with_indexer_get(
|s: &mut Self, idx: i64| -> Result<String, Box<EvalAltResult>> {
if idx as usize >= s.board.len() {
return Err(
EvalAltResult::ErrorIndexNotFound(idx.into(), Position::NONE).into(),
);
}
let idx = idx as usize;
return Ok(s.board[idx].map(|x| x.to_string()).unwrap_or_default());
},
)
.with_indexer_set(
|s: &mut Self, idx: i64, val: String| -> Result<(), Box<EvalAltResult>> {
let idx: usize = match idx.try_into() {
Ok(x) => x,
Err(_) => {
return Err(EvalAltResult::ErrorIndexNotFound(
idx.into(),
Position::NONE,
)
.into());
}
};
if idx >= s.board.len() {
return Err(EvalAltResult::ErrorIndexNotFound(
(idx as i64).into(),
Position::NONE,
)
.into());
}
match Symb::from_str(&val) {
None => return Err(format!("Invalid symbol {val}").into()),
Some(x) => {
s.board[idx] = Some(x);
s.placed_by[idx] = Some("NONE".to_owned()); // Arbitrary
}
}
return Ok(());
},
)
.with_indexer_set(
|s: &mut Self, idx: i64, _val: ()| -> Result<(), Box<EvalAltResult>> {
let idx: usize = match idx.try_into() {
Ok(x) => x,
Err(_) => {
return Err(EvalAltResult::ErrorIndexNotFound(
idx.into(),
Position::NONE,
)
.into());
}
};
if idx >= s.board.len() {
return Err(EvalAltResult::ErrorIndexNotFound(
(idx as i64).into(),
Position::NONE,
)
.into());
}
s.board[idx] = None;
s.placed_by[idx] = None;
return Ok(());
},
)
.with_indexer_set(
|s: &mut Self, idx: i64, val: i64| -> Result<(), Box<EvalAltResult>> {
let idx: usize = match idx.try_into() {
Ok(x) => x,
Err(_) => {
return Err(EvalAltResult::ErrorIndexNotFound(
idx.into(),
Position::NONE,
)
.into());
}
};
if idx >= s.board.len() {
return Err(EvalAltResult::ErrorIndexNotFound(
(idx as i64).into(),
Position::NONE,
)
.into());
}
match Symb::from_str(&val.to_string()) {
None => return Err(format!("Invalid symbol {val}").into()),
Some(x) => {
s.board[idx] = Some(x);
s.placed_by[idx] = Some("NULL".to_owned()); // Arbitrary
}
}
return Ok(());
},
);
}
}

View File

@@ -0,0 +1,9 @@
mod action;
mod board;
mod symb;
mod tree;
pub use action::PlayerAction;
pub use board::Board;
pub use symb::Symb;
pub use tree::TreeElement;

View File

@@ -0,0 +1,120 @@
use rand::Rng;
use std::{
fmt::{Debug, Display},
num::NonZeroU8,
};
#[derive(PartialEq, Eq, Clone, Copy, Hash)]
pub enum Symb {
Number(NonZeroU8),
Zero,
Plus,
Minus,
Times,
Div,
}
impl Display for Symb {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Number(x) => write!(f, "{x}")?,
Self::Zero => write!(f, "0")?,
Self::Plus => write!(f, "+")?,
Self::Minus => write!(f, "-")?,
Self::Div => write!(f, "÷")?,
Self::Times => write!(f, "×")?,
}
Ok(())
}
}
impl Debug for Symb {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(self, f)
}
}
impl Symb {
/// Is this symbol a plain binary operator?
pub fn is_op(&self) -> bool {
matches!(self, Symb::Div | Symb::Plus | Symb::Times | Symb::Minus)
}
pub fn is_minus(&self) -> bool {
self == &Self::Minus
}
pub fn new_random<R: Rng>(rng: &mut R) -> Self {
match rng.gen_range(0..=13) {
0 => Symb::Zero,
n @ 1..=9 => Symb::Number(NonZeroU8::new(n).unwrap()),
10 => Symb::Div,
11 => Symb::Minus,
12 => Symb::Plus,
13 => Symb::Times,
_ => unreachable!(),
}
}
pub fn new_random_op<R: Rng>(rng: &mut R) -> Self {
match rng.gen_range(0..=3) {
0 => Symb::Div,
1 => Symb::Minus,
2 => Symb::Plus,
3 => Symb::Times,
_ => unreachable!(),
}
}
pub const fn get_char(&self) -> Option<char> {
match self {
Self::Plus => Some('+'),
Self::Minus => Some('-'),
Self::Times => Some('×'),
Self::Div => Some('÷'),
Self::Zero => Some('0'),
Self::Number(x) => match x.get() {
1 => Some('1'),
2 => Some('2'),
3 => Some('3'),
4 => Some('4'),
5 => Some('5'),
6 => Some('6'),
7 => Some('7'),
8 => Some('8'),
9 => Some('9'),
_ => None,
},
}
}
pub fn from_str(s: &str) -> Option<Self> {
if s.chars().count() != 1 {
return None;
}
Self::from_char(s.chars().next()?)
}
pub const fn from_char(c: char) -> Option<Self> {
match c {
'1' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(1) })),
'2' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(2) })),
'3' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(3) })),
'4' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(4) })),
'5' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(5) })),
'6' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(6) })),
'7' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(7) })),
'8' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(8) })),
'9' => Some(Self::Number(unsafe { NonZeroU8::new_unchecked(9) })),
'0' => Some(Self::Zero),
'+' => Some(Self::Plus),
'-' => Some(Self::Minus),
'*' => Some(Self::Times),
'/' => Some(Self::Div),
'×' => Some(Self::Times),
'÷' => Some(Self::Div),
_ => None,
}
}
}

View File

@@ -0,0 +1,143 @@
use std::fmt::{Debug, Display};
#[derive(PartialEq, Clone)]
pub enum TreeElement {
Partial(String),
Number(f32),
Add {
l: Box<TreeElement>,
r: Box<TreeElement>,
},
Sub {
l: Box<TreeElement>,
r: Box<TreeElement>,
},
Mul {
l: Box<TreeElement>,
r: Box<TreeElement>,
},
Div {
l: Box<TreeElement>,
r: Box<TreeElement>,
},
Neg {
r: Box<TreeElement>,
},
}
impl Display for TreeElement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Partial(s) => write!(f, "{s}")?,
Self::Number(n) => write!(f, "{n}")?,
Self::Add { l, r } => write!(f, "({l}+{r})")?,
Self::Div { l, r } => write!(f, "({l}÷{r})")?,
Self::Mul { l, r } => write!(f, "({l}×{r})")?,
Self::Sub { l, r } => write!(f, "({l}-{r})")?,
Self::Neg { r } => write!(f, "(-{r})")?,
}
Ok(())
}
}
impl Debug for TreeElement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self, f)
}
}
#[allow(dead_code)]
impl TreeElement {
pub fn left(&self) -> Option<&TreeElement> {
match self {
Self::Add { l, .. }
| Self::Sub { l, .. }
| Self::Mul { l, .. }
| Self::Div { l, .. } => Some(&**l),
_ => None,
}
}
pub fn right(&self) -> Option<&TreeElement> {
match self {
Self::Add { r, .. }
| Self::Neg { r, .. }
| Self::Sub { r, .. }
| Self::Mul { r, .. }
| Self::Div { r, .. } => Some(&**r),
_ => None,
}
}
pub fn left_mut(&mut self) -> Option<&mut TreeElement> {
match self {
Self::Add { l, .. }
| Self::Sub { l, .. }
| Self::Mul { l, .. }
| Self::Div { l, .. } => Some(&mut **l),
_ => None,
}
}
pub fn right_mut(&mut self) -> Option<&mut TreeElement> {
match self {
Self::Add { r, .. }
| Self::Neg { r, .. }
| Self::Sub { r, .. }
| Self::Mul { r, .. }
| Self::Div { r, .. } => Some(&mut **r),
_ => None,
}
}
pub fn evaluate(&self) -> Option<f32> {
match self {
Self::Number(x) => Some(*x),
// Try to parse strings of a partial
Self::Partial(s) => s.parse().ok(),
Self::Add { l, r } => {
let l = l.evaluate();
let r = r.evaluate();
if let (Some(l), Some(r)) = (l, r) {
Some(l + r)
} else {
None
}
}
Self::Mul { l, r } => {
let l = l.evaluate();
let r = r.evaluate();
if let (Some(l), Some(r)) = (l, r) {
Some(l * r)
} else {
None
}
}
Self::Div { l, r } => {
let l = l.evaluate();
let r = r.evaluate();
if r == Some(0.0) {
None
} else if let (Some(l), Some(r)) = (l, r) {
Some(l / r)
} else {
None
}
}
Self::Sub { l, r } => {
let l = l.evaluate();
let r = r.evaluate();
if let (Some(l), Some(r)) = (l, r) {
Some(l - r)
} else {
None
}
}
Self::Neg { r } => {
let r = r.evaluate();
r.map(|r| -r)
}
}
}
}

2
rust/minimax/src/lib.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod agents;
pub mod game;

View File

@@ -0,0 +1,21 @@
[package]
name = "rhai-codemirror"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
rhai = { workspace = true, features = ["internals"] }
wasm-bindgen = { workspace = true }
js-sys = { workspace = true }
web-sys = { workspace = true, features = ["console"] }
console_error_panic_hook = { workspace = true }
wee_alloc = { workspace = true }
serde = { workspace = true }
serde-wasm-bindgen = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
rhai = { workspace = true, features = ["wasm-bindgen"] }

View File

@@ -0,0 +1,64 @@
use js_sys::{Array, JsString, RegExp};
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "codemirror")]
extern "C" {
pub type StringStream;
#[wasm_bindgen(method)]
#[must_use]
pub fn eol(this: &StringStream) -> bool;
#[wasm_bindgen(method)]
#[must_use]
pub fn sol(this: &StringStream) -> bool;
#[wasm_bindgen(method)]
#[must_use]
pub fn peek(this: &StringStream) -> JsString;
#[wasm_bindgen(method)]
pub fn next(this: &StringStream) -> JsString;
#[wasm_bindgen(method, js_name = eat)]
pub fn eat_regexp(this: &StringStream, m: &RegExp) -> bool;
#[wasm_bindgen(method, js_name = eatWhile)]
pub fn eat_while_regexp(this: &StringStream, m: &RegExp) -> bool;
#[wasm_bindgen(method, js_name = eatSpace)]
pub fn eat_space(this: &StringStream) -> bool;
#[wasm_bindgen(method, js_name = skipToEnd)]
pub fn skip_to_end(this: &StringStream);
#[wasm_bindgen(method, js_name = skipTo)]
pub fn skip_to(this: &StringStream, str: &str) -> bool;
#[wasm_bindgen(method, js_name = match)]
#[must_use]
pub fn match_str(this: &StringStream, pattern: &str, consume: bool, case_fold: bool) -> bool;
#[wasm_bindgen(method, js_name = match)]
#[must_use]
pub fn match_regexp(this: &StringStream, pattern: &RegExp, consume: bool) -> Array;
#[wasm_bindgen(method, js_name = backUp)]
pub fn back_up(this: &StringStream, n: u32);
#[wasm_bindgen(method)]
#[must_use]
pub fn column(this: &StringStream) -> u32;
#[wasm_bindgen(method)]
#[must_use]
pub fn indentation(this: &StringStream) -> u32;
#[wasm_bindgen(method)]
#[must_use]
pub fn current(this: &StringStream) -> String;
#[wasm_bindgen(method, js_name = lookAhead)]
#[must_use]
pub fn look_ahead(this: &StringStream, n: u32) -> Option<String>;
}

View File

@@ -0,0 +1,20 @@
use wasm_bindgen::prelude::*;
mod codemirror;
mod rhai_mode;
pub use rhai_mode::*;
// Use `wee_alloc` as the global allocator for smaller WASM size.
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
// This provides better error messages in debug mode.
// It's disabled in release mode so it doesn't bloat up the file size.
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
Ok(())
}

View File

@@ -0,0 +1,353 @@
use crate::codemirror;
use js_sys::RegExp;
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use web_sys::console;
#[wasm_bindgen]
pub struct RhaiMode {
indent_unit: u32,
}
#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct State {
token_state: rhai::TokenizeState,
unclosed_bracket_count: i32,
line_indent: u32,
is_defining_identifier: bool,
/// Buffered character, if any. (For use by `StreamAdapter`.)
buf: Option<char>,
/// Interpolated string brace counting stack
interpolated_str_brace_stack: Vec<u8>,
}
thread_local! {
static ELECTRIC_INPUT: RegExp = RegExp::new("^\\s*[}\\])]$", "");
static LINE_COMMENT: JsValue = JsValue::from_str("//");
static CODEMIRROR_PASS: RefCell<JsValue> = RefCell::new(JsValue::null());
}
#[wasm_bindgen]
#[allow(dead_code)]
pub fn init_codemirror_pass(codemirror_pass: JsValue) {
CODEMIRROR_PASS.with(|v| v.replace(codemirror_pass));
}
#[wasm_bindgen]
impl RhaiMode {
#[wasm_bindgen(constructor)]
pub fn new(indent_unit: u32) -> Self {
Self { indent_unit }
}
#[wasm_bindgen(js_name = startState)]
pub fn start_state(&self) -> State {
State {
token_state: rhai::TokenizeState {
include_comments: true,
..Default::default()
},
unclosed_bracket_count: 0,
line_indent: 0,
is_defining_identifier: false,
buf: None,
interpolated_str_brace_stack: vec![],
}
}
#[wasm_bindgen(js_name = copyState)]
pub fn copy_state(&self, state: &State) -> State {
state.clone()
}
pub fn token(
&self,
stream: codemirror::StringStream,
state: &mut State,
) -> Result<Option<String>, JsValue> {
token(stream, state)
}
pub fn indent(&self, state: &mut State, text_after: String) -> JsValue {
indent(self, state, text_after)
.map(JsValue::from)
.unwrap_or_else(|| CODEMIRROR_PASS.with(|v| v.borrow().clone()))
}
#[wasm_bindgen(getter, js_name = electricInput)]
pub fn electric_input(&self) -> RegExp {
ELECTRIC_INPUT.with(|v| v.clone())
}
#[wasm_bindgen(getter, js_name = lineComment)]
pub fn line_comment(&self) -> JsValue {
LINE_COMMENT.with(|v| v.clone())
}
}
struct StreamAdapter {
/// Buffered character, if any.
buf: Option<char>,
stream: codemirror::StringStream,
}
impl rhai::InputStream for StreamAdapter {
fn unget(&mut self, ch: char) {
self.buf = Some(ch);
}
fn get_next(&mut self) -> Option<char> {
if let Some(ch) = self.buf.take() {
return Some(ch);
}
let first = self.stream.next();
if first.is_falsy() {
return None;
}
assert_eq!(first.length(), 1);
let first_code_unit = first.char_code_at(0) as u16;
if let Some(Ok(c)) = std::char::decode_utf16(std::iter::once(first_code_unit)).next() {
Some(c)
} else {
// The first value is likely an unpared surrogate, so we get one
// more UTF-16 unit to attempt to make a proper Unicode scalar.
let second = self.stream.next();
if second.is_falsy() {
return Some(std::char::REPLACEMENT_CHARACTER);
}
assert_eq!(second.length(), 1);
let second_code_unit = second.char_code_at(0) as u16;
if let Some(Ok(c)) =
std::char::decode_utf16([first_code_unit, second_code_unit].iter().copied()).next()
{
Some(c)
} else {
// Turns out to not be a proper surrogate pair, so back up one
// unit for it to be decoded separately.
self.stream.back_up(1);
Some(std::char::REPLACEMENT_CHARACTER)
}
}
}
fn peek_next(&mut self) -> Option<char> {
if let Some(ch) = self.buf {
return Some(ch);
}
let first = self.stream.peek();
if first.is_falsy() {
return None;
}
assert_eq!(first.length(), 1);
let first_code_unit = first.char_code_at(0) as u16;
if let Some(Ok(c)) = std::char::decode_utf16(std::iter::once(first_code_unit)).next() {
Some(c)
} else {
// The first value is likely an unpared surrogate, so we get one more
// value to attempt to make a proper Unicode scalar value.
self.stream.next();
let second = self.stream.peek();
if second.is_falsy() {
return Some(std::char::REPLACEMENT_CHARACTER);
}
self.stream.back_up(1);
assert_eq!(second.length(), 1);
let second_code_unit = second.char_code_at(0) as u16;
if let Some(Ok(c)) =
std::char::decode_utf16([first_code_unit, second_code_unit].iter().copied()).next()
{
Some(c)
} else {
Some(std::char::REPLACEMENT_CHARACTER)
}
}
}
}
fn token(stream: codemirror::StringStream, state: &mut State) -> Result<Option<String>, JsValue> {
if stream.sol() {
state.line_indent = stream.indentation();
state.unclosed_bracket_count = 0;
}
let mut stream_adapter = StreamAdapter {
stream,
buf: state.buf,
};
let (next_token, _) = rhai::get_next_token(
&mut stream_adapter,
&mut state.token_state,
&mut rhai::Position::default(),
);
state.buf = stream_adapter.buf;
match &next_token {
rhai::Token::LeftBrace
| rhai::Token::LeftBracket
| rhai::Token::LeftParen
| rhai::Token::MapStart => {
if state.unclosed_bracket_count < 0 {
state.unclosed_bracket_count = 0;
}
state.unclosed_bracket_count += 1;
}
rhai::Token::RightBrace | rhai::Token::RightBracket | rhai::Token::RightParen => {
state.unclosed_bracket_count -= 1;
}
_ => {}
};
let res = match &next_token {
rhai::Token::IntegerConstant(_) => "number",
rhai::Token::FloatConstant(_) => "number",
rhai::Token::Identifier(_) => {
if state.is_defining_identifier {
"def"
} else {
"identifier"
}
}
rhai::Token::CharConstant(_) => "string-2",
rhai::Token::StringConstant(_) => "string",
rhai::Token::InterpolatedString(_) => {
state.interpolated_str_brace_stack.push(0);
"string"
}
rhai::Token::LeftBrace => {
if let Some(brace_counting) = state.interpolated_str_brace_stack.last_mut() {
*brace_counting += 1;
}
"bracket"
}
rhai::Token::RightBrace => {
if let Some(brace_counting) = state.interpolated_str_brace_stack.last_mut() {
*brace_counting -= 1;
if *brace_counting == 0 {
state.interpolated_str_brace_stack.pop();
state.token_state.is_within_text_terminated_by = Some("`".into());
}
}
"bracket"
}
rhai::Token::LeftParen => "bracket",
rhai::Token::RightParen => "bracket",
rhai::Token::LeftBracket => "bracket",
rhai::Token::RightBracket => "bracket",
rhai::Token::QuestionBracket => "bracket",
rhai::Token::Unit => "bracket", // empty fn parens are parsed as this
rhai::Token::Plus => "operator",
rhai::Token::UnaryPlus => "operator",
rhai::Token::Minus => "operator",
rhai::Token::UnaryMinus => "operator",
rhai::Token::Multiply => "operator",
rhai::Token::Divide => "operator",
rhai::Token::Modulo => "operator",
rhai::Token::PowerOf => "operator",
rhai::Token::LeftShift => "operator",
rhai::Token::RightShift => "operator",
rhai::Token::SemiColon => "operator",
rhai::Token::Colon => "operator",
rhai::Token::DoubleColon => "operator",
rhai::Token::Comma => "operator",
rhai::Token::Period => "operator",
rhai::Token::ExclusiveRange => "operator",
rhai::Token::InclusiveRange => "operator",
rhai::Token::MapStart => "bracket",
rhai::Token::Equals => "operator",
rhai::Token::True => "builtin",
rhai::Token::False => "builtin",
rhai::Token::Let => "keyword",
rhai::Token::Const => "keyword",
rhai::Token::If => "keyword",
rhai::Token::Else => "keyword",
rhai::Token::While => "keyword",
rhai::Token::Loop => "keyword",
rhai::Token::For => "keyword",
rhai::Token::In => "keyword",
rhai::Token::NotIn => "keyword",
rhai::Token::LessThan => "operator",
rhai::Token::GreaterThan => "operator",
rhai::Token::LessThanEqualsTo => "operator",
rhai::Token::GreaterThanEqualsTo => "operator",
rhai::Token::EqualsTo => "operator",
rhai::Token::NotEqualsTo => "operator",
rhai::Token::Bang => "operator",
rhai::Token::Elvis => "operator",
rhai::Token::DoubleQuestion => "operator",
rhai::Token::Pipe => "operator",
rhai::Token::Or => "operator",
rhai::Token::XOr => "operator",
rhai::Token::Ampersand => "operator",
rhai::Token::And => "operator",
rhai::Token::Fn => "keyword",
rhai::Token::Continue => "keyword",
rhai::Token::Break => "keyword",
rhai::Token::Return => "keyword",
rhai::Token::Throw => "keyword",
rhai::Token::PlusAssign => "operator",
rhai::Token::MinusAssign => "operator",
rhai::Token::MultiplyAssign => "operator",
rhai::Token::DivideAssign => "operator",
rhai::Token::LeftShiftAssign => "operator",
rhai::Token::RightShiftAssign => "operator",
rhai::Token::AndAssign => "operator",
rhai::Token::OrAssign => "operator",
rhai::Token::XOrAssign => "operator",
rhai::Token::ModuloAssign => "operator",
rhai::Token::PowerOfAssign => "operator",
rhai::Token::Private => "keyword",
// Import/Export/As tokens not available in this Rhai version
rhai::Token::DoubleArrow => "operator",
rhai::Token::Underscore => "operator",
rhai::Token::Switch => "keyword",
rhai::Token::Do => "keyword",
rhai::Token::Until => "keyword",
rhai::Token::Try => "keyword",
rhai::Token::Catch => "keyword",
rhai::Token::Comment(_) => "comment",
rhai::Token::LexError(e) => {
console::log_1(&JsValue::from_str(&format!("LexError: {}", e)));
"error"
}
rhai::Token::Reserved(_) => "keyword",
// Custom token not available in this Rhai version
rhai::Token::EOF => return Ok(None),
token @ _ => {
console::log_1(&JsValue::from_str(&format!("Unhandled token {:?}", token)));
"error"
}
};
match &next_token {
rhai::Token::Fn | rhai::Token::Let | rhai::Token::Const | rhai::Token::For => {
state.is_defining_identifier = true;
}
rhai::Token::Comment(_) => {}
_ => {
state.is_defining_identifier = false;
}
};
Ok(Some(res.to_owned()))
}
fn indent(mode: &RhaiMode, state: &State, text_after: String) -> Option<u32> {
let should_dedent = || {
text_after
.trim_start()
.starts_with(['}', ']', ')'].as_ref())
};
#[allow(clippy::collapsible_if)]
if state.unclosed_bracket_count > 0 {
if should_dedent() {
Some(state.line_indent)
} else {
Some(state.line_indent + mode.indent_unit)
}
} else {
if should_dedent() {
Some(state.line_indent.saturating_sub(mode.indent_unit))
} else {
None
}
}
}

28
rust/runner/Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "runner"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
minimax = { workspace = true }
anyhow = { workspace = true }
rand = { workspace = true }
itertools = { workspace = true }
wasm-bindgen = { workspace = true }
js-sys = { workspace = true }
web-sys = { workspace = true }
console_error_panic_hook = { workspace = true }
wee_alloc = { workspace = true }
serde = { workspace = true }
serde-wasm-bindgen = { workspace = true }
rhai = { workspace = true }
getrandom = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
rhai = { workspace = true, features = ["wasm-bindgen"] }
getrandom = { workspace = true, features = ["js"] }

30
rust/runner/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "script-runner-wasm",
"version": "0.1.0",
"description": "Rust WASM script runner",
"main": "pkg/script_runner.js",
"types": "pkg/script_runner.d.ts",
"files": [
"pkg"
],
"scripts": {
"build": "wasm-pack build --target web --out-dir pkg",
"build:nodejs": "wasm-pack build --target nodejs --out-dir pkg-node",
"build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler",
"dev": "wasm-pack build --dev --target web --out-dir pkg"
},
"repository": {
"type": "git",
"url": "."
},
"keywords": [
"wasm",
"rust",
"script-runner"
],
"author": "",
"license": "MIT",
"devDependencies": {
"wasm-pack": "^0.12.1"
}
}

12
rust/runner/src/ansi.rs Normal file
View File

@@ -0,0 +1,12 @@
#![expect(clippy::allow_attributes)]
#![allow(dead_code)]
pub const RESET: &str = "\x1b[0m";
pub const RED: &str = "\x1b[31m";
pub const BLUE: &str = "\x1b[34m";
pub const MAGENTA: &str = "\x1b[35m";
pub const UP: &str = "\x1b[A";
pub const DOWN: &str = "\x1b[B";
pub const LEFT: &str = "\x1b[D";
pub const RIGHT: &str = "\x1b[C";

View File

@@ -0,0 +1,421 @@
use std::cmp::Ordering;
use minimax::{
agents::{Agent, RhaiAgent},
game::Board,
};
use rand::{rngs::StdRng, SeedableRng};
use rhai::ParseError;
use wasm_bindgen::prelude::*;
use crate::ansi;
#[derive(Debug, Clone, Copy)]
enum GameState {
/// Round 1, red is maximizing
Red,
/// Round 2, red is minimizing (and goes second)
Blue { red_score: f32 },
/// Game over, red won
RedWins { blue_score: f32 },
/// Game over, blue won
BlueWins { blue_score: f32 },
/// Game over, draw
DrawScore { score: f32 },
/// Invalid board, draw
DrawInvalid,
/// Error, end early
Error,
}
#[wasm_bindgen]
pub struct MinMaxGame {
red_agent: RhaiAgent<StdRng>,
blue_agent: RhaiAgent<StdRng>,
board: Board,
is_red_turn: bool,
is_first_print: bool,
state: GameState,
game_state_callback: Box<dyn Fn(&str) + 'static>,
}
#[wasm_bindgen]
impl MinMaxGame {
#[wasm_bindgen(constructor)]
pub fn new(
red_script: &str,
red_print_callback: js_sys::Function,
red_debug_callback: js_sys::Function,
blue_script: &str,
blue_print_callback: js_sys::Function,
blue_debug_callback: js_sys::Function,
game_state_callback: js_sys::Function,
) -> Result<MinMaxGame, String> {
Self::new_native(
red_script,
move |s| {
let _ = red_print_callback.call1(&JsValue::null(), &JsValue::from_str(s));
},
move |s| {
let _ = red_debug_callback.call1(&JsValue::null(), &JsValue::from_str(s));
},
blue_script,
move |s| {
let _ = blue_print_callback.call1(&JsValue::null(), &JsValue::from_str(s));
},
move |s| {
let _ = blue_debug_callback.call1(&JsValue::null(), &JsValue::from_str(s));
},
move |s| {
let _ = game_state_callback.call1(&JsValue::null(), &JsValue::from_str(s));
},
)
.map_err(|x| format!("Error at {}: {}", x.1, x.0))
}
fn new_native(
red_script: &str,
red_print_callback: impl Fn(&str) + 'static,
red_debug_callback: impl Fn(&str) + 'static,
blue_script: &str,
blue_print_callback: impl Fn(&str) + 'static,
blue_debug_callback: impl Fn(&str) + 'static,
game_state_callback: impl Fn(&str) + 'static,
) -> Result<MinMaxGame, ParseError> {
console_error_panic_hook::set_once();
let mut seed1 = [0u8; 32];
let mut seed2 = [0u8; 32];
getrandom::getrandom(&mut seed1).unwrap();
getrandom::getrandom(&mut seed2).unwrap();
Ok(MinMaxGame {
board: Board::new(),
is_first_print: true,
is_red_turn: true,
state: GameState::Red,
red_agent: RhaiAgent::new(
red_script,
StdRng::from_seed(seed1),
red_print_callback,
red_debug_callback,
)?,
blue_agent: RhaiAgent::new(
blue_script,
StdRng::from_seed(seed2),
blue_print_callback,
blue_debug_callback,
)?,
game_state_callback: Box::new(game_state_callback),
})
}
/// Is this game over for any reason?
#[wasm_bindgen]
pub fn is_done(&self) -> bool {
match self.state {
GameState::DrawScore { .. }
| GameState::DrawInvalid
| GameState::Error
| GameState::BlueWins { .. }
| GameState::RedWins { .. } => true,
_ => false,
}
}
#[wasm_bindgen]
pub fn red_won(&self) -> Option<bool> {
match self.state {
GameState::DrawScore { .. } => Some(false),
GameState::DrawInvalid { .. } => Some(false),
GameState::BlueWins { .. } => Some(false),
GameState::RedWins { .. } => Some(true),
_ => None,
}
}
#[wasm_bindgen]
pub fn blue_won(&self) -> Option<bool> {
match self.state {
GameState::DrawScore { .. } => Some(false),
GameState::DrawInvalid { .. } => Some(false),
GameState::BlueWins { .. } => Some(true),
GameState::RedWins { .. } => Some(false),
_ => None,
}
}
#[wasm_bindgen]
pub fn is_draw_score(&self) -> Option<bool> {
match self.state {
GameState::DrawScore { .. } => Some(true),
GameState::DrawInvalid { .. } => Some(false),
GameState::BlueWins { .. } => Some(false),
GameState::RedWins { .. } => Some(false),
_ => None,
}
}
#[wasm_bindgen]
pub fn is_draw_invalid(&self) -> Option<bool> {
match self.state {
GameState::DrawScore { .. } => Some(false),
GameState::DrawInvalid { .. } => Some(true),
GameState::BlueWins { .. } => Some(false),
GameState::RedWins { .. } => Some(false),
_ => None,
}
}
#[wasm_bindgen]
pub fn is_error(&self) -> bool {
match self.state {
GameState::Error => true,
_ => false,
}
}
// Play one turn
#[wasm_bindgen]
pub fn step(&mut self) -> Result<(), String> {
if self.is_first_print {
self.print_board("", "");
(self.game_state_callback)("\r\n");
}
let action = match (self.state, self.is_red_turn) {
(GameState::Blue { .. }, false) => self.blue_agent.step_max(&self.board),
(GameState::Red, false) => self.blue_agent.step_min(&self.board),
(GameState::Blue { .. }, true) => self.red_agent.step_min(&self.board),
(GameState::Red, true) => self.red_agent.step_max(&self.board),
// Game is done, do nothing
(GameState::Error, _)
| (GameState::BlueWins { .. }, _)
| (GameState::RedWins { .. }, _)
| (GameState::DrawInvalid, _)
| (GameState::DrawScore { .. }, _) => return Ok(()),
}
.map_err(|err| format!("{err}"))?;
let player = self.is_red_turn.then_some("Red").unwrap_or("Blue");
let player_name = self
.is_red_turn
.then_some(self.red_agent.name())
.unwrap_or(self.blue_agent.name());
if !self.board.play(action, player) {
self.state = GameState::Error;
return Err(format!(
"{player} ({player_name}) made an invalid move {action}",
));
}
self.print_board(
self.is_red_turn.then_some(ansi::RED).unwrap_or(ansi::BLUE),
self.is_red_turn.then_some("Red").unwrap_or("Blue"),
);
(self.game_state_callback)("\n\r");
if !self.board.is_full() && !self.board.is_stuck() {
// This was not the last move
self.is_red_turn = !self.is_red_turn;
} else {
// This was the last move
// Close board
(self.game_state_callback)(&format!(
"{}{}\n\r",
" ".repeat(6),
" ".repeat(self.board.size())
));
// Evaluate board and update state
match (self.state, self.board.evaluate()) {
// Start next round
(GameState::Red, Some(red_score)) => {
self.board = Board::new();
self.is_first_print = true;
self.is_red_turn = false;
self.state = GameState::Blue { red_score }
}
// Game over
(GameState::Blue { red_score }, Some(blue_score)) => {
self.state = match red_score.total_cmp(&blue_score) {
Ordering::Equal => GameState::DrawScore { score: red_score },
Ordering::Greater => GameState::RedWins { blue_score },
Ordering::Less => GameState::BlueWins { blue_score },
}
}
// Could not evaluate board, tie by default
(GameState::Red, None) | (GameState::Blue { .. }, None) => {
self.state = GameState::DrawInvalid
}
// Other code should make sure this never happens
(GameState::BlueWins { .. }, _)
| (GameState::RedWins { .. }, _)
| (GameState::DrawInvalid, _)
| (GameState::DrawScore { .. }, _)
| (GameState::Error, _) => unreachable!(),
}
if self.board.is_stuck() {
self.state = GameState::DrawInvalid;
}
// Print depending on new state
match self.state {
GameState::DrawScore { score } => {
(self.game_state_callback)(&format!("Tie! Score: {score:.2}\n\r"));
}
GameState::DrawInvalid => {
(self.game_state_callback)(&format!("Tie, invalid board!\n\r"));
}
GameState::RedWins { blue_score, .. } => {
(self.game_state_callback)(&format!(
"{}Blue score:{} {blue_score:.2}\n\r",
ansi::BLUE,
ansi::RESET,
));
(self.game_state_callback)(&format!(
"{}Red wins!{}\n\r",
ansi::RED,
ansi::RESET,
));
}
GameState::BlueWins { blue_score, .. } => {
(self.game_state_callback)(&format!(
"{}Blue score:{} {blue_score:.2}\n\r",
ansi::BLUE,
ansi::RESET,
));
(self.game_state_callback)(&format!(
"{}Blue wins!{}\n\r",
ansi::BLUE,
ansi::RESET,
));
}
GameState::Blue { red_score } => {
(self.game_state_callback)(&format!(
"{}Red score:{} {red_score:.2}\n\r",
ansi::RED,
ansi::RESET,
));
}
// Other code should make sure this never happens
GameState::Error | GameState::Red => unreachable!(),
}
(self.game_state_callback)("\r\n");
}
return Ok(());
}
fn print_board(&mut self, color: &str, player: &str) {
let board_label = format!("{}{:<6}{}", color, player, ansi::RESET);
(self.game_state_callback)(&format!(
"\r{}{}{}{}",
board_label,
if self.is_first_print { '╓' } else { '║' },
self.board.prettyprint(),
if self.is_first_print { '╖' } else { '║' },
));
self.is_first_print = false;
}
}
//
// MARK: tests
//
// TODO:
// - infinite loop
// - random is different
// - incorrect return type
// - globals
#[test]
fn full_random() {
const SCRIPT: &str = r#"
fn random_action(board) {
let symb = rand_symb();
let pos = rand_int(0, 10);
let action = Action(symb, pos);
while !board.can_play(action) {
let symb = rand_symb();
let pos = rand_int(0, 10);
action = Action(symb, pos);
}
return action
}
fn step_min(board) {
random_action(board)
}
fn step_max(board) {
random_action(board)
}
"#;
let mut game =
MinMaxGame::new_native(&SCRIPT, |_| {}, |_| {}, &SCRIPT, |_| {}, |_| {}, |_| {}).unwrap();
let mut n = 0;
while !game.is_done() {
println!("{:?}", game.step());
println!("{:?}", game.board);
n += 1;
assert!(n < 10);
}
}
#[test]
fn infinite_loop() {
const SCRIPT: &str = r#"
fn step_min(board) {
loop {}
}
fn step_max(board) {
loop {}
}
"#;
let mut game =
MinMaxGame::new_native(&SCRIPT, |_| {}, |_| {}, &SCRIPT, |_| {}, |_| {}, |_| {}).unwrap();
while !game.is_done() {
println!("{:?}", game.step());
println!("{:?}", game.board);
}
}

View File

@@ -0,0 +1,288 @@
use minimax::{
agents::{Agent, RhaiAgent},
game::Board,
};
use rand::{rngs::StdRng, SeedableRng};
use rhai::ParseError;
use wasm_bindgen::prelude::*;
use crate::{ansi, terminput::TermInput};
#[wasm_bindgen]
pub struct GameStateHuman {
/// Red player
human: TermInput,
// Blue player
agent: RhaiAgent<StdRng>,
board: Board,
is_red_turn: bool,
is_first_turn: bool,
is_error: bool,
red_score: Option<f32>,
game_state_callback: Box<dyn Fn(&str) + 'static>,
}
#[wasm_bindgen]
impl GameStateHuman {
#[wasm_bindgen(constructor)]
pub fn new(
max_script: &str,
max_print_callback: js_sys::Function,
max_debug_callback: js_sys::Function,
game_state_callback: js_sys::Function,
) -> Result<GameStateHuman, String> {
Self::new_native(
max_script,
move |s| {
let _ = max_print_callback.call1(&JsValue::null(), &JsValue::from_str(s));
},
move |s| {
let _ = max_debug_callback.call1(&JsValue::null(), &JsValue::from_str(s));
},
move |s| {
let _ = game_state_callback.call1(&JsValue::null(), &JsValue::from_str(s));
},
)
.map_err(|x| format!("Error at {}: {}", x.1, x.0))
}
fn new_native(
max_script: &str,
max_print_callback: impl Fn(&str) + 'static,
max_debug_callback: impl Fn(&str) + 'static,
game_state_callback: impl Fn(&str) + 'static,
) -> Result<Self, ParseError> {
console_error_panic_hook::set_once();
let mut seed1 = [0u8; 32];
let mut seed2 = [0u8; 32];
getrandom::getrandom(&mut seed1).unwrap();
getrandom::getrandom(&mut seed2).unwrap();
Ok(Self {
board: Board::new(),
is_red_turn: true,
is_first_turn: true,
is_error: false,
red_score: None,
human: TermInput::new(ansi::RED.to_string()),
agent: RhaiAgent::new(
max_script,
StdRng::from_seed(seed1),
max_print_callback,
max_debug_callback,
)?,
game_state_callback: Box::new(game_state_callback),
})
}
#[wasm_bindgen]
pub fn is_done(&self) -> bool {
(self.board.is_full() && self.red_score.is_some()) || self.is_error
}
#[wasm_bindgen]
pub fn is_error(&self) -> bool {
self.is_error
}
#[wasm_bindgen]
pub fn print_start(&mut self) -> Result<(), String> {
self.print_board("", "");
(self.game_state_callback)("\r\n");
if !self.is_red_turn {
let action = {
if self.red_score.is_none() {
self.agent.step_min(&self.board)
} else {
self.agent.step_max(&self.board)
}
}
.map_err(|err| format!("{err}"))?;
if !self.board.play(action, "Blue") {
self.is_error = true;
return Err(format!(
"{} ({}) made an invalid move {}",
self.is_red_turn.then_some("Red").unwrap_or("Blue"),
self.is_red_turn
.then_some("Human")
.unwrap_or(self.agent.name()),
action
));
}
self.print_board(
self.is_red_turn.then_some(ansi::RED).unwrap_or(ansi::BLUE),
self.is_red_turn.then_some("Red").unwrap_or("Blue"),
);
(self.game_state_callback)("\r\n");
self.is_red_turn = true;
}
self.print_ui();
return Ok(());
}
#[wasm_bindgen]
pub fn print_board(&mut self, color: &str, player: &str) {
let board_label = format!("{}{:<6}{}", color, player, ansi::RESET);
// Print board
(self.game_state_callback)(&format!(
"\r{}{}{}{}",
board_label,
if self.is_first_turn { '╓' } else { '║' },
self.board.prettyprint(),
if self.is_first_turn { '╖' } else { '║' },
));
self.is_first_turn = false;
}
#[wasm_bindgen]
pub fn print_ui(&mut self) {
(self.game_state_callback)(
&self
.human
.print_state(&self.board, self.red_score.is_some()),
);
}
#[wasm_bindgen]
pub fn take_input(&mut self, data: String) -> Result<(), String> {
self.human.process_input(&self.board, data);
self.print_ui();
if let Some(action) = self.human.pop_action() {
if !self.board.play(action, "Red") {
self.is_error = true;
return Err(format!(
"{} ({}) made an invalid move {}",
self.is_red_turn.then_some("Red").unwrap_or("Blue"),
self.is_red_turn
.then_some("Human")
.unwrap_or(self.agent.name()),
action
));
}
self.is_red_turn = false;
if self.board.is_full() {
self.print_end()?;
return Ok(());
}
self.print_board(ansi::RED, "Red");
(self.game_state_callback)("\r\n");
let action = {
if self.red_score.is_none() {
self.agent.step_min(&self.board)
} else {
self.agent.step_max(&self.board)
}
}
.map_err(|err| format!("{err}"))?;
if !self.board.play(action, "Blue") {
self.is_error = true;
return Err(format!(
"{} ({}) made an invalid move {}",
self.is_red_turn.then_some("Red").unwrap_or("Blue"),
self.is_red_turn
.then_some("Human")
.unwrap_or(self.agent.name()),
action
));
}
self.is_red_turn = true;
if self.board.is_full() {
self.print_end()?;
return Ok(());
}
self.print_board(ansi::BLUE, "Blue");
(self.game_state_callback)("\r\n");
self.print_ui();
}
return Ok(());
}
fn print_end(&mut self) -> Result<(), String> {
let board_label = format!(
"{}{:<6}{}",
self.is_red_turn.then_some(ansi::BLUE).unwrap_or(ansi::RED),
self.is_red_turn.then_some("Blue").unwrap_or("Red"),
ansi::RESET
);
(self.game_state_callback)(&format!("\r{}{}", board_label, self.board.prettyprint()));
(self.game_state_callback)("\r\n");
(self.game_state_callback)(&format!(
"\r{}{}",
" ",
" ".repeat(self.board.size())
));
(self.game_state_callback)("\r\n");
let score = self.board.evaluate().unwrap();
(self.game_state_callback)(&format!(
"\r\n{}{} score:{} {:.2}\r\n",
self.red_score
.is_none()
.then_some(ansi::RED)
.unwrap_or(ansi::BLUE),
self.red_score.is_none().then_some("Red").unwrap_or("Blue"),
ansi::RESET,
score
));
(self.game_state_callback)("\r\n");
match self.red_score {
// Start second round
None => {
let mut seed1 = [0u8; 32];
let mut seed2 = [0u8; 32];
getrandom::getrandom(&mut seed1).unwrap();
getrandom::getrandom(&mut seed2).unwrap();
self.board = Board::new();
self.is_red_turn = false;
self.is_first_turn = true;
self.is_error = false;
self.human = TermInput::new(ansi::RED.to_string());
self.red_score = Some(score);
self.print_start()?;
}
// End game
Some(red_score) => {
if red_score == score {
(self.game_state_callback)(&format!("Tie! Score: {:.2}\r\n", score));
} else {
let red_wins = red_score > score;
(self.game_state_callback)(&format!(
"{}{} wins!{}",
red_wins.then_some(ansi::RED).unwrap_or(ansi::BLUE),
red_wins.then_some("Red").unwrap_or("Blue"),
ansi::RESET,
));
}
}
}
return Ok(());
}
}

14
rust/runner/src/lib.rs Normal file
View File

@@ -0,0 +1,14 @@
use wasm_bindgen::prelude::*;
mod ansi;
mod gamestate;
mod gamestatehuman;
mod terminput;
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
pub fn init_panic_hook() {
console_error_panic_hook::set_once();
}

View File

@@ -0,0 +1,181 @@
use itertools::Itertools;
use minimax::game::{Board, PlayerAction, Symb};
use crate::ansi::{DOWN, LEFT, RESET, RIGHT, UP};
struct SymbolSelector {
symbols: Vec<char>,
cursor: usize,
}
impl SymbolSelector {
fn new(symbols: Vec<char>) -> Self {
Self { symbols, cursor: 0 }
}
fn current(&self) -> char {
self.symbols[self.cursor]
}
fn check(&mut self, board: &Board) {
while board.contains(Symb::from_char(self.current()).unwrap()) {
if self.cursor == 0 {
self.cursor = self.symbols.len() - 1;
} else {
self.cursor -= 1;
}
}
}
fn down(&mut self, board: &Board) {
if self.cursor == 0 {
self.cursor = self.symbols.len() - 1;
} else {
self.cursor -= 1;
}
while board.contains(Symb::from_char(self.current()).unwrap()) {
if self.cursor == 0 {
self.cursor = self.symbols.len() - 1;
} else {
self.cursor -= 1;
}
}
}
fn up(&mut self, board: &Board) {
if self.cursor == self.symbols.len() - 1 {
self.cursor = 0;
} else {
self.cursor += 1;
}
while board.contains(Symb::from_char(self.current()).unwrap()) {
if self.cursor == self.symbols.len() - 1 {
self.cursor = 0;
} else {
self.cursor += 1;
}
}
}
}
pub struct TermInput {
player_color: String,
cursor: usize,
symbol_selector: SymbolSelector,
/// Set to Some() when the player selects an action.
/// Should be cleared and applied immediately.
queued_action: Option<PlayerAction>,
}
impl TermInput {
pub fn new(player_color: String) -> Self {
Self {
cursor: 0,
queued_action: None,
player_color,
symbol_selector: SymbolSelector::new(vec![
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '+', '-', '×', '÷',
]),
}
}
pub fn pop_action(&mut self) -> Option<PlayerAction> {
self.queued_action.take()
}
pub fn print_state(&mut self, board: &Board, minimize: bool) -> String {
let cursor_max = board.size() - 1;
self.symbol_selector.check(board);
let board_label = format!(
"{}{:<6}{RESET}",
self.player_color,
if minimize { "Min" } else { "Max" },
);
return format!(
"\r{}{}{}{}{RESET}{}{}",
board_label,
// Cursor
" ".repeat(self.cursor),
self.player_color,
if board.is_full() {
' '
} else {
self.symbol_selector.current()
},
// RESET
" ".repeat(cursor_max - self.cursor),
self.symbol_selector
.symbols
.iter()
.map(|x| {
if board.contains(Symb::from_char(*x).unwrap()) {
" ".to_string()
} else if *x == self.symbol_selector.current() {
format!("{}{x}{RESET}", self.player_color,)
} else {
format!("{x}",)
}
})
.join("")
);
}
pub fn process_input(&mut self, board: &Board, data: String) {
let cursor_max = board.size() - 1;
self.symbol_selector.check(board);
match &data[..] {
RIGHT => {
self.cursor = cursor_max.min(self.cursor + 1);
}
LEFT => {
if self.cursor != 0 {
self.cursor -= 1;
}
}
UP => {
self.symbol_selector.up(board);
}
DOWN => {
self.symbol_selector.down(board);
}
" " | "\n" | "\r" => {
let symb = Symb::from_char(self.symbol_selector.current());
if let Some(symb) = symb {
let action = PlayerAction {
symb,
pos: self.cursor,
};
if board.can_play(&action) {
self.queued_action = Some(action);
}
}
}
c => {
let symb = Symb::from_str(c);
if let Some(symb) = symb {
let action = PlayerAction {
symb,
pos: self.cursor,
};
if board.can_play(&action) {
self.queued_action = Some(action);
}
}
}
};
}
}

1
rust/rustfmt.toml Normal file
View File

@@ -0,0 +1 @@
hard_tabs = true