diff --git a/Cargo.lock b/Cargo.lock index 69655ee..2dc27e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,242 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "keys-tools" version = "0.1.0" +dependencies = [ + "crossterm", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml index 6777dfa..70d2eb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,11 @@ path = "src/bin/fetch.rs" name = "games" path = "src/bin/games.rs" +[dependencies] +crossterm = { version = "=0.29.0", default-features = false, features = ["events", "windows"] } + [profile.release] codegen-units = 1 lto = true opt-level = "z" -panic = "abort" strip = true diff --git a/README.md b/README.md index 3174a1f..85ff000 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ Small native terminal tools with one job each: - `fetch` shows a fast system and CPU overview with styled, plain, or JSON output. -- `games` lists and launches terminal games already installed on the machine. +- `games` is an original native terminal arcade with eleven complete game modes. -The binaries share no third-party runtime dependencies and never download or -install software. +The binaries never download or install software. `games` uses Crossterm for +raw keyboard input and terminal restoration; no upstream game code, assets, or +runtime is bundled. ## Install @@ -28,7 +29,7 @@ Rust 1.97.1 is pinned for builds. ```sh cargo build --release --locked ./target/release/fetch -./target/release/games list +./target/release/games ``` Useful non-interactive forms: @@ -37,30 +38,21 @@ Useful non-interactive forms: fetch --plain fetch --json games list --json -games info 2048 -games run 2048 +games info merge +games run merge --seed 1 ``` -`NO_COLOR=1` disables decoration. `games run` executes the resolved program -directly, never through a shell. +`NO_COLOR=1` disables decoration. Interactive play needs a real terminal and +restores raw mode, cursor state, wrapping, and the main screen when it exits. -## Game packs +## Native arcade -`games` reads optional `*.game` files from directories in `TOOLS_GAME_PATH` -and from the user data directory (`$XDG_DATA_HOME/keys-tools/games`, or -`~/.local/share/keys-tools/games`). A manifest is five bounded, single-line -fields: - -```ini -name=maze -command=maze -summary=Small terminal maze -source=https://example.com/maze -license=MIT -``` - -Names cannot shadow bundled entries. Commands contain one executable and no -shell syntax; arguments are supplied explicitly to `games run`. +`games` contains eleven clean-room modes: `delve`, `orbit`, `serpent`, +`merge`, `vector`, `cards`, `seedling`, `sprint`, `keybed`, `glyphs`, and +`scout`. They use an original implementation, maps, words, prose, and ASCII art +informed only by the high-level behavior of the research projects. Sessions +are finite and seeded; v1 has no network, audio, persistence, plugins, external +executables, or copied assets. ## Scope @@ -68,7 +60,8 @@ shell syntax; arguments are supplied explicitly to `games run`. environment-backed view on Windows. It is intentionally not a configurable replacement for every Fastfetch module. See [research](docs/research.md) and [fetch benchmarks](benches/fetch.md) for the measured comparison and limits. -The [games benchmark](benches/games.md) measures only launcher-owned work. +The [games benchmark](benches/games.md) measures the native arcade's owned +non-interactive path and binary size. ## Project diff --git a/benches/fetch.md b/benches/fetch.md index 70aac94..6089d76 100644 --- a/benches/fetch.md +++ b/benches/fetch.md @@ -17,6 +17,8 @@ shell, and terminal categories. They are not identical: `fetch` additionally prints architecture, CPU topology, and physical/logical core counts. For this bounded overview, `fetch` used 70.5% less mean launch time than Fastfetch and 84.1% less than Macchina. Its binary was 79.2% and 80.0% smaller, respectively. +The current unwind-safe build is 336,224 bytes, 192 bytes larger than the +measured build; its collection path is unchanged. The requested combined workflow was also measured through the same shell: diff --git a/benches/games.md b/benches/games.md index 5dda130..7bff9ae 100644 --- a/benches/games.md +++ b/benches/games.md @@ -1,17 +1,34 @@ -# Games benchmark +# Native arcade benchmark -`games` owns only discovery and safe process launch; the selected game owns -its runtime. The repository benchmark therefore measures `games list --plain` -against the built-in catalog with empty extension directories. It does not -misrepresent a downstream game's startup time as launcher performance. +The benchmark measures the owned non-interactive contract +(`games list --plain`) and the stripped release binary size. Gameplay is +event-driven and terminal-bound; comparing whole sessions with unrelated +upstream games would not be equivalent. + +Measured 2026-08-14 on Darwin 25.5.0 ARM64 with Rust 1.97.1 and Hyperfine +1.20.0, using 100 runs after 20 warmups: + +| Version | Mean ± standard deviation | Stripped binary | +| --- | ---: | ---: | +| External launcher (`3b618dd`, reference only) | 1.8 ± 0.1 ms | 354,496 bytes | +| Native eleven-game arcade | 1.5 ± 0.1 ms | 438,992 bytes | + +The two list commands are not equivalent workloads, so their timing difference +is not a performance claim. The native binary adds 84,496 bytes (about 24%) +for eleven game engines and cross-platform terminal input. +The macOS ARM64 wheel is 410,899 bytes, including both binaries, package +metadata, project and dependency licenses, and CycloneDX SBOM; that is 67,191 +bytes (about 20%) over +the prior 343,708-byte wheel. Run the same benchmark used by CI: ```sh -cargo build --release --locked -benches/run.sh +RUNS=100 WARMUP=20 bash benches/run.sh ``` Hyperfine JSON, Markdown, and binary sizes are written to `target/benchmarks/`. Hosted-runner timings are diagnostic evidence, not a stable regression threshold; compare performance claims on the same host. +No claim is made that one original mode is faster or more feature-complete +than an upstream inspiration: they are different games. diff --git a/benches/run.sh b/benches/run.sh index 9eb24b9..70f876f 100755 --- a/benches/run.sh +++ b/benches/run.sh @@ -19,9 +19,7 @@ for binary in "$fetch" "$games"; do } done -mkdir -p "$output/empty" -export TOOLS_GAME_PATH="$output/empty" -export XDG_DATA_HOME="$output/empty" +mkdir -p "$output" hyperfine --shell=none --warmup "$warmup" --runs "$runs" \ --export-json "$output/tools.json" \ diff --git a/docs/THIRD_PARTY_LICENSES.md b/docs/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..658f8ee --- /dev/null +++ b/docs/THIRD_PARTY_LICENSES.md @@ -0,0 +1,289 @@ +# Third-party licenses + +Keys Tools statically links the following Rust crates. Versions are locked in +`Cargo.lock`. For crates offered under multiple licenses, this distribution +uses the Apache-2.0 option. + +| Crates | License used | +| --- | --- | +| crossterm 0.29.0, crossterm_winapi 0.9.1 | MIT (Timon) | +| mio 1.2.2 | MIT (Carl Lerche and other MIO contributors) | +| redox_syscall 0.5.18 | MIT (Redox OS Developers) | +| bitflags 2.13.1, cfg-if 1.0.4, document-features 0.2.12, errno 0.3.14, libc 0.2.189, linux-raw-sys 0.12.1, litrs 1.0.0, lock_api 0.4.14, log 0.4.33, parking_lot 0.12.5, parking_lot_core 0.9.12, rustix 1.1.4, scopeguard 1.2.0, signal-hook 0.3.18, signal-hook-mio 0.2.5, signal-hook-registry 1.4.8, smallvec 1.15.2, wasi 0.11.1+wasi-snapshot-preview1, winapi 0.3.9, winapi-i686-pc-windows-gnu 0.4.0, winapi-x86_64-pc-windows-gnu 0.4.0, windows-link 0.2.1, windows-sys 0.61.2 | Apache-2.0 | + +Only target-relevant crates are linked into a given platform wheel. + +## Crossterm and Crossterm WinAPI — MIT + +MIT License + +Copyright (c) 2019 Timon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Mio — MIT + +Copyright (c) 2014 Carl Lerche and other MIO contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +## Redox syscall — MIT + +Copyright (c) 2017 Redox OS Developers + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## Apache License 2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/docs/research.md b/docs/research.md index 1683c71..39e51ef 100644 --- a/docs/research.md +++ b/docs/research.md @@ -51,12 +51,13 @@ or assets are copied into Keys Tools. - `fetch` compares only with cpufetch, macchina, and fastfetch. They are the three requested tools with overlapping CPU/system-information output. -- `games` follows tuihub's hub shape but launches installed programs rather - than copying game code. This keeps GPL/AGPL projects separate and preserves - every upstream install, data, and update contract. -- Ratatui is useful for persistent interactive screens, but these two commands - need one render and safe process launch. ANSI plus the Rust standard library - removes a dependency and a second event loop. +- `games` is a clean-room arcade informed only by observable genres and + high-level behavior. It uses an original implementation, maps, text, words, + and ASCII assets; no upstream source, data, branding, save format, or executable is + copied, linked, or launched. +- Crossterm is the only runtime dependency and owns cross-platform raw input, + resize events, alternate-screen entry, and terminal restoration. Ratatui is + excluded because these fixed layouts do not need a widget or layout engine. - PyPI distribution uses [maturin binary bindings](https://www.maturin.rs/bindings.html), which put native binaries on the environment `PATH`. PyO3 is excluded because the commands expose no Python API; native wheels are required per diff --git a/pyproject.toml b/pyproject.toml index b0e3b2c..347f816 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,4 +25,5 @@ Source = "https://github.com/keys-i/tools" [tool.maturin] bindings = "bin" compatibility = "pypi" +include = [{ path = "docs/THIRD_PARTY_LICENSES.md", format = ["sdist", "wheel"] }] strip = true diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh index d6827e8..52b1ac2 100644 --- a/scripts/smoke-wheel.sh +++ b/scripts/smoke-wheel.sh @@ -6,11 +6,22 @@ set -euo pipefail exit 2 } +python_command=python +command -v "$python_command" >/dev/null 2>&1 || python_command=python3 +"$python_command" - "$1" <<'PY' +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1]) as wheel: + notices = [name for name in wheel.namelist() if name.endswith("THIRD_PARTY_LICENSES.md")] + assert notices, "wheel omits third-party license notices" + text = wheel.read(notices[0]).decode() + assert "crossterm 0.29.0" in text and "Apache License" in text +PY + test_dir=$(mktemp -d) trap 'rm -rf -- "$test_dir"' EXIT -python_command=python -command -v "$python_command" >/dev/null 2>&1 || python_command=python3 "$python_command" -m venv "$test_dir" if [[ ${RUNNER_OS:-} == Windows ]]; then python_bin="$test_dir/Scripts/python.exe" diff --git a/src/games.rs b/src/games.rs index c4f0d0c..c947c73 100644 --- a/src/games.rs +++ b/src/games.rs @@ -1,113 +1,197 @@ -use std::collections::HashSet; -use std::env; -use std::ffi::OsStr; -use std::fs; -use std::io::Read as _; -use std::path::{Path, PathBuf}; -use std::process::Command; +mod action; +mod puzzle; +mod turns; + +use std::io::{self, IsTerminal as _, Write}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crossterm::cursor::{Hide, MoveTo, Show}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; +use crossterm::terminal::{ + BeginSynchronizedUpdate, Clear, ClearType, DisableLineWrap, EnableLineWrap, + EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, + enable_raw_mode, size, +}; +use crossterm::{execute, queue}; use crate::{VERSION, json_string, use_color}; -const HELP: &str = "One launcher for terminal games\n\nUsage:\n games [list] [--plain | --json]\n games info NAME\n games run NAME [ARG ...]\n\nOptions:\n --plain Stable text without decoration\n --json Machine-readable game list\n -h, --help Show this help\n -V, --version Show the version\n\nExtra .game manifests are read from TOOLS_GAME_PATH and the user data directory."; -const MAX_MANIFEST_BYTES: u64 = 64 * 1024; -const MAX_MANIFESTS: usize = 256; - -#[derive(Clone, Debug, PartialEq)] -struct Game { - name: String, - command: String, - summary: String, - source: String, - license: String, +const HELP: &str = "Native clean-room terminal arcade\n\nUsage:\n games\n games list [--plain | --json]\n games info NAME\n games run NAME [--seed NUMBER]\n\nOptions:\n --plain Stable text without decoration\n --json Machine-readable game list\n --seed NUMBER Reproducible randomized setup\n -h, --help Show this help\n -V, --version Show the version\n\nInteractive controls: arrows or WASD; Esc returns to the menu. Most modes use r to restart and q to quit; text modes use Ctrl-R and Ctrl-C."; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Input { + Up, + Down, + Left, + Right, + Enter, + Backspace, + Char(char), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Outcome { + Playing, + Won(String), + Lost(String), +} + +pub(crate) struct Scene { + pub title: &'static str, + pub status: String, + pub lines: Vec, + pub help: &'static str, +} + +pub(crate) trait Game { + fn input(&mut self, input: Input); + fn tick(&mut self) {} + fn tick_rate(&self) -> Option { + None + } + fn scene(&self, width: u16, height: u16) -> Scene; + fn outcome(&self) -> &Outcome; + fn accepts_text(&self) -> bool { + false + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Self { + Self(seed) + } + + pub fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut value = self.0; + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) + } + + pub fn index(&mut self, length: usize) -> usize { + if length == 0 { + 0 + } else { + (self.next() % length as u64) as usize + } + } + + pub fn chance(&mut self, numerator: u64, denominator: u64) -> bool { + denominator != 0 && self.next() % denominator < numerator + } } -const BUILTINS: &[(&str, &str, &str, &str, &str)] = &[ - ( - "balatro", - "balatro_tui", - "Deck-building roguelike", - "https://github.com/Passeriform/BalatroTUI", - "GPL-3.0", - ), - ( - "botany", - "botany.py", - "Grow a persistent terminal plant", - "https://github.com/jifunks/botany", - "ISC", - ), - ( - "brogue", - "brogue", - "Classic single-player roguelike", - "https://github.com/tmewett/BrogueCE", - "AGPL-3.0", - ), - ( - "pokete", - "pokete", - "Creature-catching terminal game", - "https://github.com/lxgr-linux/pokete", - "GPL-3.0", - ), - ( - "rebels", - "rebels", - "Space-pirate basketball", - "https://github.com/ricott1/rebels-in-the-sky", - "GPL-3.0", - ), - ( - "snake", - "snake", - "Minimal terminal Snake", - "https://github.com/wick3dr0se/snake", - "GPL-3.0", - ), - ( - "tttui", - "tttui", - "Terminal typing test", - "https://github.com/reidoboss/tttui", - "MIT", - ), - ( - "2048", - "tui-2048", - "Terminal 2048 puzzle", - "https://github.com/ps06756/tui-2048", - "MIT", - ), - ( - "wordle", - "wordle.raku", - "Raku Wordle implementation", - "https://github.com/m-dango/raku-wordle", - "Artistic-2.0", - ), - ( - "raycaster", - "awkaster.awk", - "gawk raycasting demo", - "https://github.com/TheMozg/awk-raycaster", - "MIT", - ), +struct ModeSpec { + name: &'static str, + title: &'static str, + summary: &'static str, + factory: fn(u64) -> Box, +} + +const MODES: &[ModeSpec] = &[ + ModeSpec { + name: "delve", + title: "DELVE", + summary: "Escape a compact three-floor dungeon", + factory: action::delve, + }, + ModeSpec { + name: "orbit", + title: "ORBIT COURT", + summary: "Win a twelve-possession space match", + factory: turns::orbit, + }, + ModeSpec { + name: "serpent", + title: "SERPENT RUN", + summary: "Eat twelve sparks without hitting the wall", + factory: action::serpent, + }, + ModeSpec { + name: "merge", + title: "NUMBER MERGE", + summary: "Slide matching tiles to reach 256", + factory: puzzle::merge, + }, + ModeSpec { + name: "vector", + title: "EXIT VECTOR", + summary: "Find an access card and escape a raycast maze", + factory: action::vector, + }, + ModeSpec { + name: "cards", + title: "HAND THRESHOLD", + summary: "Build scoring card hands across three rounds", + factory: puzzle::cards, + }, + ModeSpec { + name: "seedling", + title: "SEEDLING", + summary: "Guide a plant through a seven-day cycle", + factory: turns::seedling, + }, + ModeSpec { + name: "sprint", + title: "TYPE SPRINT", + summary: "Complete an original prompt with five mistakes or fewer", + factory: puzzle::sprint, + }, + ModeSpec { + name: "keybed", + title: "KEYBED", + summary: "Repeat an eight-note visual melody", + factory: turns::keybed, + }, + ModeSpec { + name: "glyphs", + title: "FIVE GLYPHS", + summary: "Find a five-letter answer in six guesses", + factory: puzzle::glyphs, + }, + ModeSpec { + name: "scout", + title: "FIELD SCOUT", + summary: "Recruit a creature and reach the field gate", + factory: turns::scout, + }, ]; pub fn run(arguments: Vec) -> Result { let mut arguments = arguments.into_iter(); match arguments.next().as_deref() { - None | Some("list") => list(arguments.collect()), + None => { + if io::stdin().is_terminal() && io::stdout().is_terminal() { + interactive(None, session_seed()) + } else { + list(Vec::new()) + } + } + Some("list") => list(arguments.collect()), Some("info") => { - let name = required_name(arguments.next())?; + let name = arguments + .next() + .ok_or_else(|| "info needs a game name".to_owned())?; if arguments.next().is_some() { return Err("info accepts one game name".into()); } info(&name) } Some("run") => { - let name = required_name(arguments.next())?; - launch(&name, arguments.collect()) + let name = arguments + .next() + .ok_or_else(|| "run needs a game name".to_owned())?; + let seed = parse_seed(arguments.collect())?; + find_mode(&name)?; + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err("interactive games need a terminal; run this command directly".into()); + } + interactive(Some(&name), seed.unwrap_or_else(session_seed)) } Some("-h" | "--help") => { println!("{HELP}"); @@ -121,9 +205,15 @@ pub fn run(arguments: Vec) -> Result { } } -fn required_name(name: Option) -> Result { - name.filter(|value| valid_name(value)) - .ok_or_else(|| "enter a valid game name".into()) +fn parse_seed(arguments: Vec) -> Result, String> { + match arguments.as_slice() { + [] => Ok(None), + [flag, value] if flag == "--seed" => value + .parse() + .map(Some) + .map_err(|_| "--seed needs an unsigned integer".into()), + _ => Err("usage: games run NAME [--seed NUMBER]".into()), + } } fn list(arguments: Vec) -> Result { @@ -139,372 +229,384 @@ fn list(arguments: Vec) -> Result { if plain && json { return Err("--plain and --json cannot be used together".into()); } - - let games = registry()?; if json { - println!("{}", render_json(&games)); + let values = MODES + .iter() + .map(|mode| { + format!( + "{{\"name\":{},\"title\":{},\"summary\":{},\"native\":true}}", + json_string(mode.name), + json_string(mode.title), + json_string(mode.summary), + ) + }) + .collect::>(); + println!("[{}]", values.join(",")); } else if use_color(plain) { - print_styled(&games); + println!("\x1b[38;2;125;207;255m+-- GAMES \x1b[38;2;86;95;137m// native arcade\x1b[0m"); + for mode in MODES { + println!( + "\x1b[38;2;125;207;255m|\x1b[0m \x1b[38;2;158;206;106m*\x1b[0m {:10} {}", + mode.name, mode.summary + ); + } + println!("\x1b[38;2;125;207;255m+--\x1b[0m \x1b[38;2;86;95;137mgames run NAME\x1b[0m"); } else { - print_plain(&games); + for mode in MODES { + println!("{}\tnative\t{}", mode.name, mode.summary); + } } Ok(0) } fn info(name: &str) -> Result { - let game = find_game(name)?; - println!("Name\t{}", game.name); - println!("Command\t{}", game.command); - println!( - "Status\t{}", - if find_command(&game.command).is_some() { - "ready" - } else { - "missing" - } - ); - println!("License\t{}", game.license); - println!("Source\t{}", game.source); - println!("Summary\t{}", game.summary); + let mode = find_mode(name)?; + println!("Name\t{}", mode.name); + println!("Title\t{}", mode.title); + println!("Implementation\tNative clean-room Rust"); + println!("Summary\t{}", mode.summary); Ok(0) } -fn launch(name: &str, arguments: Vec) -> Result { - let game = find_game(name)?; - let executable = find_command(&game.command).ok_or_else(|| { - format!( - "{} is not installed (expected {:?}); see {}", - game.name, game.command, game.source - ) - })?; - let status = Command::new(&executable) - .args(arguments) - .status() - .map_err(|error| format!("cannot launch {}: {error}", executable.display()))?; - Ok(status.code().unwrap_or(1)) +fn find_mode(name: &str) -> Result<&'static ModeSpec, String> { + MODES + .iter() + .find(|mode| mode.name == name) + .ok_or_else(|| format!("unknown game {name:?}; run 'games list'")) } -fn find_game(name: &str) -> Result { - registry()? - .into_iter() - .find(|game| game.name == name) - .ok_or_else(|| format!("unknown game {name:?}; run 'games list'")) +fn session_seed() -> u64 { + let time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + time ^ u64::from(std::process::id()) } -fn registry() -> Result, String> { - let mut games = BUILTINS - .iter() - .map(|&(name, command, summary, source, license)| Game { - name: name.into(), - command: command.into(), - summary: summary.into(), - source: source.into(), - license: license.into(), - }) - .collect::>(); - let mut names = games - .iter() - .map(|game| game.name.clone()) - .collect::>(); - let mut manifest_count = 0; +fn interactive(initial: Option<&str>, mut seed: u64) -> Result { + let _terminal = TerminalSession::enter().map_err(|error| format!("terminal: {error}"))?; + let mut stdout = io::stdout(); + let mut selected = initial + .and_then(|name| MODES.iter().position(|mode| mode.name == name)) + .unwrap_or(0); + let mut active = initial.map(|_| (MODES[selected].factory)(seed)); + let mut next_tick = schedule_tick(active.as_deref()); + let color = use_color(false); - for directory in manifest_directories() { - if !directory.exists() { - continue; - } - let mut paths = fs::read_dir(&directory) - .map_err(|error| format!("cannot read {}: {error}", directory.display()))? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.extension() == Some(OsStr::new("game"))) - .collect::>(); - paths.sort_unstable(); - for path in paths { - manifest_count += 1; - if manifest_count > MAX_MANIFESTS { - return Err(format!("more than {MAX_MANIFESTS} game manifests found")); + loop { + let terminal_size = size().unwrap_or((80, 24)); + let scene = active.as_ref().map_or_else( + || menu_scene(selected), + |game| game.scene(terminal_size.0, terminal_size.1), + ); + let outcome = active + .as_ref() + .map_or(&Outcome::Playing, |game| game.outcome()); + draw(&mut stdout, &scene, outcome, terminal_size, color) + .map_err(|error| format!("draw: {error}"))?; + + let tick_rate = active.as_ref().and_then(|game| game.tick_rate()); + let input_ready = tick_rate.map_or(Ok(true), |rate| { + event::poll( + next_tick + .saturating_duration_since(Instant::now()) + .min(rate), + ) + }); + + if input_ready.map_err(|error| format!("input: {error}"))? { + match event::read().map_err(|error| format!("input: {error}"))? { + Event::Key(key) + if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + if quit_key(key, active.as_ref().is_some_and(|game| game.accepts_text())) { + break; + } + if key.code == KeyCode::Esc { + if active.is_some() { + active = None; + } else { + break; + } + continue; + } + if active.is_some() + && restart_key(key, active.as_ref().is_some_and(|game| game.accepts_text())) + { + active = Some((MODES[selected].factory)(seed)); + next_tick = schedule_tick(active.as_deref()); + continue; + } + if let Some(game) = active.as_mut() { + if game.outcome() != &Outcome::Playing && key.code == KeyCode::Enter { + seed = seed.wrapping_add(1); + active = Some((MODES[selected].factory)(seed)); + next_tick = schedule_tick(active.as_deref()); + } else if game.outcome() == &Outcome::Playing { + game.input(map_key(key)); + } + } else { + match map_key(key) { + Input::Up | Input::Char('k') => { + selected = selected.checked_sub(1).unwrap_or(MODES.len() - 1); + } + Input::Down | Input::Char('j') => { + selected = (selected + 1) % MODES.len(); + } + Input::Enter => { + active = Some((MODES[selected].factory)(seed)); + next_tick = schedule_tick(active.as_deref()); + } + _ => {} + } + } + } + Event::Resize(_, _) => {} + _ => {} } - let game = read_manifest(&path)?; - if !names.insert(game.name.clone()) { - return Err(format!( - "duplicate game name {:?} in {}", - game.name, - path.display() - )); + } + + if let Some(game) = active.as_mut() + && let Some(rate) = game.tick_rate() + && Instant::now() >= next_tick + { + if game.outcome() == &Outcome::Playing { + game.tick(); } - games.push(game); + next_tick = Instant::now() + rate; } } - games.sort_unstable_by(|left, right| left.name.cmp(&right.name)); - Ok(games) + Ok(0) } -fn manifest_directories() -> Vec { - let mut directories: Vec = env::var_os("TOOLS_GAME_PATH") - .map(|value| { - env::split_paths(&value) - .filter(|path| !path.as_os_str().is_empty()) - .collect() - }) - .unwrap_or_default(); - if let Some(path) = env::var_os("XDG_DATA_HOME") { - directories.push(PathBuf::from(path).join("keys-tools/games")); - } else if let Some(home) = env::var_os("HOME") { - directories.push(PathBuf::from(home).join(".local/share/keys-tools/games")); - } - directories +fn schedule_tick(game: Option<&dyn Game>) -> Instant { + Instant::now() + game.and_then(|game| game.tick_rate()).unwrap_or_default() } -fn read_manifest(path: &Path) -> Result { - let before = fs::symlink_metadata(path) - .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; - if before.file_type().is_symlink() || !before.is_file() { - return Err(format!("unsafe game manifest: {}", path.display())); - } - #[cfg(windows)] - let file = { - use std::os::windows::fs::OpenOptionsExt as _; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - fs::OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) - }; - #[cfg(not(windows))] - let file = fs::File::open(path); - let file = file.map_err(|error| format!("cannot read {}: {error}", path.display()))?; - let opened = file - .metadata() - .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt as _; - if before.dev() != opened.dev() || before.ino() != opened.ino() { - return Err(format!("game manifest changed: {}", path.display())); - } - } - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt as _; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - if opened.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return Err(format!("unsafe game manifest: {}", path.display())); - } - } - if !opened.is_file() || opened.len() > MAX_MANIFEST_BYTES { - return Err(format!("unsafe game manifest: {}", path.display())); - } - let mut bytes = Vec::with_capacity(opened.len() as usize + 1); - file.take(MAX_MANIFEST_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|error| format!("cannot read {}: {error}", path.display()))?; - if bytes.len() as u64 > MAX_MANIFEST_BYTES { - return Err(format!("unsafe game manifest: {}", path.display())); - } - let text = String::from_utf8(bytes) - .map_err(|_| format!("{}: manifest must be UTF-8", path.display()))?; - parse_manifest(&text).map_err(|error| format!("{}: {error}", path.display())) +fn quit_key(key: KeyEvent, accepts_text: bool) -> bool { + !accepts_text && matches!(key.code, KeyCode::Char('q' | 'Q')) + || key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c' | 'C')) } -fn parse_manifest(text: &str) -> Result { - if text - .chars() - .any(|character| character.is_control() && !matches!(character, '\n' | '\r')) - { - return Err("manifest contains control characters".into()); - } - let mut values = std::collections::HashMap::new(); - for (index, line) in text.lines().enumerate() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line - .split_once('=') - .ok_or_else(|| format!("line {} must be key=value", index + 1))?; - let key = key.trim(); - let value = value.trim(); - if !matches!(key, "name" | "command" | "summary" | "source" | "license") { - return Err(format!("unknown key {key:?}")); - } - if value.is_empty() || value.contains(['\0', '\n', '\r']) { - return Err(format!("{key} must be one non-empty line")); - } - if values.insert(key, value).is_some() { - return Err(format!("duplicate key {key:?}")); - } - } - let get = |key| { - values - .get(key) - .copied() - .ok_or_else(|| format!("missing {key}")) - }; - let name = get("name")?; - let command = get("command")?; - let summary = get("summary")?; - let source = get("source")?; - let license = get("license")?; - if !valid_name(name) { - return Err("name must contain lowercase letters, digits, and hyphens".into()); - } - if command.chars().any(char::is_whitespace) || command.len() > 512 { - return Err("command must be one executable without arguments".into()); - } - if summary.len() > 200 || license.len() > 64 { - return Err("summary or license is too long".into()); +fn restart_key(key: KeyEvent, accepts_text: bool) -> bool { + if accepts_text { + key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('r' | 'R')) + } else { + matches!(key.code, KeyCode::Char('r' | 'R')) } - if !source.starts_with("https://") || source.len() > 512 { - return Err("source must be an https URL".into()); +} + +fn map_key(key: KeyEvent) -> Input { + match key.code { + KeyCode::Up => Input::Up, + KeyCode::Down => Input::Down, + KeyCode::Left => Input::Left, + KeyCode::Right => Input::Right, + KeyCode::Enter => Input::Enter, + KeyCode::Backspace => Input::Backspace, + KeyCode::Char(character) => Input::Char(character.to_ascii_lowercase()), + _ => Input::Char('\0'), } - Ok(Game { - name: name.into(), - command: command.into(), - summary: summary.into(), - source: source.into(), - license: license.into(), - }) } -fn valid_name(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +fn menu_scene(selected: usize) -> Scene { + let lines = MODES + .iter() + .enumerate() + .map(|(index, mode)| { + format!( + "{} {:10} {}", + if index == selected { ">" } else { " " }, + mode.name, + mode.summary + ) + }) + .collect(); + Scene { + title: "ARCADE", + status: MODES[selected].title.into(), + lines, + help: "Up/Down choose Enter play q quit", + } } -fn find_command(command: &str) -> Option { - let candidate = Path::new(command); - if candidate.components().count() > 1 { - return executable(candidate).then(|| candidate.to_owned()); +fn draw( + stdout: &mut W, + scene: &Scene, + outcome: &Outcome, + (width, height): (u16, u16), + color_enabled: bool, +) -> io::Result<()> { + queue!( + stdout, + BeginSynchronizedUpdate, + MoveTo(0, 0), + Clear(ClearType::All) + )?; + if width < 60 || height < 20 { + write_centered( + stdout, + height / 2, + width, + "Resize to at least 60 x 20", + color_enabled.then_some(Color::Yellow), + )?; + queue!(stdout, EndSynchronizedUpdate)?; + return stdout.flush(); } - let path = env::var_os("PATH")?; - for directory in env::split_paths(&path).filter(|path| !path.as_os_str().is_empty()) { - let candidate = directory.join(command); - if executable(&candidate) { - return Some(candidate); - } - #[cfg(windows)] - { - let candidate = directory.join(format!("{command}.exe")); - if executable(&candidate) { - return Some(candidate); - } - } + + write_centered( + stdout, + 1, + width, + &format!("+-- GAMES // {} --+", scene.title), + color_enabled.then_some(Color::Cyan), + )?; + let available = usize::from(height.saturating_sub(7)); + let start = 3 + (available.saturating_sub(scene.lines.len().min(available)) / 2) as u16; + for (offset, line) in scene.lines.iter().take(available).enumerate() { + write_centered( + stdout, + start + offset as u16, + width, + line, + color_enabled.then_some(Color::White), + )?; } - None -} -#[cfg(unix)] -fn executable(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt as _; - path.metadata() - .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + let (message, status_color) = match outcome { + Outcome::Playing => (&scene.status, Color::Grey), + Outcome::Won(message) => (message, Color::Green), + Outcome::Lost(message) => (message, Color::Red), + }; + write_centered( + stdout, + height - 3, + width, + message, + color_enabled.then_some(status_color), + )?; + write_centered( + stdout, + height - 2, + width, + scene.help, + color_enabled.then_some(Color::Grey), + )?; + queue!(stdout, EndSynchronizedUpdate)?; + stdout.flush() } -#[cfg(windows)] -fn executable(path: &Path) -> bool { - path.extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case(OsStr::new("exe"))) - && path.is_file() +fn write_centered( + stdout: &mut W, + row: u16, + width: u16, + value: &str, + color: Option, +) -> io::Result<()> { + let value = value.chars().take(width as usize).collect::(); + let column = width.saturating_sub(value.chars().count() as u16) / 2; + queue!(stdout, MoveTo(column, row))?; + if let Some(color) = color { + queue!(stdout, SetForegroundColor(color))?; + } + queue!(stdout, Print(value))?; + if color.is_some() { + queue!(stdout, ResetColor)?; + } + Ok(()) } -#[cfg(not(any(unix, windows)))] -fn executable(path: &Path) -> bool { - path.is_file() -} +struct TerminalSession; -fn print_plain(games: &[Game]) { - for game in games { - let status = if find_command(&game.command).is_some() { - "ready" - } else { - "missing" - }; - println!("{}\t{status}\t{}", game.name, game.summary); +impl TerminalSession { + fn enter() -> io::Result { + enable_raw_mode()?; + if let Err(error) = execute!(io::stdout(), EnterAlternateScreen, Hide, DisableLineWrap) { + restore_terminal(); + return Err(error); + } + Ok(Self) } } -fn print_styled(games: &[Game]) { - const CYAN: &str = "\x1b[38;2;125;207;255m"; - const GREEN: &str = "\x1b[38;2;158;206;106m"; - const MUTED: &str = "\x1b[38;2;86;95;137m"; - const RESET: &str = "\x1b[0m"; - println!("{CYAN}╭─ ◈ GAMES {MUTED}// terminal arcade{RESET}"); - for game in games { - let (marker, color) = if find_command(&game.command).is_some() { - ("●", GREEN) - } else { - ("○", MUTED) - }; - println!( - "{CYAN}│{RESET} {color}{marker}{RESET} {:12} {}", - game.name, game.summary - ); +impl Drop for TerminalSession { + fn drop(&mut self) { + restore_terminal(); } - println!("{CYAN}╰─{RESET} {MUTED}games run NAME{RESET}"); } -fn render_json(games: &[Game]) -> String { - let values = games - .iter() - .map(|game| { - format!( - "{{\"name\":{},\"command\":{},\"summary\":{},\"source\":{},\"license\":{},\"available\":{}}}", - json_string(&game.name), - json_string(&game.command), - json_string(&game.summary), - json_string(&game.source), - json_string(&game.license), - find_command(&game.command).is_some(), - ) - }) - .collect::>(); - format!("[{}]", values.join(",")) +fn restore_terminal() { + let _ = execute!( + io::stdout(), + EndSynchronizedUpdate, + ResetColor, + Show, + EnableLineWrap, + LeaveAlternateScreen + ); + let _ = disable_raw_mode(); } #[cfg(test)] mod tests { use super::*; - const MANIFEST: &str = "name=maze\ncommand=maze\nsummary=Small maze game\nsource=https://example.com/maze\nlicense=MIT\n"; - #[test] - fn parses_strict_manifest() { - let game = parse_manifest(MANIFEST).expect("valid manifest"); - assert_eq!(game.name, "maze"); - assert_eq!(game.command, "maze"); - assert!(parse_manifest(&format!("{MANIFEST}unknown=x\n")).is_err()); - assert!(parse_manifest(&MANIFEST.replace("command=maze", "command=sh -c bad")).is_err()); - assert!(parse_manifest(&MANIFEST.replace("name=maze", "name=../maze")).is_err()); - assert!(parse_manifest(&MANIFEST.replace("Small maze", "Small\x1b[2Jmaze")).is_err()); + fn rng_and_mode_registry_are_deterministic() { + let mut left = Rng::new(7); + let mut right = Rng::new(7); + assert_eq!(left.next(), right.next()); + assert_eq!(MODES.len(), 11); + for mode in MODES { + let game = (mode.factory)(1); + let replay = (mode.factory)(1); + let scene = game.scene(80, 24); + let replay_scene = replay.scene(80, 24); + assert_eq!(scene.title, mode.title); + assert!(!scene.lines.is_empty()); + assert_eq!(scene.status, replay_scene.status); + assert_eq!(scene.lines, replay_scene.lines); + assert_eq!(game.outcome(), &Outcome::Playing); + } } #[test] - fn rejects_oversized_manifest() { - let path = env::temp_dir().join(format!("keys-tools-large-{}.game", std::process::id())); - fs::write(&path, vec![b'x'; MAX_MANIFEST_BYTES as usize + 1]).expect("write manifest"); - let result = read_manifest(&path); - let _ = fs::remove_file(path); - assert!(result.is_err()); - } + fn renderer_handles_plain_and_small_terminals() { + let scene = menu_scene(0); + let mut output = Vec::new(); + draw(&mut output, &scene, &Outcome::Playing, (80, 24), false).expect("draw menu"); + let output = String::from_utf8(output).expect("ANSI is UTF-8"); + assert!(output.contains("GAMES // ARCADE")); + assert!(!output.contains("\x1b[38;")); - #[cfg(windows)] - #[test] - fn rejects_batch_launchers() { - let path = env::temp_dir().join(format!("keys-tools-{}.cmd", std::process::id())); - fs::write(&path, "@echo off\n").expect("write batch file"); - assert!(!executable(&path)); - let _ = fs::remove_file(path); + let mut small = Vec::new(); + draw(&mut small, &scene, &Outcome::Playing, (40, 10), false).expect("draw resize state"); + assert!(String::from_utf8_lossy(&small).contains("Resize to at least 60 x 20")); } #[test] - fn json_exposes_license_and_availability() { - let game = parse_manifest(MANIFEST).expect("valid manifest"); - let json = render_json(&[game]); - assert!(json.contains("\"name\":\"maze\"")); - assert!(json.contains("\"license\":\"MIT\"")); - assert!(json.contains("\"available\":")); + fn parses_seed_and_rejects_unknown_mode() { + assert_eq!(parse_seed(vec!["--seed".into(), "42".into()]), Ok(Some(42))); + assert!(parse_seed(vec!["--seed".into(), "no".into()]).is_err()); + assert!(find_mode("missing").is_err()); + assert!(restart_key( + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), + false + )); + assert!(!restart_key( + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), + true + )); + assert!(restart_key( + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + true + )); + assert!(!quit_key( + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + true + )); } } diff --git a/src/games/action.rs b/src/games/action.rs new file mode 100644 index 0000000..b8f574e --- /dev/null +++ b/src/games/action.rs @@ -0,0 +1,666 @@ +use std::collections::VecDeque; +use std::f32::consts::PI; +use std::time::Duration; + +use super::{Game, Input, Outcome, Rng, Scene}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Point { + x: i16, + y: i16, +} + +pub(super) fn serpent(seed: u64) -> Box { + Box::new(Serpent::new(seed)) +} + +const SNAKE_WIDTH: i16 = 30; +const SNAKE_HEIGHT: i16 = 14; + +struct Serpent { + body: VecDeque, + direction: Point, + queued: Point, + food: Point, + score: u8, + paused: bool, + rng: Rng, + outcome: Outcome, +} + +impl Serpent { + fn new(seed: u64) -> Self { + let body = VecDeque::from([ + Point { x: 15, y: 7 }, + Point { x: 14, y: 7 }, + Point { x: 13, y: 7 }, + ]); + let mut game = Self { + body, + direction: Point { x: 1, y: 0 }, + queued: Point { x: 1, y: 0 }, + food: Point { x: 0, y: 0 }, + score: 0, + paused: false, + rng: Rng::new(seed), + outcome: Outcome::Playing, + }; + game.place_food(); + game + } + + fn steer(&mut self, direction: Point) { + if direction.x != -self.direction.x || direction.y != -self.direction.y { + self.queued = direction; + } + } + + fn place_food(&mut self) { + for _ in 0..SNAKE_WIDTH * SNAKE_HEIGHT { + let candidate = Point { + x: self.rng.index(SNAKE_WIDTH as usize) as i16, + y: self.rng.index(SNAKE_HEIGHT as usize) as i16, + }; + if !self.body.contains(&candidate) { + self.food = candidate; + return; + } + } + self.outcome = Outcome::Won("SERPENT RUN // board filled".into()); + } +} + +impl Game for Serpent { + fn input(&mut self, input: Input) { + match input { + Input::Up | Input::Char('w' | 'k') => self.steer(Point { x: 0, y: -1 }), + Input::Down | Input::Char('s' | 'j') => self.steer(Point { x: 0, y: 1 }), + Input::Left | Input::Char('a' | 'h') => self.steer(Point { x: -1, y: 0 }), + Input::Right | Input::Char('d' | 'l') => self.steer(Point { x: 1, y: 0 }), + Input::Char('p') => self.paused = !self.paused, + _ => {} + } + } + + fn tick(&mut self) { + if self.paused || self.outcome != Outcome::Playing { + return; + } + self.direction = self.queued; + let head = self.body[0]; + let next = Point { + x: head.x + self.direction.x, + y: head.y + self.direction.y, + }; + let tail = self.body.back().copied(); + let hits_body = self.body.contains(&next) && !(tail == Some(next) && next != self.food); + if next.x < 0 || next.y < 0 || next.x >= SNAKE_WIDTH || next.y >= SNAKE_HEIGHT || hits_body + { + self.outcome = Outcome::Lost("SERPENT RUN // collision".into()); + return; + } + self.body.push_front(next); + if next == self.food { + self.score += 1; + if self.score >= 12 { + self.outcome = Outcome::Won("SERPENT RUN // twelve sparks collected".into()); + } else { + self.place_food(); + } + } else { + self.body.pop_back(); + } + } + + fn tick_rate(&self) -> Option { + (!self.paused && self.outcome == Outcome::Playing).then_some(Duration::from_millis(115)) + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let mut board = vec![vec![' '; SNAKE_WIDTH as usize]; SNAKE_HEIGHT as usize]; + board[self.food.y as usize][self.food.x as usize] = '*'; + for (index, point) in self.body.iter().enumerate() { + board[point.y as usize][point.x as usize] = if index == 0 { '@' } else { 'o' }; + } + let border = format!("+{}+", "-".repeat(SNAKE_WIDTH as usize)); + let mut lines = Vec::with_capacity(SNAKE_HEIGHT as usize + 2); + lines.push(border.clone()); + lines.extend( + board + .into_iter() + .map(|row| format!("|{}|", row.into_iter().collect::())), + ); + lines.push(border); + Scene { + title: "SERPENT RUN", + status: format!( + "sparks {}/12{}", + self.score, + if self.paused { " // paused" } else { "" } + ), + lines, + help: "Arrows/WASD steer p pause r restart Esc menu q quit", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +pub(super) fn delve(seed: u64) -> Box { + Box::new(Delve::new(seed)) +} + +const FLOORS: [[&str; 9]; 3] = [ + [ + "###############", + "#@....#....K..#", + "#.....#.......#", + "#.....#..M....#", + "#.............#", + "#...M.#.......#", + "#.....#..+....#", + "#.........>...#", + "###############", + ], + [ + "###############", + "#@......#.....#", + "#.#####.#.###.#", + "#.....#...#K..#", + "#..M..#####...#", + "#.............#", + "#.###..M..###.#", + "#..+......>...#", + "###############", + ], + [ + "###############", + "#@............#", + "#.#####.#####.#", + "#.#...#.....#.#", + "#.#.R...M...#.#", + "#.#...#####.#.#", + "#...M.....+...#", + "#............>#", + "###############", + ], +]; + +struct Delve { + floor: usize, + map: Vec>, + player: Point, + enemies: Vec, + hp: i8, + key: bool, + relic: bool, + rng: Rng, + message: String, + outcome: Outcome, +} + +impl Delve { + fn new(seed: u64) -> Self { + let mut game = Self { + floor: 0, + map: Vec::new(), + player: Point { x: 1, y: 1 }, + enemies: Vec::new(), + hp: 8, + key: false, + relic: false, + rng: Rng::new(seed), + message: "Find the floor key, then reach >".into(), + outcome: Outcome::Playing, + }; + game.load_floor(); + game + } + + fn load_floor(&mut self) { + self.map = FLOORS[self.floor] + .iter() + .map(|row| row.chars().collect()) + .collect(); + self.enemies.clear(); + for (y, row) in self.map.iter_mut().enumerate() { + for (x, cell) in row.iter_mut().enumerate() { + match *cell { + '@' => { + self.player = Point { + x: x as i16, + y: y as i16, + }; + *cell = '.'; + } + 'M' => { + self.enemies.push(Point { + x: x as i16, + y: y as i16, + }); + *cell = '.'; + } + _ => {} + } + } + } + self.key = false; + } + + fn tile(&self, point: Point) -> char { + self.map + .get(point.y as usize) + .and_then(|row| row.get(point.x as usize)) + .copied() + .unwrap_or('#') + } + + fn turn(&mut self, direction: Point) { + let target = Point { + x: self.player.x + direction.x, + y: self.player.y + direction.y, + }; + if self.tile(target) == '#' { + self.message = "Stone blocks the way".into(); + return; + } + if let Some(index) = self.enemies.iter().position(|enemy| *enemy == target) { + self.enemies.remove(index); + self.message = "You drive back a shade".into(); + } else { + self.player = target; + self.message.clear(); + } + self.enemy_turn(); + } + + fn enemy_turn(&mut self) { + for enemy in &mut self.enemies { + let dx = (self.player.x - enemy.x).signum(); + let dy = (self.player.y - enemy.y).signum(); + if (self.player.x - enemy.x).abs() + (self.player.y - enemy.y).abs() == 1 { + self.hp -= 1; + self.message = "A shade hits you".into(); + continue; + } + let step = if self.rng.chance(1, 2) { + Point { + x: enemy.x + dx, + y: enemy.y, + } + } else { + Point { + x: enemy.x, + y: enemy.y + dy, + } + }; + let tile = self.map[step.y as usize][step.x as usize]; + if tile != '#' && step != self.player { + *enemy = step; + } + } + if self.hp <= 0 { + self.outcome = Outcome::Lost("DELVE // the dungeon takes its toll".into()); + } + } + + fn gather(&mut self) { + let tile = self.tile(self.player); + match tile { + 'K' => { + self.key = true; + self.map[self.player.y as usize][self.player.x as usize] = '.'; + self.message = "Floor key secured".into(); + } + 'R' => { + self.relic = true; + self.map[self.player.y as usize][self.player.x as usize] = '.'; + self.message = "The signal relic hums".into(); + } + '+' => { + self.hp = (self.hp + 3).min(8); + self.map[self.player.y as usize][self.player.x as usize] = '.'; + self.message = "Restored three health".into(); + } + _ => self.message = "Nothing to gather here".into(), + } + } + + fn descend(&mut self) { + if self.tile(self.player) != '>' { + self.message = "Stand on > to descend".into(); + } else if self.floor < 2 && self.key { + self.floor += 1; + self.load_floor(); + self.message = "A colder floor opens below".into(); + } else if self.floor == 2 && self.relic { + self.outcome = Outcome::Won("DELVE // relic recovered".into()); + } else { + self.message = if self.floor == 2 { + "The exit needs the relic" + } else { + "The stairs need the floor key" + } + .into(); + } + } +} + +impl Game for Delve { + fn input(&mut self, input: Input) { + match input { + Input::Up | Input::Char('w' | 'k') => self.turn(Point { x: 0, y: -1 }), + Input::Down | Input::Char('s' | 'j') => self.turn(Point { x: 0, y: 1 }), + Input::Left | Input::Char('a' | 'h') => self.turn(Point { x: -1, y: 0 }), + Input::Right | Input::Char('d' | 'l') => self.turn(Point { x: 1, y: 0 }), + Input::Char('g') => self.gather(), + Input::Char('>') | Input::Enter => self.descend(), + _ => {} + } + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let mut map = self.map.clone(); + for enemy in &self.enemies { + map[enemy.y as usize][enemy.x as usize] = 'M'; + } + map[self.player.y as usize][self.player.x as usize] = '@'; + Scene { + title: "DELVE", + status: format!( + "floor {}/3 hp {} key {} relic {} {}", + self.floor + 1, + self.hp.max(0), + if self.key { "yes" } else { "no" }, + if self.relic { "yes" } else { "no" }, + self.message + ), + lines: map + .into_iter() + .map(|row| row.into_iter().collect()) + .collect(), + help: "Arrows/WASD move g gather Enter descend r restart Esc menu", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +pub(super) fn vector(seed: u64) -> Box { + Box::new(Vector::new(seed)) +} + +const VECTOR_MAP: [&str; 12] = [ + "################", + "#..............#", + "#......#..C....#", + "#......#.......#", + "#..............#", + "#..#######.....#", + "#..............#", + "#.....#........#", + "#.....#..###...#", + "#.....#......E.#", + "#@.............#", + "################", +]; + +#[derive(Clone, Copy)] +struct Target { + x: f32, + y: f32, + alive: bool, +} + +struct Vector { + x: f32, + y: f32, + angle: f32, + hp: i8, + ammo: u8, + card: bool, + targets: [Target; 2], + message: String, + outcome: Outcome, +} + +impl Vector { + fn new(seed: u64) -> Self { + let mut rng = Rng::new(seed); + Self { + x: 1.5, + y: 10.5, + angle: 0.0, + hp: 6, + ammo: 8, + card: false, + targets: [ + Target { + x: 11.5, + y: 4.5, + alive: true, + }, + Target { + x: 3.5 + rng.index(3) as f32, + y: 7.5, + alive: true, + }, + ], + message: "Locate C, then activate E".into(), + outcome: Outcome::Playing, + } + } + + fn tile(x: f32, y: f32) -> char { + VECTOR_MAP + .get(y.floor().max(0.0) as usize) + .and_then(|row| row.as_bytes().get(x.floor().max(0.0) as usize)) + .map_or('#', |byte| *byte as char) + } + + fn walk(&mut self, distance: f32, strafe: f32) { + let next_x = self.x + self.angle.cos() * distance - self.angle.sin() * strafe; + let next_y = self.y + self.angle.sin() * distance + self.angle.cos() * strafe; + if Self::tile(next_x, self.y) != '#' { + self.x = next_x; + } + if Self::tile(self.x, next_y) != '#' { + self.y = next_y; + } + if Self::tile(self.x, self.y) == 'C' { + self.card = true; + self.message = "Access card secured".into(); + } + for target in self.targets.iter().filter(|target| target.alive) { + if (target.x - self.x).hypot(target.y - self.y) < 0.9 { + self.hp -= 1; + self.message = "A sentinel strikes".into(); + } + } + if self.hp <= 0 { + self.outcome = Outcome::Lost("EXIT VECTOR // signal lost".into()); + } + } + + fn fire(&mut self) { + if self.ammo == 0 { + self.message = "No pulses remain".into(); + return; + } + self.ammo -= 1; + let mut hit = None; + let mut distance = f32::MAX; + for (index, target) in self.targets.iter().enumerate() { + if !target.alive { + continue; + } + let dx = target.x - self.x; + let dy = target.y - self.y; + let target_angle = dy.atan2(dx); + let delta = normalize_angle(target_angle - self.angle).abs(); + let target_distance = dx.hypot(dy); + if delta < 0.14 && target_distance < distance && self.line_clear(target.x, target.y) { + distance = target_distance; + hit = Some(index); + } + } + if let Some(index) = hit { + self.targets[index].alive = false; + self.message = "Sentinel dispersed".into(); + } else { + self.message = "Pulse misses".into(); + } + } + + fn line_clear(&self, target_x: f32, target_y: f32) -> bool { + let distance = (target_x - self.x).hypot(target_y - self.y); + let steps = (distance / 0.1) as usize; + (1..steps).all(|step| { + let ratio = step as f32 / steps as f32; + Self::tile( + self.x + (target_x - self.x) * ratio, + self.y + (target_y - self.y) * ratio, + ) != '#' + }) + } + + fn activate(&mut self) { + if Self::tile(self.x, self.y) != 'E' { + self.message = "Stand on E to activate the lift".into(); + } else if self.card { + self.outcome = Outcome::Won("EXIT VECTOR // lift engaged".into()); + } else { + self.message = "The lift needs an access card".into(); + } + } + + fn ray_view(&self, width: usize, height: usize) -> Vec> { + let mut view = vec![vec![' '; width]; height]; + for column in 0..width { + let ray = self.angle + (column as f32 / width as f32 - 0.5) * PI / 3.0; + let mut distance = 0.05; + while distance < 18.0 { + let x = self.x + ray.cos() * distance; + let y = self.y + ray.sin() * distance; + if Self::tile(x, y) == '#' { + break; + } + distance += 0.05; + } + let wall = ((height as f32 / distance.max(0.2)) * 1.6) as usize; + let top = height.saturating_sub(wall) / 2; + let bottom = (top + wall).min(height); + for (row, cells) in view.iter_mut().enumerate() { + cells[column] = if row >= top && row < bottom { + if distance < 3.0 { '#' } else { '|' } + } else if row >= height / 2 { + '.' + } else { + ' ' + }; + } + } + view + } +} + +fn normalize_angle(mut angle: f32) -> f32 { + while angle > PI { + angle -= 2.0 * PI; + } + while angle < -PI { + angle += 2.0 * PI; + } + angle +} + +impl Game for Vector { + fn input(&mut self, input: Input) { + match input { + Input::Up | Input::Char('w') => self.walk(0.35, 0.0), + Input::Down | Input::Char('s') => self.walk(-0.35, 0.0), + Input::Left | Input::Char('j') => self.angle -= 0.16, + Input::Right | Input::Char('l') => self.angle += 0.16, + Input::Char('a') => self.walk(0.0, -0.3), + Input::Char('d') => self.walk(0.0, 0.3), + Input::Char(' ') | Input::Enter => self.fire(), + Input::Char('x') => self.activate(), + _ => {} + } + self.angle = normalize_angle(self.angle); + } + + fn scene(&self, width: u16, height: u16) -> Scene { + let ray_width = usize::from(width.saturating_sub(22)).clamp(24, 50); + let ray_height = usize::from(height.saturating_sub(8)).clamp(10, 14); + let view = self.ray_view(ray_width, ray_height); + let mut map = VECTOR_MAP + .iter() + .map(|row| row.chars().collect::>()) + .collect::>(); + map[self.y.floor() as usize][self.x.floor() as usize] = '@'; + for target in self.targets.iter().filter(|target| target.alive) { + map[target.y.floor() as usize][target.x.floor() as usize] = 'M'; + } + let lines = (0..ray_height) + .map(|row| { + let mini = if row < map.len() { + map[row].iter().collect::() + } else { + " ".repeat(16) + }; + format!("{mini} {}", view[row].iter().collect::()) + }) + .collect(); + Scene { + title: "EXIT VECTOR", + status: format!( + "hp {} pulses {} card {} {}", + self.hp.max(0), + self.ammo, + if self.card { "yes" } else { "no" }, + self.message + ), + lines, + help: "W/S move A/D strafe J/L turn Space fire x exit Esc menu", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serpent_rejects_reverse_and_wins_at_twelve() { + let mut game = Serpent::new(1); + game.steer(Point { x: -1, y: 0 }); + assert_eq!(game.queued, Point { x: 1, y: 0 }); + game.score = 11; + game.food = Point { x: 16, y: 7 }; + game.tick(); + assert!(matches!(game.outcome, Outcome::Won(_))); + } + + #[test] + fn delve_gathers_key_and_vector_respects_walls() { + let mut delve = Delve::new(2); + delve.player = Point { x: 11, y: 1 }; + delve.gather(); + assert!(delve.key); + + let mut vector = Vector::new(3); + vector.x = 1.1; + vector.angle = PI; + vector.walk(0.35, 0.0); + assert!(vector.x >= 1.0); + } +} diff --git a/src/games/puzzle.rs b/src/games/puzzle.rs new file mode 100644 index 0000000..80711d9 --- /dev/null +++ b/src/games/puzzle.rs @@ -0,0 +1,650 @@ +use std::time::Instant; + +use super::{Game, Input, Outcome, Rng, Scene}; + +pub(super) fn merge(seed: u64) -> Box { + Box::new(Merge::new(seed)) +} + +#[derive(Clone, Copy)] +enum Slide { + Left, + Right, + Up, + Down, +} + +struct Merge { + cells: [u16; 16], + score: u32, + rng: Rng, + message: String, + outcome: Outcome, +} + +impl Merge { + fn new(seed: u64) -> Self { + let mut game = Self { + cells: [0; 16], + score: 0, + rng: Rng::new(seed), + message: "Reach 256".into(), + outcome: Outcome::Playing, + }; + game.spawn(); + game.spawn(); + game + } + + fn spawn(&mut self) { + let empty = self + .cells + .iter() + .enumerate() + .filter_map(|(index, value)| (*value == 0).then_some(index)) + .collect::>(); + if let Some(&index) = empty.get(self.rng.index(empty.len())) { + self.cells[index] = if self.rng.chance(1, 10) { 4 } else { 2 }; + } + } + + fn slide(&mut self, direction: Slide) { + let before = self.cells; + let mut gained = 0; + for line in 0..4 { + let positions = (0..4) + .map(|offset| match direction { + Slide::Left => line * 4 + offset, + Slide::Right => line * 4 + 3 - offset, + Slide::Up => offset * 4 + line, + Slide::Down => (3 - offset) * 4 + line, + }) + .collect::>(); + let values = positions + .iter() + .map(|position| self.cells[*position]) + .filter(|value| *value != 0) + .collect::>(); + let mut merged = Vec::with_capacity(4); + let mut index = 0; + while index < values.len() { + if values.get(index + 1) == values.get(index) { + let value = values[index] * 2; + merged.push(value); + gained += u32::from(value); + index += 2; + } else { + merged.push(values[index]); + index += 1; + } + } + merged.resize(4, 0); + for (position, value) in positions.into_iter().zip(merged) { + self.cells[position] = value; + } + } + if self.cells == before { + if can_move(&self.cells) { + self.message = "No tiles moved".into(); + } else { + self.outcome = Outcome::Lost("NUMBER MERGE // no moves remain".into()); + } + return; + } + self.score += gained; + self.spawn(); + self.message = if gained > 0 { + format!("Merged +{gained}") + } else { + "Shifted".into() + }; + if self.cells.iter().any(|value| *value >= 256) { + self.outcome = Outcome::Won("NUMBER MERGE // 256 reached".into()); + } else if !can_move(&self.cells) { + self.outcome = Outcome::Lost("NUMBER MERGE // no moves remain".into()); + } + } +} + +fn can_move(cells: &[u16; 16]) -> bool { + cells.contains(&0) + || (0..4) + .any(|row| (0..3).any(|column| cells[row * 4 + column] == cells[row * 4 + column + 1])) + || (0..3).any(|row| { + (0..4).any(|column| cells[row * 4 + column] == cells[(row + 1) * 4 + column]) + }) +} + +impl Game for Merge { + fn input(&mut self, input: Input) { + match input { + Input::Left | Input::Char('a' | 'h') => self.slide(Slide::Left), + Input::Right | Input::Char('d' | 'l') => self.slide(Slide::Right), + Input::Up | Input::Char('w' | 'k') => self.slide(Slide::Up), + Input::Down | Input::Char('s' | 'j') => self.slide(Slide::Down), + _ => {} + } + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let border = "+------+------+------+------+"; + let mut lines = Vec::with_capacity(9); + lines.push(border.into()); + for row in 0..4 { + lines.push( + (0..4) + .map(|column| { + let value = self.cells[row * 4 + column]; + if value == 0 { + "| ".to_owned() + } else { + format!("|{value:^6}") + } + }) + .collect::() + + "|", + ); + lines.push(border.into()); + } + Scene { + title: "NUMBER MERGE", + status: format!("score {} {}", self.score, self.message), + lines, + help: "Arrows/WASD slide r restart Esc menu q quit", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +pub(super) fn cards(seed: u64) -> Box { + Box::new(Cards::new(seed)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Card { + rank: u8, + suit: char, +} + +struct Cards { + deck: Vec, + hand: [Card; 5], + selected: [bool; 5], + round: usize, + redraw: bool, + total: u32, + message: String, + outcome: Outcome, +} + +impl Cards { + const TARGETS: [u32; 3] = [18, 24, 30]; + + fn new(seed: u64) -> Self { + let mut rng = Rng::new(seed); + let mut deck = ['S', 'H', 'D', 'C'] + .into_iter() + .flat_map(|suit| (1..=13).map(move |rank| Card { rank, suit })) + .collect::>(); + for index in (1..deck.len()).rev() { + let other = rng.index(index + 1); + deck.swap(index, other); + } + let hand = std::array::from_fn(|_| deck.pop().expect("full deck")); + Self { + deck, + hand, + selected: [false; 5], + round: 0, + redraw: true, + total: 0, + message: "Select up to three cards".into(), + outcome: Outcome::Playing, + } + } + + fn toggle(&mut self, index: usize) { + if self.selected[index] { + self.selected[index] = false; + } else if self.selected.iter().filter(|value| **value).count() < 3 { + self.selected[index] = true; + } else { + self.message = "Only three cards may score".into(); + } + } + + fn redraw(&mut self) { + if !self.redraw { + self.message = "Redraw already used".into(); + return; + } + for index in 0..5 { + if !self.selected[index] + && let Some(card) = self.deck.pop() + { + self.hand[index] = card; + } + } + self.redraw = false; + self.message = "Unselected cards replaced".into(); + } + + fn score(&mut self) { + let selected = self + .hand + .iter() + .zip(self.selected) + .filter_map(|(card, selected)| selected.then_some(*card)) + .collect::>(); + if selected.is_empty() { + self.message = "Select at least one card".into(); + return; + } + let score = score_cards(&selected); + let target = Self::TARGETS[self.round]; + if score < target { + self.outcome = + Outcome::Lost(format!("HAND THRESHOLD // {score} falls short of {target}")); + return; + } + self.total += score; + self.round += 1; + if self.round == Self::TARGETS.len() { + self.outcome = Outcome::Won(format!( + "HAND THRESHOLD // all rounds cleared ({})", + self.total + )); + return; + } + if self.deck.len() < 5 { + self.outcome = Outcome::Lost("HAND THRESHOLD // deck exhausted".into()); + return; + } + self.hand = std::array::from_fn(|_| self.deck.pop().expect("checked deck")); + self.selected = [false; 5]; + self.redraw = true; + self.message = format!("Round cleared with {score}"); + } +} + +fn score_cards(cards: &[Card]) -> u32 { + let mut counts = [0_u8; 14]; + let mut score = 0; + for card in cards { + counts[card.rank as usize] += 1; + score += u32::from(card.rank.min(10)); + } + score + + counts + .iter() + .map(|count| match count { + 2 => 8, + 3 => 20, + _ => 0, + }) + .sum::() +} + +fn card_label(card: Card) -> String { + let rank = match card.rank { + 1 => "A".into(), + 11 => "J".into(), + 12 => "Q".into(), + 13 => "K".into(), + value => value.to_string(), + }; + format!("{rank}{}", card.suit) +} + +impl Game for Cards { + fn input(&mut self, input: Input) { + match input { + Input::Char(character @ '1'..='5') => { + self.toggle(character as usize - '1' as usize); + } + Input::Char('d') => self.redraw(), + Input::Enter => self.score(), + _ => {} + } + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let cards = self + .hand + .iter() + .enumerate() + .map(|(index, card)| { + format!( + "{}{:^5}{}", + if self.selected[index] { '[' } else { ' ' }, + card_label(*card), + if self.selected[index] { ']' } else { ' ' } + ) + }) + .collect::>(); + Scene { + title: "HAND THRESHOLD", + status: format!( + "round {}/3 target {} total {} redraw {} {}", + self.round + 1, + Self::TARGETS[self.round.min(2)], + self.total, + if self.redraw { "ready" } else { "used" }, + self.message + ), + lines: vec![ + "Build a scoring set of at most three cards".into(), + String::new(), + cards.join(" "), + " 1 2 3 4 5".into(), + String::new(), + "Ranks score face value; pairs +8, triples +20".into(), + ], + help: "1-5 select d redraw unselected Enter score r restart Esc menu", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +pub(super) fn sprint(seed: u64) -> Box { + Box::new(Sprint::new(seed)) +} + +const PROMPTS: &[&str] = &[ + "quiet terminals make sharp tools", + "small loops keep latency honest", + "measure twice then remove the wrapper", + "clean code leaves the cursor where it found it", +]; + +struct Sprint { + prompt: &'static str, + typed: String, + mistakes: u8, + started: Instant, + message: String, + outcome: Outcome, +} + +impl Sprint { + fn new(seed: u64) -> Self { + let mut rng = Rng::new(seed); + Self { + prompt: PROMPTS[rng.index(PROMPTS.len())], + typed: String::new(), + mistakes: 0, + started: Instant::now(), + message: "Type the prompt exactly".into(), + outcome: Outcome::Playing, + } + } + + fn type_character(&mut self, character: char) { + if character.is_control() || self.typed.len() >= self.prompt.len() { + return; + } + let expected = self.prompt.as_bytes()[self.typed.len()] as char; + if character != expected { + self.mistakes += 1; + self.message = format!("Expected {expected:?}"); + if self.mistakes > 5 { + self.outcome = Outcome::Lost("TYPE SPRINT // too many mistakes".into()); + } + } + self.typed.push(character); + if self.typed == self.prompt { + let seconds = self.started.elapsed().as_secs_f64().max(0.1); + let words = self.prompt.split_whitespace().count() as f64; + self.outcome = Outcome::Won(format!( + "TYPE SPRINT // {:.0} wpm with {} mistakes", + words / seconds * 60.0, + self.mistakes + )); + } + } +} + +impl Game for Sprint { + fn input(&mut self, input: Input) { + match input { + Input::Char(character) => self.type_character(character), + Input::Backspace => { + self.typed.pop(); + self.message = "Correct the line".into(); + } + Input::Enter if self.typed != self.prompt => { + self.message = "The prompt is not complete".into(); + } + _ => {} + } + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let caret = format!("{}^", " ".repeat(self.typed.len())); + Scene { + title: "TYPE SPRINT", + status: format!( + "progress {}/{} mistakes {}/5 {}", + self.typed.len(), + self.prompt.len(), + self.mistakes, + self.message + ), + lines: vec![ + "TARGET".into(), + self.prompt.into(), + String::new(), + "INPUT".into(), + self.typed.clone(), + caret, + ], + help: "Type Backspace correct Ctrl-R restart Esc menu Ctrl-C quit", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } + + fn accepts_text(&self) -> bool { + true + } +} + +pub(super) fn glyphs(seed: u64) -> Box { + Box::new(Glyphs::new(seed)) +} + +const ANSWERS: &[&str] = &[ + "AMBER", "BRISK", "CLOUD", "EMBER", "FROST", "GRAIN", "HONEY", "IVORY", "MANGO", "NORTH", + "OCEAN", "PRISM", "RIVER", "SOLAR", "THORN", "VIVID", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mark { + Absent, + Present, + Correct, +} + +struct Glyphs { + answer: &'static str, + current: String, + rows: Vec<(String, [Mark; 5])>, + message: String, + outcome: Outcome, +} + +impl Glyphs { + fn new(seed: u64) -> Self { + let mut rng = Rng::new(seed); + Self { + answer: ANSWERS[rng.index(ANSWERS.len())], + current: String::new(), + rows: Vec::new(), + message: "Enter five letters".into(), + outcome: Outcome::Playing, + } + } + + fn submit(&mut self) { + if self.current.len() != 5 { + self.message = "A guess needs five letters".into(); + return; + } + let guess = std::mem::take(&mut self.current); + let marks = grade(self.answer, &guess); + let won = marks.iter().all(|mark| *mark == Mark::Correct); + self.rows.push((guess, marks)); + if won { + self.outcome = Outcome::Won(format!("FIVE GLYPHS // {} found", self.answer)); + } else if self.rows.len() == 6 { + self.outcome = Outcome::Lost(format!("FIVE GLYPHS // answer was {}", self.answer)); + } else { + self.message = format!("{} attempts remain", 6 - self.rows.len()); + } + } +} + +fn grade(answer: &str, guess: &str) -> [Mark; 5] { + let answer = answer.as_bytes(); + let guess = guess.as_bytes(); + let mut marks = [Mark::Absent; 5]; + let mut used = [false; 5]; + for index in 0..5 { + if guess[index] == answer[index] { + marks[index] = Mark::Correct; + used[index] = true; + } + } + for guess_index in 0..5 { + if marks[guess_index] == Mark::Correct { + continue; + } + if let Some(answer_index) = + (0..5).find(|index| !used[*index] && answer[*index] == guess[guess_index]) + { + marks[guess_index] = Mark::Present; + used[answer_index] = true; + } + } + marks +} + +fn marked_row(word: &str, marks: [Mark; 5]) -> String { + word.chars() + .zip(marks) + .map(|(character, mark)| match mark { + Mark::Correct => format!("[{character}]"), + Mark::Present => format!("({character})"), + Mark::Absent => format!(" {character} "), + }) + .collect::>() + .join(" ") +} + +impl Game for Glyphs { + fn input(&mut self, input: Input) { + match input { + Input::Char(character) if character.is_ascii_alphabetic() && self.current.len() < 5 => { + self.current.push(character.to_ascii_uppercase()); + } + Input::Backspace => { + self.current.pop(); + } + Input::Enter => self.submit(), + _ => {} + } + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let mut lines = self + .rows + .iter() + .map(|(word, marks)| marked_row(word, *marks)) + .collect::>(); + while lines.len() < 6 { + if lines.len() == self.rows.len() { + let mut input = self.current.clone(); + input.push_str(&"_".repeat(5 - input.len())); + lines.push( + input + .chars() + .map(|character| format!(" {character} ")) + .collect::>() + .join(" "), + ); + } else { + lines.push(" _ _ _ _ _ ".into()); + } + } + Scene { + title: "FIVE GLYPHS", + status: self.message.clone(), + lines, + help: "Letters type Backspace erase Enter submit Ctrl-R restart Esc menu", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } + + fn accepts_text(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_combines_each_tile_once() { + let mut game = Merge::new(1); + game.cells = [2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + game.slide(Slide::Left); + assert_eq!(&game.cells[..4], &[4, 4, 0, 0]); + assert_eq!(game.score, 8); + + game.cells = [2, 4, 2, 4, 4, 2, 4, 2, 2, 4, 2, 4, 4, 2, 4, 2]; + game.slide(Slide::Left); + assert!(matches!(game.outcome, Outcome::Lost(_))); + } + + #[test] + fn cards_reward_sets_and_glyphs_handle_duplicates() { + let pair = [ + Card { rank: 7, suit: 'S' }, + Card { rank: 7, suit: 'H' }, + Card { rank: 2, suit: 'D' }, + ]; + assert_eq!(score_cards(&pair), 24); + assert_eq!( + grade("AMBER", "ARRAY"), + [ + Mark::Correct, + Mark::Present, + Mark::Absent, + Mark::Absent, + Mark::Absent + ] + ); + } + + #[test] + fn sprint_has_a_finite_failure_path() { + let mut game = Sprint::new(1); + for _ in 0..6 { + game.type_character('!'); + game.typed.clear(); + } + assert!(matches!(game.outcome, Outcome::Lost(_))); + } +} diff --git a/src/games/turns.rs b/src/games/turns.rs new file mode 100644 index 0000000..2a26d29 --- /dev/null +++ b/src/games/turns.rs @@ -0,0 +1,609 @@ +use std::time::Duration; + +use super::{Game, Input, Outcome, Rng, Scene}; + +pub(super) fn orbit(seed: u64) -> Box { + Box::new(Orbit::new(seed)) +} + +const TACTICS: [(&str, &str); 3] = [ + ("DRIVE", "steady two-point attempt"), + ("ARC", "risky three-point attempt"), + ("RESET", "one-point cut with stamina recovery"), +]; + +struct Orbit { + possession: u8, + tactic: usize, + home: u16, + away: u16, + stamina: i8, + rng: Rng, + message: String, + outcome: Outcome, +} + +impl Orbit { + fn new(seed: u64) -> Self { + Self { + possession: 0, + tactic: 0, + home: 0, + away: 0, + stamina: 8, + rng: Rng::new(seed), + message: "Choose a tactic, then resolve".into(), + outcome: Outcome::Playing, + } + } + + fn resolve(&mut self) { + let (chance, points, cost) = match self.tactic { + 0 => (7, 2, 2), + 1 => (4, 3, 3), + _ => (8, 1, -2), + }; + let adjusted = (chance + self.stamina.max(0) / 3).clamp(1, 9) as u64; + let scored = self.rng.chance(adjusted, 10); + if scored { + self.home += points; + } + self.stamina = (self.stamina - cost).clamp(0, 10); + let rival_points = if self.rng.chance(2, 10) { 3 } else { 2 }; + let rival_scored = self.rng.chance(6, 10); + if rival_scored { + self.away += rival_points; + } + self.possession += 1; + self.message = format!( + "You {} {} // rival {} {}", + if scored { "score" } else { "miss" }, + points, + if rival_scored { "scores" } else { "misses" }, + rival_points + ); + if self.possession == 16 && self.home == self.away { + if self.rng.chance(1, 2) { + self.home += 1; + } else { + self.away += 1; + } + } + if self.possession >= 12 && self.home != self.away { + self.outcome = if self.home > self.away { + Outcome::Won(format!("ORBIT COURT // {}-{}", self.home, self.away)) + } else { + Outcome::Lost(format!("ORBIT COURT // {}-{}", self.home, self.away)) + }; + } else if self.possession >= 12 { + self.message.push_str(" // sudden orbit"); + } + } +} + +impl Game for Orbit { + fn input(&mut self, input: Input) { + match input { + Input::Left | Input::Char('a' | 'h') => { + self.tactic = self.tactic.checked_sub(1).unwrap_or(TACTICS.len() - 1); + } + Input::Right | Input::Char('d' | 'l') => { + self.tactic = (self.tactic + 1) % TACTICS.len(); + } + Input::Enter | Input::Char(' ') => self.resolve(), + _ => {} + } + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let meter = format!( + "[{}{}]", + "=".repeat(self.stamina as usize), + " ".repeat(10 - self.stamina as usize) + ); + let tactics = TACTICS + .iter() + .enumerate() + .map(|(index, (name, summary))| { + format!( + "{} {:5} {}", + if index == self.tactic { ">" } else { " " }, + name, + summary + ) + }) + .collect::>(); + Scene { + title: "ORBIT COURT", + status: format!( + "possession {} HOME {} : {} RIVAL stamina {} {}", + self.possession + 1, + self.home, + self.away, + meter, + self.message + ), + lines: [ + vec![ + " . * .".into(), + " HOME [O]-----------( ) RIVAL".into(), + " ' * '".into(), + String::new(), + ], + tactics, + ] + .concat(), + help: "Left/Right tactic Enter resolve r restart Esc menu q quit", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +pub(super) fn seedling(seed: u64) -> Box { + Box::new(Seedling::new(seed)) +} + +struct Seedling { + day: u8, + moisture: i8, + health: i8, + growth: u8, + watered: bool, + rng: Rng, + weather: &'static str, + message: String, + outcome: Outcome, +} + +impl Seedling { + fn new(seed: u64) -> Self { + Self { + day: 0, + moisture: 5, + health: 5, + growth: 0, + watered: false, + rng: Rng::new(seed), + weather: "mild", + message: "Water once if needed, then advance the day".into(), + outcome: Outcome::Playing, + } + } + + fn water(&mut self) { + if self.watered { + self.message = "Already watered today".into(); + } else { + self.moisture = (self.moisture + 3).min(10); + self.watered = true; + self.message = "The soil darkens".into(); + } + } + + fn next_day(&mut self) { + self.day += 1; + let roll = self.rng.index(3); + self.weather = ["sunny", "mild", "rainy"][roll]; + self.moisture += match roll { + 0 => -3, + 1 => -2, + _ => 1, + }; + self.moisture = self.moisture.clamp(0, 10); + if (2..=8).contains(&self.moisture) { + self.growth += 1; + self.message = "A fresh leaf unfolds".into(); + } else { + self.health -= 1; + self.message = if self.moisture < 2 { + "The soil is too dry" + } else { + "The roots are waterlogged" + } + .into(); + } + self.watered = false; + if self.health <= 0 { + self.outcome = Outcome::Lost("SEEDLING // the cycle ended early".into()); + } else if self.day >= 7 { + self.outcome = Outcome::Won(format!("SEEDLING // seven days, {} leaves", self.growth)); + } + } + + fn plant(&self) -> Vec { + match self.growth { + 0 => vec![" .".into(), "____/ \\____".into()], + 1..=2 => vec![" |".into(), " /".into(), "____|_____".into()], + 3..=4 => vec![ + " \\ | /".into(), + " \\|".into(), + " |".into(), + "_____|_____".into(), + ], + _ => vec![ + " .-.-.".into(), + " \\ | /".into(), + " \\ | /".into(), + " |".into(), + "____|_____".into(), + ], + } + } +} + +impl Game for Seedling { + fn input(&mut self, input: Input) { + match input { + Input::Char('w') => self.water(), + Input::Char('n') | Input::Enter => self.next_day(), + _ => {} + } + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + Scene { + title: "SEEDLING", + status: format!( + "day {}/7 weather {} moisture {}/10 health {}/5 {}", + self.day, self.weather, self.moisture, self.health, self.message + ), + lines: self.plant(), + help: "w water once n/Enter next day r restart Esc menu q quit", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +pub(super) fn keybed(seed: u64) -> Box { + Box::new(Keybed::new(seed)) +} + +const NOTES: [char; 8] = ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k']; + +struct Keybed { + sequence: [u8; 8], + shown: usize, + listening: bool, + cursor: usize, + misses: u8, + message: String, + outcome: Outcome, +} + +impl Keybed { + fn new(seed: u64) -> Self { + let mut rng = Rng::new(seed); + Self { + sequence: std::array::from_fn(|_| rng.index(NOTES.len()) as u8), + shown: 0, + listening: false, + cursor: 0, + misses: 0, + message: "Watch the melody".into(), + outcome: Outcome::Playing, + } + } + + fn play(&mut self, note: char) { + if !self.listening { + self.message = "Wait until the melody is hidden".into(); + return; + } + if note == NOTES[self.sequence[self.cursor] as usize] { + self.cursor += 1; + self.message = "Correct note".into(); + if self.cursor == self.sequence.len() { + self.outcome = Outcome::Won("KEYBED // melody repeated".into()); + } + } else { + self.misses += 1; + self.message = "Wrong note".into(); + if self.misses >= 3 { + self.outcome = Outcome::Lost("KEYBED // three notes missed".into()); + } + } + } +} + +impl Game for Keybed { + fn input(&mut self, input: Input) { + if let Input::Char(note) = input + && NOTES.contains(¬e) + { + self.play(note); + } + } + + fn tick(&mut self) { + if self.listening { + return; + } + if self.shown < self.sequence.len() { + self.shown += 1; + } else { + self.listening = true; + self.message = "Repeat the eight notes".into(); + } + } + + fn tick_rate(&self) -> Option { + (!self.listening).then_some(Duration::from_millis(450)) + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let melody = if self.listening { + "_ ".repeat(self.sequence.len()) + } else { + self.sequence + .iter() + .take(self.shown) + .map(|note| NOTES[*note as usize].to_ascii_uppercase().to_string()) + .collect::>() + .join(" ") + }; + let keys = NOTES + .iter() + .map(|note| format!("[{note}]")) + .collect::>() + .join(" "); + Scene { + title: "KEYBED", + status: format!( + "notes {}/8 misses {}/3 {}", + self.cursor, self.misses, self.message + ), + lines: vec![ + "MELODY".into(), + melody, + String::new(), + "+--+--+--+--+--+--+--+--+".into(), + format!("| | | | | | | | |"), + "+--+--+--+--+--+--+--+--+".into(), + keys, + ], + help: "a s d f g h j k play r restart Esc menu q quit", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +pub(super) fn scout(seed: u64) -> Box { + Box::new(Scout::new(seed)) +} + +const FIELD: [&str; 8] = [ + "##########", + "#@..\"....#", + "#..##..\".#", + "#...\"....#", + "#.###....##", + "#..\"..\"..#", + "#.......G#", + "##########", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Position { + x: usize, + y: usize, +} + +struct Foe { + hp: i8, + strength: i8, +} + +struct Scout { + player: Position, + hp: i8, + foe: Option, + bind_charges: u8, + recruited: bool, + encountered: bool, + rng: Rng, + message: String, + outcome: Outcome, +} + +impl Scout { + fn new(seed: u64) -> Self { + Self { + player: Position { x: 1, y: 1 }, + hp: 10, + foe: None, + bind_charges: 3, + recruited: false, + encountered: false, + rng: Rng::new(seed), + message: "Find a field creature before reaching G".into(), + outcome: Outcome::Playing, + } + } + + fn tile(position: Position) -> char { + FIELD[position.y].as_bytes()[position.x] as char + } + + fn walk(&mut self, dx: isize, dy: isize) { + if self.foe.is_some() { + self.message = "Resolve the encounter first".into(); + return; + } + let next = Position { + x: self.player.x.saturating_add_signed(dx), + y: self.player.y.saturating_add_signed(dy), + }; + if Self::tile(next) == '#' { + self.message = "Dense brush blocks the path".into(); + return; + } + self.player = next; + match Self::tile(next) { + '"' if !self.recruited && (!self.encountered || self.rng.chance(1, 2)) => { + self.encountered = true; + self.foe = Some(Foe { hp: 6, strength: 2 }); + self.message = "A bright field creature appears".into(); + } + 'G' if self.recruited => { + self.outcome = Outcome::Won("FIELD SCOUT // recruit returned safely".into()); + } + 'G' => self.message = "Recruit a creature before leaving".into(), + _ => self.message = "The field rustles".into(), + } + } + + fn strike(&mut self) { + let damage = 2 + self.rng.index(3) as i8; + if let Some(foe) = &mut self.foe { + foe.hp -= damage; + self.message = format!("Strike deals {damage}"); + if foe.hp <= 0 { + self.foe = None; + self.message = "The creature flees; find another".into(); + return; + } + } + self.counterattack(); + } + + fn bind(&mut self) { + if self.bind_charges == 0 { + self.message = "No bind charges remain".into(); + self.counterattack(); + return; + } + self.bind_charges -= 1; + if self.foe.as_ref().is_some_and(|foe| foe.hp <= 3) { + self.recruited = true; + self.foe = None; + self.message = "Creature recruited; reach G".into(); + } else { + self.message = "The creature breaks free".into(); + self.counterattack(); + } + } + + fn counterattack(&mut self) { + if let Some(foe) = &self.foe { + self.hp -= foe.strength; + if self.hp <= 0 { + self.outcome = Outcome::Lost("FIELD SCOUT // forced to retreat".into()); + } + } + } +} + +impl Game for Scout { + fn input(&mut self, input: Input) { + if self.foe.is_some() { + match input { + Input::Char('1') | Input::Enter => self.strike(), + Input::Char('2') => self.bind(), + Input::Char('3') => { + self.foe = None; + self.message = "You retreat into the grass".into(); + } + _ => {} + } + return; + } + match input { + Input::Up | Input::Char('w' | 'k') => self.walk(0, -1), + Input::Down | Input::Char('s' | 'j') => self.walk(0, 1), + Input::Left | Input::Char('a' | 'h') => self.walk(-1, 0), + Input::Right | Input::Char('d' | 'l') => self.walk(1, 0), + _ => {} + } + } + + fn scene(&self, _width: u16, _height: u16) -> Scene { + let mut map = FIELD + .iter() + .map(|row| row.chars().collect::>()) + .collect::>(); + map[self.player.y][self.player.x] = '@'; + let mut lines = map + .into_iter() + .map(|row| row.into_iter().collect()) + .collect::>(); + if let Some(foe) = &self.foe { + lines.extend([ + String::new(), + format!("ENCOUNTER creature hp {}/6", foe.hp.max(0)), + "1 strike 2 bind 3 retreat".into(), + ]); + } + Scene { + title: "FIELD SCOUT", + status: format!( + "hp {}/10 bind {} recruit {} {}", + self.hp.max(0), + self.bind_charges, + if self.recruited { "yes" } else { "no" }, + self.message + ), + lines, + help: "WASD move encounter: 1 strike 2 bind 3 retreat Esc menu", + } + } + + fn outcome(&self) -> &Outcome { + &self.outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn orbit_ends_after_regulation_or_sudden_death() { + for seed in 0..256 { + let mut game = Orbit::new(seed); + for _ in 0..16 { + if game.outcome != Outcome::Playing { + break; + } + game.resolve(); + } + assert_ne!(game.outcome, Outcome::Playing, "seed {seed}"); + assert!(game.possession <= 16); + } + } + + #[test] + fn seedling_and_keybed_have_finite_win_paths() { + let mut seedling = Seedling::new(5); + for _ in 0..7 { + seedling.water(); + seedling.next_day(); + } + assert_ne!(seedling.outcome, Outcome::Playing); + + let mut keybed = Keybed::new(6); + keybed.listening = true; + let sequence = keybed.sequence; + for note in sequence { + keybed.play(NOTES[note as usize]); + } + assert!(matches!(keybed.outcome, Outcome::Won(_))); + } + + #[test] + fn scout_recruits_a_weakened_creature() { + let mut game = Scout::new(7); + game.foe = Some(Foe { hp: 3, strength: 1 }); + game.bind(); + assert!(game.recruited); + assert!(game.foe.is_none()); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 823253c..1a5d5bc 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -8,11 +8,6 @@ fn command(binary: &str) -> Command { }; let mut command = Command::new(path); command.env("NO_COLOR", "1"); - command.env_remove("TOOLS_GAME_PATH"); - command.env( - "XDG_DATA_HOME", - std::env::temp_dir().join("keys-tools-test-empty"), - ); command } @@ -33,7 +28,7 @@ fn fetch_supports_plain_json_and_invalid_input() { } #[test] -fn games_lists_json_and_refuses_unknown_games() { +fn games_lists_native_modes_and_rejects_invalid_input() { let list = command("games") .args(["list", "--json"]) .output() @@ -41,7 +36,10 @@ fn games_lists_json_and_refuses_unknown_games() { assert!(list.status.success()); let output = String::from_utf8_lossy(&list.stdout); assert!(output.starts_with('[')); - assert!(output.contains("\"name\":\"2048\"")); + assert!(output.contains("\"name\":\"merge\"")); + assert!(output.contains("\"name\":\"serpent\"")); + assert_eq!(output.matches("\"native\":true").count(), 11); + assert!(!output.contains("\"command\"")); let missing = command("games") .args(["run", "not-a-game"]) @@ -50,42 +48,29 @@ fn games_lists_json_and_refuses_unknown_games() { assert_eq!(missing.status.code(), Some(2)); assert!(String::from_utf8_lossy(&missing.stderr).contains("unknown game")); - let unavailable = command("games") - .env("PATH", "") - .args(["run", "2048"]) + let invalid_seed = command("games") + .args(["run", "merge", "--seed", "no"]) .output() - .expect("report unavailable game"); - assert_eq!(unavailable.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&unavailable.stderr).contains("github.com/ps06756/tui-2048")); + .expect("reject invalid seed"); + assert_eq!(invalid_seed.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid_seed.stderr).contains("--seed")); } #[test] -fn games_launches_a_manifest_command_without_a_shell() { - let directory = std::env::temp_dir().join(format!( - "keys-tools-game-pack-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&directory).expect("create pack"); - std::fs::write( - directory.join("probe.game"), - format!( - "name=probe\ncommand={}\nsummary=Test command\nsource=https://example.com/probe\nlicense=MIT\n", - env!("CARGO_BIN_EXE_fetch") - ), - ) - .expect("write manifest"); - - let output = command("games") - .env("TOOLS_GAME_PATH", &directory) - .args(["run", "probe", "--version"]) +fn games_describes_native_modes_and_requires_a_terminal_to_play() { + let info = command("games") + .args(["info", "merge"]) .output() - .expect("run manifest command"); - let _ = std::fs::remove_dir_all(&directory); + .expect("show mode info"); + assert!(info.status.success()); + let text = String::from_utf8_lossy(&info.stdout); + assert!(text.contains("Implementation\tNative clean-room Rust")); + assert!(text.contains("Title\tNUMBER MERGE")); - assert!(output.status.success()); - assert_eq!( - String::from_utf8_lossy(&output.stdout).trim(), - "fetch 0.1.0" - ); + let non_tty = command("games") + .args(["run", "merge", "--seed", "1"]) + .output() + .expect("reject non-terminal play"); + assert_eq!(non_tty.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&non_tty.stderr).contains("need a terminal")); }