From d0ce3b227850127a5c5b84b456aeb487910ff333 Mon Sep 17 00:00:00 2001 From: Tesla1983 <30205867+Tesla1983@users.noreply.github.com> Date: Sun, 17 May 2026 20:16:34 +0800 Subject: [PATCH] Add Rust binary release workflow --- .github/workflows/release-binaries.yml | 76 +++++++++ rust/src/games.rs | 209 +++++++++++++++++++++++++ rust/src/lib.rs | 1 + rust/src/shell.rs | 99 ++++++++++++ 4 files changed, 385 insertions(+) create mode 100644 .github/workflows/release-binaries.yml create mode 100644 rust/src/games.rs diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 0000000..c5ca0b6 --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -0,0 +1,76 @@ +name: Release Rust Binaries + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + archive: tar.gz + - os: macos-13 + target: x86_64-apple-darwin + archive: tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + archive: zip + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build minios binary + run: cargo build --release --locked --bin minios --target ${{ matrix.target }} + + - name: Package Unix binary + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + mkdir -p dist/package + cp "target/${{ matrix.target }}/release/minios" dist/package/minios + tar -C dist/package -czf "dist/minios-${{ matrix.target }}.tar.gz" minios + + - name: Package Windows binary + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist/package | Out-Null + Copy-Item "target/${{ matrix.target }}/release/minios.exe" "dist/package/minios.exe" + Compress-Archive -Path "dist/package/minios.exe" -DestinationPath "dist/minios-${{ matrix.target }}.zip" -Force + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: minios-${{ matrix.target }} + path: dist/minios-${{ matrix.target }}.${{ matrix.archive }} + if-no-files-found: error + + - name: Upload binaries to GitHub release + if: startsWith(github.ref, 'refs/tags/') + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + gh release view "${TAG_NAME}" >/dev/null 2>&1 || \ + gh release create "${TAG_NAME}" --title "${TAG_NAME}" --notes "Automated MiniOS Rust host CLI binaries." + gh release upload "${TAG_NAME}" dist/minios-${{ matrix.target }}.${{ matrix.archive }} --clobber diff --git a/rust/src/games.rs b/rust/src/games.rs new file mode 100644 index 0000000..33c016d --- /dev/null +++ b/rust/src/games.rs @@ -0,0 +1,209 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +const X_MAX: f32 = 320.0; +const Y_MAX: f32 = 230.0; + +#[derive(Debug, Clone)] +struct Lcg { + state: u64, +} + +impl Lcg { + fn seeded() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos() as u64) + .unwrap_or(0x5eed_2024); + Self { + state: nanos ^ 0xa5a5_5a5a_1337_2024, + } + } + + fn next_u32(&mut self) -> u32 { + self.state = self + .state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (self.state >> 32) as u32 + } + + fn next_range(&mut self, min: u32, max: u32) -> u32 { + debug_assert!(min <= max); + min + (self.next_u32() % (max - min + 1)) + } + + fn next_unit(&mut self) -> f32 { + self.next_u32() as f32 / u32::MAX as f32 + } +} + +#[derive(Debug, Clone)] +struct BallState { + x: f32, + y: f32, + speed_x: f32, + speed_y: f32, +} + +/// 弹球游戏:host Rust 版本用文本帧模拟 C++ TFT 弹球动画。 +pub fn ball_game(radius: u16, num_balls: usize, trail: bool) -> Vec { + const GRAVITY: f32 = 0.05; + const FLOOR_DAMP: f32 = 0.85; + const WALL_DAMP: f32 = 0.90; + + let mut rng = Lcg::seeded(); + let radius_f = radius as f32; + let mut balls = (0..num_balls) + .map(|index| { + let angle = rng.next_unit() * std::f32::consts::TAU; + let speed = 1.5 + rng.next_unit() * 3.5; + BallState { + x: X_MAX / 2.0 + index as f32, + y: Y_MAX / 2.0, + speed_x: speed * angle.cos(), + speed_y: speed * angle.sin(), + } + }) + .collect::>(); + + for _ in 0..12 { + for ball in &mut balls { + ball.speed_y += GRAVITY; + ball.x += ball.speed_x; + ball.y += ball.speed_y; + + if ball.x - radius_f <= 0.0 { + ball.speed_x = ball.speed_x.abs() * WALL_DAMP; + ball.x = radius_f; + } else if ball.x + radius_f >= X_MAX { + ball.speed_x = -ball.speed_x.abs() * WALL_DAMP; + ball.x = X_MAX - radius_f; + } + + if ball.y - radius_f <= 0.0 { + ball.speed_y = ball.speed_y.abs() * WALL_DAMP; + ball.y = radius_f; + } else if ball.y + radius_f >= Y_MAX { + ball.speed_y = -ball.speed_y.abs() * FLOOR_DAMP; + ball.y = Y_MAX - radius_f; + } + } + } + + let mut lines = vec![ + format!("Ball / 弹球: radius={radius}, balls={num_balls}, trail={trail}"), + "Host preview: simulated 12 physics frames without TFT hardware.".to_string(), + ]; + for (index, ball) in balls.iter().enumerate() { + lines.push(format!( + "ball #{:02}: pos=({:.1},{:.1}) vel=({:.2},{:.2})", + index + 1, + ball.x, + ball.y, + ball.speed_x, + ball.speed_y + )); + } + lines +} + +/// 乒乓游戏:host Rust 版本生成一局短回合预览。 +pub fn pingpong_game() -> Vec { + let mut rng = Lcg::seeded(); + let mut x = X_MAX / 2.0; + let mut y = Y_MAX / 2.0; + let mut speed_x = if rng.next_u32() % 2 == 0 { 2.0 } else { -2.0 }; + let mut speed_y = if rng.next_u32() % 2 == 0 { 1.5 } else { -1.5 }; + let racket_x = 8.0; + let racket_y = 92.0; + let racket_len = 30.0; + let radius = 5.0; + let mut score = 0; + + for _ in 0..48 { + x += speed_x; + y += speed_y; + + if y - radius <= 0.0 || y + radius >= Y_MAX { + speed_y = -speed_y; + } + + if x + radius >= X_MAX { + speed_x = -speed_x; + } + + if x - radius <= racket_x + 5.0 && y >= racket_y && y <= racket_y + racket_len { + x = racket_x + 5.0 + radius; + speed_x = speed_x.abs(); + score += 1; + } + + if x - radius < 0.0 { + break; + } + } + + vec![ + "PingPong / 乒乓: host preview".to_string(), + format!("score={score}, ball=({x:.1},{y:.1}), velocity=({speed_x:.1},{speed_y:.1})"), + "Use the C++ ESP32 firmware for live Serial controls and TFT animation.".to_string(), + ] +} + +/// D20 骰子游戏:host Rust 版本执行一次二十面骰投掷。 +pub fn d20_game() -> Vec { + let mut rng = Lcg::seeded(); + let value = rng.next_range(1, 20); + let note = match value { + 20 => "Lucky! / 幸运!", + 1 => "Unlucky :( / 不太走运 :(", + _ => "Roll complete / 投掷完成", + }; + + vec![ + "D20 / 二十面骰子: roll".to_string(), + format!("Rolled: {value}"), + note.to_string(), + ] +} + +/// 抛硬币游戏:host Rust 版本执行一次硬币翻转。 +pub fn coin_game() -> Vec { + let mut rng = Lcg::seeded(); + let heads = rng.next_u32() % 2 == 0; + let result = if heads { + "Heads / 正面" + } else { + "Tails / 反面" + }; + + vec![ + "Coin / 抛硬币: flip".to_string(), + format!("You got: {result}"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ball_preview_contains_requested_count() { + let lines = ball_game(10, 2, true); + assert!(lines[0].contains("弹球")); + assert_eq!( + lines + .iter() + .filter(|line| line.starts_with("ball #")) + .count(), + 2 + ); + } + + #[test] + fn chance_games_include_chinese_labels() { + assert!(pingpong_game()[0].contains("乒乓")); + assert!(d20_game()[0].contains("二十面骰子")); + assert!(coin_game()[0].contains("抛硬币")); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cb3da51..f7ab8fe 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -7,6 +7,7 @@ pub mod display; pub mod filesystem; +pub mod games; pub mod kernel; pub mod shell; pub mod theme; diff --git a/rust/src/shell.rs b/rust/src/shell.rs index 51c1d4a..b698959 100644 --- a/rust/src/shell.rs +++ b/rust/src/shell.rs @@ -1,5 +1,6 @@ use crate::display::Display; use crate::filesystem::{FileSystem, MemoryFileSystem}; +use crate::games; use crate::kernel::Kernel; use crate::theme::{Theme, ThemeName}; @@ -109,6 +110,10 @@ impl MiniOs { "ps" | "processes" | "top" => self.show_processes(), "sysstat" | "stat" => self.show_system_stats(), "kill" => self.kill_command(&args), + "ball" => self.ball_command(&args), + "pong" | "pingpong" => self.print_game_lines(games::pingpong_game()), + "d20" | "dice" => self.print_game_lines(games::d20_game()), + "coin" | "coinflip" | "flip" => self.print_game_lines(games::coin_game()), "echo" => self .display .print_line(command.strip_prefix(&args.cmd).unwrap_or_default().trim()), @@ -250,6 +255,8 @@ impl MiniOs { fn show_help(&mut self) { self.display.print_line("Commands: help, version, clear, history, ls, write, append, read, rm, mv, cp, theme, ps, stat, kill, echo, exit"); + self.display + .print_line("Games: ball [radius] [count] [trail], pong, d20, coin"); self.display.print_line( "Network commands are compiled as hardware-backend stubs in this host build.", ); @@ -312,6 +319,48 @@ impl MiniOs { .print_line("Memory: managed by Rust allocator in host build"); } + fn print_game_lines(&mut self, lines: Vec) { + for line in lines { + self.display.print_line(line); + } + } + + fn ball_command(&mut self, args: &CommandArgs) { + let radius = if args.arg1.is_empty() { + 10 + } else { + match args.arg1.parse::() { + Ok(radius) if radius > 0 && radius < 116 => radius, + Ok(_) => { + self.display + .print_line("Error: ball radius must be between 1 and 115 pixels."); + return; + } + Err(_) => { + self.display + .print_line("Error: invalid radius, must be a number."); + return; + } + } + }; + + let count = if args.arg2.is_empty() { + 1 + } else { + match args.arg2.parse::() { + Ok(count) if count > 0 => count, + _ => { + self.display + .print_line("Error: number of balls must be a positive integer."); + return; + } + } + }; + + let trail = args.rest.eq_ignore_ascii_case("trail"); + self.print_game_lines(games::ball_game(radius, count, trail)); + } + fn kill_command(&mut self, args: &CommandArgs) { if args.arg1.is_empty() { self.display.print_line("Usage: kill "); @@ -361,4 +410,54 @@ mod tests { let mut os = MiniOs::default(); assert_eq!(os.run_command("exit"), CommandOutcome::Exit); } + + #[test] + fn game_commands_are_available_in_host_build() { + let mut os = MiniOs::default(); + + os.run_command("ball 8 2 trail"); + assert!(os.display.lines().iter().any(|line| line.contains("弹球"))); + assert_eq!( + os.display + .lines() + .iter() + .filter(|line| line.starts_with("ball #")) + .count(), + 2 + ); + + os.run_command("pong"); + assert!(os.display.lines().iter().any(|line| line.contains("乒乓"))); + + os.run_command("d20"); + assert!(os + .display + .lines() + .iter() + .any(|line| line.contains("二十面骰子"))); + + os.run_command("coin"); + assert!(os + .display + .lines() + .iter() + .any(|line| line.contains("抛硬币"))); + } + + #[test] + fn ball_command_validates_arguments() { + let mut os = MiniOs::default(); + + os.run_command("ball nope"); + assert_eq!( + os.display.lines().last().unwrap(), + "Error: invalid radius, must be a number." + ); + + os.run_command("ball 10 0"); + assert_eq!( + os.display.lines().last().unwrap(), + "Error: number of balls must be a positive integer." + ); + } }