From 19f523d0eda7dc8d4db10674caf99eabbfe05d04 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Sat, 1 Nov 2025 17:17:13 -0700 Subject: [PATCH] Add Rust: `minimax`, `runner`, and codelens highlighter --- build.sh | 12 + rust/Cargo.lock | 615 ++++++++++++++++++++++++ rust/Cargo.toml | 41 ++ rust/minimax/Cargo.toml | 17 + rust/minimax/src/agents/mod.rs | 18 + rust/minimax/src/agents/rhai.rs | 308 ++++++++++++ rust/minimax/src/game/action.rs | 70 +++ rust/minimax/src/game/board.rs | 632 +++++++++++++++++++++++++ rust/minimax/src/game/mod.rs | 9 + rust/minimax/src/game/symb.rs | 120 +++++ rust/minimax/src/game/tree.rs | 143 ++++++ rust/minimax/src/lib.rs | 2 + rust/rhai-codemirror/Cargo.toml | 21 + rust/rhai-codemirror/src/codemirror.rs | 64 +++ rust/rhai-codemirror/src/lib.rs | 20 + rust/rhai-codemirror/src/rhai_mode.rs | 353 ++++++++++++++ rust/runner/Cargo.toml | 28 ++ rust/runner/package.json | 30 ++ rust/runner/src/ansi.rs | 12 + rust/runner/src/gamestate.rs | 421 ++++++++++++++++ rust/runner/src/gamestatehuman.rs | 288 +++++++++++ rust/runner/src/lib.rs | 14 + rust/runner/src/terminput.rs | 181 +++++++ rust/rustfmt.toml | 1 + 24 files changed, 3420 insertions(+) create mode 100644 build.sh create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/minimax/Cargo.toml create mode 100644 rust/minimax/src/agents/mod.rs create mode 100644 rust/minimax/src/agents/rhai.rs create mode 100644 rust/minimax/src/game/action.rs create mode 100644 rust/minimax/src/game/board.rs create mode 100644 rust/minimax/src/game/mod.rs create mode 100644 rust/minimax/src/game/symb.rs create mode 100644 rust/minimax/src/game/tree.rs create mode 100644 rust/minimax/src/lib.rs create mode 100644 rust/rhai-codemirror/Cargo.toml create mode 100644 rust/rhai-codemirror/src/codemirror.rs create mode 100644 rust/rhai-codemirror/src/lib.rs create mode 100644 rust/rhai-codemirror/src/rhai_mode.rs create mode 100644 rust/runner/Cargo.toml create mode 100644 rust/runner/package.json create mode 100644 rust/runner/src/ansi.rs create mode 100644 rust/runner/src/gamestate.rs create mode 100644 rust/runner/src/gamestatehuman.rs create mode 100644 rust/runner/src/lib.rs create mode 100644 rust/runner/src/terminput.rs create mode 100644 rust/rustfmt.toml diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..b602b76 --- /dev/null +++ b/build.sh @@ -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 diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..bb60805 --- /dev/null +++ b/rust/Cargo.lock @@ -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", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..cd3ce43 --- /dev/null +++ b/rust/Cargo.toml @@ -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" diff --git a/rust/minimax/Cargo.toml b/rust/minimax/Cargo.toml new file mode 100644 index 0000000..383f9a0 --- /dev/null +++ b/rust/minimax/Cargo.toml @@ -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 } diff --git a/rust/minimax/src/agents/mod.rs b/rust/minimax/src/agents/mod.rs new file mode 100644 index 0000000..2c8afc2 --- /dev/null +++ b/rust/minimax/src/agents/mod.rs @@ -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; + + /// Try to maximize the value of a board. + fn step_max(&mut self, board: &Board) -> Result; +} diff --git a/rust/minimax/src/agents/rhai.rs b/rust/minimax/src/agents/rhai.rs new file mode 100644 index 0000000..ed68aac --- /dev/null +++ b/rust/minimax/src/agents/rhai.rs @@ -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::() + .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 { + #[expect(dead_code)] + rng: Arc>, + + engine: Engine, + script: AST, + scope: Scope<'static>, + print_callback: Arc, +} + +impl RhaiAgent { + pub fn new( + script: &str, + rng: R, + print_callback: impl Fn(&str) + 'static, + debug_callback: impl Fn(&str) + 'static, + ) -> Result { + 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| { + 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, size: i64| -> Result> { + 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::() + .build_type::() + .build_type::>>(); + 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 Agent for RhaiAgent { + type ErrorType = EvalAltResult; + + fn name(&self) -> &'static str { + "Rhai" + } + + fn step_min(&mut self, board: &Board) -> Result { + let res = self.engine.call_fn_with_options::( + 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 { + let res = self.engine.call_fn_with_options::( + 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> { + inner: Arc>, + } + + impl> RhaiPer { + pub fn new(inner: Permutations) -> Self { + Self { + inner: Arc::new(inner), + } + } + } + + impl> IntoIterator for RhaiPer { + type Item = Vec; + type IntoIter = Permutations; + + fn into_iter(self) -> Self::IntoIter { + (*self.inner).clone() + } + } + + impl> Clone for RhaiPer { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } + } + + impl + 'static> CustomType for RhaiPer { + fn build(mut builder: TypeBuilder) { + 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()); + } + } +} diff --git a/rust/minimax/src/game/action.rs b/rust/minimax/src/game/action.rs new file mode 100644 index 0000000..4efad0f --- /dev/null +++ b/rust/minimax/src/game/action.rs @@ -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(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) { + builder + .with_name("Action") + .with_fn( + "Action", + |symb: &str, pos: i64| -> Result> { + 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> { + 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); + } +} diff --git a/rust/minimax/src/game/board.rs b/rust/minimax/src/game/board.rs new file mode 100644 index 0000000..290e40d --- /dev/null +++ b/rust/minimax/src/game/board.rs @@ -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 { + 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; 11], + placed_by: [Option; 11], + + /// Number of Nones in `board` + free_spots: usize, + + /// Index of the last board index that was changed + last_placed: Option, +} + +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; 11] { + &self.board + } + + pub fn get_board_mut(&mut self) -> &mut [Option; 11] { + &mut self.board + } + + /// Get the index of the ith empty slot + pub fn ith_empty_slot(&self, mut idx: usize) -> Option { + 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 { + 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) -> 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 { + 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 { + 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 { + self.to_tree()?.evaluate() + } + + pub fn from_board(board: [Option; 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 { + 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::>(); + + 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; + + fn into_iter(self) -> Self::IntoIter { + self.board + .iter() + .map(|x| x.map(|x| x.to_string()).unwrap_or_default()) + .collect::>() + .into_iter() + } +} + +impl CustomType for Board { + fn build(mut builder: TypeBuilder) { + 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::>() + }) + .with_indexer_get( + |s: &mut Self, idx: i64| -> Result> { + 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> { + 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> { + 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> { + 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(()); + }, + ); + } +} diff --git a/rust/minimax/src/game/mod.rs b/rust/minimax/src/game/mod.rs new file mode 100644 index 0000000..99150eb --- /dev/null +++ b/rust/minimax/src/game/mod.rs @@ -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; diff --git a/rust/minimax/src/game/symb.rs b/rust/minimax/src/game/symb.rs new file mode 100644 index 0000000..8e506de --- /dev/null +++ b/rust/minimax/src/game/symb.rs @@ -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(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(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 { + 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 { + if s.chars().count() != 1 { + return None; + } + + Self::from_char(s.chars().next()?) + } + + pub const fn from_char(c: char) -> Option { + 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, + } + } +} diff --git a/rust/minimax/src/game/tree.rs b/rust/minimax/src/game/tree.rs new file mode 100644 index 0000000..93e67af --- /dev/null +++ b/rust/minimax/src/game/tree.rs @@ -0,0 +1,143 @@ +use std::fmt::{Debug, Display}; + +#[derive(PartialEq, Clone)] +pub enum TreeElement { + Partial(String), + Number(f32), + Add { + l: Box, + r: Box, + }, + Sub { + l: Box, + r: Box, + }, + Mul { + l: Box, + r: Box, + }, + Div { + l: Box, + r: Box, + }, + Neg { + r: Box, + }, +} + +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 { + 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) + } + } + } +} diff --git a/rust/minimax/src/lib.rs b/rust/minimax/src/lib.rs new file mode 100644 index 0000000..13042eb --- /dev/null +++ b/rust/minimax/src/lib.rs @@ -0,0 +1,2 @@ +pub mod agents; +pub mod game; diff --git a/rust/rhai-codemirror/Cargo.toml b/rust/rhai-codemirror/Cargo.toml new file mode 100644 index 0000000..c9280c1 --- /dev/null +++ b/rust/rhai-codemirror/Cargo.toml @@ -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"] } diff --git a/rust/rhai-codemirror/src/codemirror.rs b/rust/rhai-codemirror/src/codemirror.rs new file mode 100644 index 0000000..6249f01 --- /dev/null +++ b/rust/rhai-codemirror/src/codemirror.rs @@ -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; +} diff --git a/rust/rhai-codemirror/src/lib.rs b/rust/rhai-codemirror/src/lib.rs new file mode 100644 index 0000000..6ee28b0 --- /dev/null +++ b/rust/rhai-codemirror/src/lib.rs @@ -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(()) +} diff --git a/rust/rhai-codemirror/src/rhai_mode.rs b/rust/rhai-codemirror/src/rhai_mode.rs new file mode 100644 index 0000000..61e1fe0 --- /dev/null +++ b/rust/rhai-codemirror/src/rhai_mode.rs @@ -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, + /// Interpolated string brace counting stack + interpolated_str_brace_stack: Vec, +} + +thread_local! { + static ELECTRIC_INPUT: RegExp = RegExp::new("^\\s*[}\\])]$", ""); + static LINE_COMMENT: JsValue = JsValue::from_str("//"); + static CODEMIRROR_PASS: RefCell = 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, 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, + stream: codemirror::StringStream, +} + +impl rhai::InputStream for StreamAdapter { + fn unget(&mut self, ch: char) { + self.buf = Some(ch); + } + + fn get_next(&mut self) -> Option { + 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 { + 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, 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 { + 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 + } + } +} diff --git a/rust/runner/Cargo.toml b/rust/runner/Cargo.toml new file mode 100644 index 0000000..fbc9c5c --- /dev/null +++ b/rust/runner/Cargo.toml @@ -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"] } diff --git a/rust/runner/package.json b/rust/runner/package.json new file mode 100644 index 0000000..2b438e8 --- /dev/null +++ b/rust/runner/package.json @@ -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" + } +} diff --git a/rust/runner/src/ansi.rs b/rust/runner/src/ansi.rs new file mode 100644 index 0000000..4358c27 --- /dev/null +++ b/rust/runner/src/ansi.rs @@ -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"; diff --git a/rust/runner/src/gamestate.rs b/rust/runner/src/gamestate.rs new file mode 100644 index 0000000..6f24e44 --- /dev/null +++ b/rust/runner/src/gamestate.rs @@ -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, + blue_agent: RhaiAgent, + + board: Board, + is_red_turn: bool, + is_first_print: bool, + state: GameState, + + game_state_callback: Box, +} + +#[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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/rust/runner/src/gamestatehuman.rs b/rust/runner/src/gamestatehuman.rs new file mode 100644 index 0000000..87097d2 --- /dev/null +++ b/rust/runner/src/gamestatehuman.rs @@ -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, + + board: Board, + is_red_turn: bool, + is_first_turn: bool, + is_error: bool, + red_score: Option, + + game_state_callback: Box, +} + +#[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 { + 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 { + 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(()); + } +} diff --git a/rust/runner/src/lib.rs b/rust/runner/src/lib.rs new file mode 100644 index 0000000..a052f19 --- /dev/null +++ b/rust/runner/src/lib.rs @@ -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(); +} diff --git a/rust/runner/src/terminput.rs b/rust/runner/src/terminput.rs new file mode 100644 index 0000000..b8b09d4 --- /dev/null +++ b/rust/runner/src/terminput.rs @@ -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, + cursor: usize, +} + +impl SymbolSelector { + fn new(symbols: Vec) -> 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, +} + +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 { + 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); + } + } + } + }; + } +} diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml new file mode 100644 index 0000000..218e203 --- /dev/null +++ b/rust/rustfmt.toml @@ -0,0 +1 @@ +hard_tabs = true