From 4687259db90c76827e44ef7467cc173bbf3f3aa0 Mon Sep 17 00:00:00 2001 From: Paul Johnecheck Date: Sun, 7 Jun 2026 14:04:20 -0400 Subject: [PATCH] move stellar to fix build --- Cargo.toml | 2 +- README.md | 6 +- stellar/.gitignore | 2 + stellar/Cargo.toml | 10 + .../.dart_tool/extension_discovery/README.md | 31 + .../extension_discovery/vs_code.json | 1 + .../golden_gen/.dart_tool/package_config.json | 226 ++++++ .../golden_gen/.dart_tool/package_graph.json | 323 +++++++++ stellar/golden_gen/bin/gen_golden.dart | 187 +++++ stellar/golden_gen/pubspec.lock | 283 ++++++++ stellar/golden_gen/pubspec.yaml | 14 + stellar/src/config.rs | 192 +++++ stellar/src/engine.rs | 679 ++++++++++++++++++ stellar/src/hex.rs | 151 ++++ stellar/src/lib.rs | 21 + stellar/src/model.rs | 165 +++++ stellar/src/result.rs | 41 ++ stellar/src/zobrist.rs | 246 +++++++ stellar/tests/differential.rs | 112 +++ 19 files changed, 2688 insertions(+), 4 deletions(-) create mode 100644 stellar/.gitignore create mode 100644 stellar/Cargo.toml create mode 100644 stellar/golden_gen/.dart_tool/extension_discovery/README.md create mode 100644 stellar/golden_gen/.dart_tool/extension_discovery/vs_code.json create mode 100644 stellar/golden_gen/.dart_tool/package_config.json create mode 100644 stellar/golden_gen/.dart_tool/package_graph.json create mode 100644 stellar/golden_gen/bin/gen_golden.dart create mode 100644 stellar/golden_gen/pubspec.lock create mode 100644 stellar/golden_gen/pubspec.yaml create mode 100644 stellar/src/config.rs create mode 100644 stellar/src/engine.rs create mode 100644 stellar/src/hex.rs create mode 100644 stellar/src/lib.rs create mode 100644 stellar/src/model.rs create mode 100644 stellar/src/result.rs create mode 100644 stellar/src/zobrist.rs create mode 100644 stellar/tests/differential.rs diff --git a/Cargo.toml b/Cargo.toml index 744b313..f15d652 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ description = "Rust SDK for Star Gambit COMs" readme = "README.md" [dependencies] -stellar = { path = "../stellar" } +stellar = { path = "stellar" } tokio = { version = "1", features = [ "rt", "net", diff --git a/README.md b/README.md index e9c3b85..b29ad1b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Star Gambit SDK -The Star Gambit SDK allows developers to build computer players (coms) and make them available for players online. +The [Star Gambit](https://stargamb.it) SDK allows developers to build computer players (coms) and make them available to online Star Gambit players . To write a com, you need to write one function: "given the board, what do I do?". The SDK handles the rest. Connect it to the server with an API token and your com can be played. @@ -53,7 +53,7 @@ The `&Game` is a read-only view of the position: | Method | Returns | | --- | --- | | `legal_actions()` | every legal SGN action now, incl. `"END"` when ending the turn is allowed | -| `engine()` | the full authoritative `GameEngine` state, for custom inspection | +| `engine()` | the full authoritative `GameEngine` state. Use .execute(sgn: &str) to compute the results of an action, returning a copy of the game engine with that SGN applied.| | `me()` | which player you are: `0` (first) or `1` | | `is_my_turn()` | true while it's your turn and the game is unfinished | @@ -73,7 +73,7 @@ Every com authenticates with an API token, which you create at - **Settings → User → Manage COMs**, or - **PLAY → VS COM**, then **Manage COMs** in the COM picker dialog. 3. **Register a COM.** Press **REGISTER A COM** and pick a difficulty. A new COM - slot appears with a permanent handle (e.g. `⎇001-00000000`). You may register + slot appears with a permanent handle (e.g. `⎇011-00000000`). You may register up to three. 4. **Reveal and copy the token.** On the COM's card, press **Reveal** next to *API Token* to show it, then **Copy token** (or select the revealed text). diff --git a/stellar/.gitignore b/stellar/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/stellar/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/stellar/Cargo.toml b/stellar/Cargo.toml new file mode 100644 index 0000000..2e54455 --- /dev/null +++ b/stellar/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "stellar" +version = "0.1.0" +edition = "2021" +description = "Rust reimplementation of the star_engine game logic (GameEngine + execute)." + +[dependencies] + +[dev-dependencies] +serde_json = "1" diff --git a/stellar/golden_gen/.dart_tool/extension_discovery/README.md b/stellar/golden_gen/.dart_tool/extension_discovery/README.md new file mode 100644 index 0000000..9dc6757 --- /dev/null +++ b/stellar/golden_gen/.dart_tool/extension_discovery/README.md @@ -0,0 +1,31 @@ +Extension Discovery Cache +========================= + +This folder is used by `package:extension_discovery` to cache lists of +packages that contains extensions for other packages. + +DO NOT USE THIS FOLDER +---------------------- + + * Do not read (or rely) the contents of this folder. + * Do write to this folder. + +If you're interested in the lists of extensions stored in this folder use the +API offered by package `extension_discovery` to get this information. + +If this package doesn't work for your use-case, then don't try to read the +contents of this folder. It may change, and will not remain stable. + +Use package `extension_discovery` +--------------------------------- + +If you want to access information from this folder. + +Feel free to delete this folder +------------------------------- + +Files in this folder act as a cache, and the cache is discarded if the files +are older than the modification time of `.dart_tool/package_config.json`. + +Hence, it should never be necessary to clear this cache manually, if you find a +need to do please file a bug. diff --git a/stellar/golden_gen/.dart_tool/extension_discovery/vs_code.json b/stellar/golden_gen/.dart_tool/extension_discovery/vs_code.json new file mode 100644 index 0000000..22ad84f --- /dev/null +++ b/stellar/golden_gen/.dart_tool/extension_discovery/vs_code.json @@ -0,0 +1 @@ +{"version":2,"entries":[{"package":"hexag","rootUri":"../../../hexag/","packageUri":"lib/"},{"package":"star_engine","rootUri":"../../../star_engine/","packageUri":"lib/"},{"package":"stellar_golden_gen","rootUri":"../","packageUri":"lib/"}]} \ No newline at end of file diff --git a/stellar/golden_gen/.dart_tool/package_config.json b/stellar/golden_gen/.dart_tool/package_config.json new file mode 100644 index 0000000..3b22c93 --- /dev/null +++ b/stellar/golden_gen/.dart_tool/package_config.json @@ -0,0 +1,226 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "_fe_analyzer_shared", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/_fe_analyzer_shared-100.0.0", + "packageUri": "lib/", + "languageVersion": "3.11" + }, + { + "name": "analyzer", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/analyzer-13.0.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "args", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/args-2.7.0", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "async", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/async-2.13.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "build", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/build-4.0.6", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "build_config", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/build_config-1.3.0", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "checked_yaml", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/checked_yaml-2.0.4", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "collection", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "convert", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/convert-3.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "copy_with_extension", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/copy_with_extension-12.1.0", + "packageUri": "lib/", + "languageVersion": "3.6" + }, + { + "name": "crypto", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/crypto-3.0.7", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "dart_style", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/dart_style-3.1.9", + "packageUri": "lib/", + "languageVersion": "3.10" + }, + { + "name": "equatable", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/equatable-2.0.8", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "file", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/file-7.0.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "freezed_annotation", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/freezed_annotation-3.1.0", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "glob", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/glob-2.1.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "hexag", + "rootUri": "../../../hexag", + "packageUri": "lib/", + "languageVersion": "3.6" + }, + { + "name": "json_annotation", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/json_annotation-4.12.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "json_serializable", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/json_serializable-6.14.0", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "logging", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/logging-1.3.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "meta", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/meta-1.18.2", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "package_config", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/package_config-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "path", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pub_semver", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/pub_semver-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "pubspec_parse", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/pubspec_parse-1.5.0", + "packageUri": "lib/", + "languageVersion": "3.6" + }, + { + "name": "source_gen", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/source_gen-4.2.3", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "source_helper", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/source_helper-1.3.12", + "packageUri": "lib/", + "languageVersion": "3.9" + }, + { + "name": "source_span", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/source_span-1.10.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "star_engine", + "rootUri": "../../../star_engine", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "string_scanner", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "typed_data", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/typed_data-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "vector_math", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/vector_math-2.3.0", + "packageUri": "lib/", + "languageVersion": "3.7" + }, + { + "name": "watcher", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/watcher-1.2.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "yaml", + "rootUri": "file:///home/paul/.pub-cache/hosted/pub.dev/yaml-3.1.3", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stellar_golden_gen", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "3.8" + } + ], + "generator": "pub", + "generatorVersion": "3.11.1", + "flutterRoot": "file:///home/paul/dev/tools/flutter", + "flutterVersion": "3.41.4", + "pubCache": "file:///home/paul/.pub-cache" +} diff --git a/stellar/golden_gen/.dart_tool/package_graph.json b/stellar/golden_gen/.dart_tool/package_graph.json new file mode 100644 index 0000000..289f1f4 --- /dev/null +++ b/stellar/golden_gen/.dart_tool/package_graph.json @@ -0,0 +1,323 @@ +{ + "roots": [ + "stellar_golden_gen" + ], + "packages": [ + { + "name": "stellar_golden_gen", + "version": "0.0.0", + "dependencies": [ + "hexag", + "star_engine" + ], + "devDependencies": [] + }, + { + "name": "hexag", + "version": "1.0.0", + "dependencies": [ + "equatable", + "freezed_annotation", + "json_serializable", + "vector_math" + ] + }, + { + "name": "star_engine", + "version": "0.0.1", + "dependencies": [ + "collection", + "copy_with_extension", + "equatable", + "freezed_annotation", + "hexag", + "json_annotation" + ] + }, + { + "name": "freezed_annotation", + "version": "3.1.0", + "dependencies": [ + "collection", + "json_annotation", + "meta" + ] + }, + { + "name": "copy_with_extension", + "version": "12.1.0", + "dependencies": [ + "meta" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "equatable", + "version": "2.0.8", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "vector_math", + "version": "2.3.0", + "dependencies": [] + }, + { + "name": "json_annotation", + "version": "4.12.0", + "dependencies": [ + "meta" + ] + }, + { + "name": "meta", + "version": "1.18.2", + "dependencies": [] + }, + { + "name": "json_serializable", + "version": "6.14.0", + "dependencies": [ + "analyzer", + "async", + "build", + "build_config", + "dart_style", + "json_annotation", + "meta", + "path", + "pub_semver", + "pubspec_parse", + "source_gen", + "source_helper" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "build_config", + "version": "1.3.0", + "dependencies": [ + "checked_yaml", + "json_annotation", + "path", + "pubspec_parse" + ] + }, + { + "name": "source_helper", + "version": "1.3.12", + "dependencies": [ + "analyzer", + "source_gen" + ] + }, + { + "name": "pub_semver", + "version": "2.2.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "build", + "version": "4.0.6", + "dependencies": [ + "analyzer", + "crypto", + "glob", + "logging", + "package_config", + "path" + ] + }, + { + "name": "package_config", + "version": "2.2.0", + "dependencies": [ + "path" + ] + }, + { + "name": "source_gen", + "version": "4.2.3", + "dependencies": [ + "analyzer", + "async", + "build", + "dart_style", + "glob", + "path", + "pub_semver", + "source_span", + "yaml" + ] + }, + { + "name": "yaml", + "version": "3.1.3", + "dependencies": [ + "collection", + "source_span", + "string_scanner" + ] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "async", + "version": "2.13.1", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "checked_yaml", + "version": "2.0.4", + "dependencies": [ + "json_annotation", + "source_span", + "yaml" + ] + }, + { + "name": "dart_style", + "version": "3.1.9", + "dependencies": [ + "analyzer", + "args", + "collection", + "package_config", + "path", + "pub_semver", + "source_span", + "yaml" + ] + }, + { + "name": "analyzer", + "version": "13.0.0", + "dependencies": [ + "_fe_analyzer_shared", + "collection", + "convert", + "crypto", + "glob", + "meta", + "package_config", + "path", + "pub_semver", + "source_span", + "watcher", + "yaml" + ] + }, + { + "name": "_fe_analyzer_shared", + "version": "100.0.0", + "dependencies": [ + "meta", + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.2", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "args", + "version": "2.7.0", + "dependencies": [] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "convert", + "version": "3.1.2", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "typed_data", + "version": "1.4.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "logging", + "version": "1.3.0", + "dependencies": [] + }, + { + "name": "glob", + "version": "2.1.3", + "dependencies": [ + "async", + "collection", + "file", + "path", + "string_scanner" + ] + }, + { + "name": "file", + "version": "7.0.1", + "dependencies": [ + "meta", + "path" + ] + }, + { + "name": "watcher", + "version": "1.2.1", + "dependencies": [ + "async", + "path" + ] + }, + { + "name": "crypto", + "version": "3.0.7", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "pubspec_parse", + "version": "1.5.0", + "dependencies": [ + "checked_yaml", + "collection", + "json_annotation", + "pub_semver", + "yaml" + ] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/stellar/golden_gen/bin/gen_golden.dart b/stellar/golden_gen/bin/gen_golden.dart new file mode 100644 index 0000000..04f6bd5 --- /dev/null +++ b/stellar/golden_gen/bin/gen_golden.dart @@ -0,0 +1,187 @@ +// Generates golden game-state dumps from the Dart star_engine. +// +// For each corpus entry we build a standard `fromStarConfig` game (board +// size + ship counts), run `GameEngine.resolve(sgn)`, and dump a canonical +// snapshot of the resulting state. Error cases (where `resolve` throws) are +// recorded with `error: true` and no state. +// +// The Rust differential test (`stellar/tests/differential.rs`) reads this +// file, replays each `sgn` through `GameEngine::execute`, and asserts the +// resulting snapshot is identical — proving the two engines agree. +// +// Run: dart pub get && dart run bin/gen_golden.dart + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:star_engine/star_engine.dart'; + +const String drawGame = + "0@1 END:0 0@1 END:0 2+2 2+0 1@1 END:3269 3+1 3+2 0@1 END:8712 4+0 0@1 END:11548 5+0 5+0 3+0 1@1 END:20281 2+0 2+0 6+1 6+2 0@1 END:24692 3+1 5+1 7+0 0@1 END:37699 4+0 8+2 8+1 6+0 END:42310 7+0 7*1 9+2 9+1 END:57920 4+0 4*1 4*0 8+2 8+1 8*0 2+1 2+1 END:74860 7*1 3+2 3+2 3*0 7+0 5+2 9+0 9+0 9*0 END:83470 6+2 6*0 8+1 8*0 2+0 2*0 2+2 6+1 END:89528 9+0 9+0 5*0 5+1 5+2 END:92491 6+0 8+1 8*0 2+0 6+2"; + +const String draw2Game = + "0@1 END:0 2@1 END:0 2+1 2+0 1@0 END:8095 3+0 0@1 END:14403 4+1 2+0 2+2 2@2 END:18428 5+2 5+0 3+3 1@1 END:27007 4+0 6+1 2+1 END:30995 7+1 5+0 3+0 END:45369 2+0 6+1 4+0 END:50042 3+0 3*1 5+0 5+1 7+4 END:80821 2+2 2+0 6+1 6*2 4+0 END:90933 5+0 5+0 3+3 3*1 7+0 7*2 7*1 END:123687 6*2 4+0 4*2 6+1 END:130945 3+3 5+1 5+1 5*0 END:139925 4+0 4*1 6+1 END:147653 3+3 5+0 5+1 5*0 END:156775 4+0 4*1 4*2"; + +const String lossByNoOptionsGame = + "2@2 END:0 1@1 END:0 2+2 0@1 END:2922 3+0 1@2 END:7585 2+1 4+2 4+0 1@1 END:14039 3+2 5+2 2@0 END:19519 2+0 6+0 4+0 4+0 END:33458 3+1 3*1 5+0 7+2 END:40964 6+0 2+0 2*0 4+2 END:47253 3*1 3*0 7+0 7*2 5+0 0@1 END:54251 6+0 END:60964 5*0 3*2 8+0 8+0 3+0 5+0 END:69475 0@1 END:71871 3+0 5+0 8+0 8+0 END:80848 9+1 9+0 END:83901 3+0 8+0 8+0 5+0 END:90610 9+0 9+0 END:94759 3+0 8+0 8+0 END:102066 9+1 END:104542 5+2 END:109504"; + +class Case { + const Case(this.name, this.boardSize, this.counts, this.sgn, + {this.error = false}); + final String name; + final int boardSize; + final List counts; + final String sgn; + final bool error; +} + +const corpus = [ + Case('initial', 5, [3, 2, 1, 0], ''), + Case('place_end', 5, [3, 2, 1, 0], '0@0 END:0'), + Case('two_place', 5, [3, 2, 1, 0], '1@1 END:0 1@1 END:0'), + Case('move_after_place', 5, [3, 2, 1, 0], '1@1 END:0 1@1 END:0 2+0'), + Case('move_end_reply', 5, [3, 2, 1, 0], '1@1 END:0 1@1 END:0 2+0 END:100 3+0'), + Case('forfeit', 5, [3, 2, 1, 0], '0=FF'), + Case('roundtrip_hash', 5, [0, 1, 0, 0], + '1@1 END:0 1@1 END:0 2+3 END:1000 3+3 END:2000 2+4 END:3000 3+4 END:4000'), + Case('draw_game', 5, [3, 1, 0, 0], drawGame), + Case('draw2_game', 5, [1, 1, 1, 0], draw2Game), + Case('loss_no_options', 5, [3, 2, 1, 0], lossByNoOptionsGame), + // Error cases: resolve must throw. + Case('err_invalid_move', 5, [3, 2, 1, 0], '2+9', error: true), + Case('err_move_after_place', 5, [3, 2, 1, 0], '1@1 2+0 END:0', error: true), + Case('err_place_after_place', 5, [3, 2, 1, 0], '1@1 2@0 END:0', error: true), + Case('err_attack_after_place', 5, [3, 2, 1, 0], '1@1 2*0 END:0', error: true), + Case('err_attack_no_target', 5, [3, 2, 1, 0], '1@1 END:0 1@1 END:0 2*0 END:0', + error: true), +]; + +Map dumpState(GameEngine g) { + return { + 'zobrist': g.zobristHash.toString(), + 'whosTurn': g.whosTurn, + 'canEndTurn': g.canEndTurn, + 'timestamp': g.timestamp, + 'result': g.result?.toSGN(), + 'isDraw': g.isDraw(), + 'repetitionValues': (g.previousStateHashes.values.toList()..sort()), + 'gameLog': g.gameLog, + 'units': g.units.map((u) { + return { + 'id': u.id, + 'kind': u.kind.name, + 'owner': u.owner, + 'damage': u.damage, + 'movesThisTurn': u.movesThisTurn, + 'fired': (u.firedThisTurn.toList()..sort()), + 'x': u.position.translate.x.round(), + 'y': u.position.translate.y.round(), + 'z': u.position.translate.z.round(), + 'rotate': u.position.rotate, + }; + }).toList(), + }; +} + +/// Picks a uniformly-random legal SGN token for the current state, or null +/// if the player has no legal action (game over / stuck). Legality is +/// established by trial: each candidate is applied speculatively and kept +/// only if `resolve` accepts it. +String? randomToken(GameEngine g, Random rng, int time) { + if (g.result != null) return null; + final candidates = []; + if (g.canEndTurn) candidates.add('END:$time'); + for (final entry in g.undeployedUnits(g.whosTurn).entries) { + for (final (pid, _) in g.placements(entry.key)) { + candidates.add('${entry.key}@$pid'); + } + } + for (final u in g.activeUnitsForCurrentPlayer) { + for (int m = 0; m < u.kind.moves.length; m++) { + candidates.add('${u.id}+$m'); + } + for (int a = 0; a < u.kind.rayAttacks.length; a++) { + candidates.add('${u.id}*$a'); + } + } + final legal = []; + for (final c in candidates) { + try { + g.resolve(c); + legal.add(c); + } catch (_) {} + } + if (legal.isEmpty) return null; + return legal[rng.nextInt(legal.length)]; +} + +/// Plays a single random game (standard config) and returns its SGN. +String randomGame(Random rng, {int maxPlies = 140}) { + var g = BaseStarGambit.config.newGame(); + final tokens = []; + int time = 0; + for (int ply = 0; ply < maxPlies; ply++) { + final tok = randomToken(g, rng, time); + if (tok == null) break; + g = g.resolve(tok); + tokens.add(tok); + if (tok.startsWith('END')) time += 100 + rng.nextInt(900); + if (g.result != null) break; + } + return tokens.join(' '); +} + +void main() { + final out = >[]; + + for (final c in corpus) { + final config = StarGambitConfig.fromStarConfig( + StarConfig(boardSize: c.boardSize, counts: c.counts), + ); + final entry = { + 'name': c.name, + 'boardSize': c.boardSize, + 'counts': c.counts, + 'sgn': c.sgn, + 'error': c.error, + }; + if (c.error) { + var threw = false; + try { + config.newGame().resolve(c.sgn); + } catch (_) { + threw = true; + } + if (!threw) { + stderr.writeln('WARNING: expected "${c.name}" to throw but it did not'); + } + entry['state'] = null; + } else { + final g = config.newGame().resolve(c.sgn); + entry['state'] = dumpState(g); + } + out.add(entry); + } + + // Randomized self-play games (standard config). Fixed seed = reproducible. + const fuzzCount = 200; + final rng = Random(12345); + for (int i = 0; i < fuzzCount; i++) { + final sgn = randomGame(rng); + final g = BaseStarGambit.config.newGame().resolve(sgn); + out.add({ + 'name': 'fuzz_$i', + 'boardSize': 5, + 'counts': [3, 2, 1, 0], + 'sgn': sgn, + 'error': false, + 'state': dumpState(g), + }); + } + + final json = const JsonEncoder.withIndent(' ').convert(out); + final file = File('../golden/states.json'); + file.writeAsStringSync('$json\n'); + stdout.writeln('Wrote ${out.length} cases to ${file.path}'); +} diff --git a/stellar/golden_gen/pubspec.lock b/stellar/golden_gen/pubspec.lock new file mode 100644 index 0000000..096db10 --- /dev/null +++ b/stellar/golden_gen/pubspec.lock @@ -0,0 +1,283 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + copy_with_extension: + dependency: transitive + description: + name: copy_with_extension + sha256: "23d5c7ad882b77c65c5f596f2b941e5eabaa704fb51a3a9f1cd76c768c4c7f36" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" + url: "https://pub.dev" + source: hosted + version: "3.1.9" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hexag: + dependency: "direct main" + description: + path: "../../hexag" + relative: true + source: path + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + json_serializable: + dependency: transitive + description: + name: json_serializable + sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501 + url: "https://pub.dev" + source: hosted + version: "6.14.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + url: "https://pub.dev" + source: hosted + version: "1.3.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + star_engine: + dependency: "direct main" + description: + path: "../../star_engine" + relative: true + source: path + version: "0.0.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "47a1b32ee755c3fcffa33db52a7258c137f97bdb2209a1075be847809fac4ccf" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/stellar/golden_gen/pubspec.yaml b/stellar/golden_gen/pubspec.yaml new file mode 100644 index 0000000..7e2741f --- /dev/null +++ b/stellar/golden_gen/pubspec.yaml @@ -0,0 +1,14 @@ +name: stellar_golden_gen +description: >- + Generates golden game-state dumps from the Dart star_engine, used to prove + the Rust `stellar` crate's GameEngine.execute produces identical state. +publish_to: none + +environment: + sdk: ^3.8.0 + +dependencies: + star_engine: + path: ../../star_engine + hexag: + path: ../../hexag diff --git a/stellar/src/config.rs b/stellar/src/config.rs new file mode 100644 index 0000000..8c3b50f --- /dev/null +++ b/stellar/src/config.rs @@ -0,0 +1,192 @@ +//! Game configuration and the base Star Gambit ruleset, ported from +//! `base_config.dart` / `star_config.dart`. + +use crate::hex::{self, Hex, HexFace, HexTransform}; +use crate::model::{KindOfUnit, Move, RayAttack}; + +/// Lightweight game parameters. Mirrors `StarConfig`. +#[derive(Clone, Debug)] +pub struct StarConfig { + pub board_size: i32, + pub counts: Vec, +} + +impl StarConfig { + pub fn new(board_size: i32) -> StarConfig { + StarConfig { + board_size, + counts: vec![3, 2, 1, 0], + } + } + + pub fn with_counts(board_size: i32, counts: Vec) -> StarConfig { + StarConfig { board_size, counts } + } +} + +/// A pre-placed unit at game start. Mirrors `InitialPlacement`. +#[derive(Clone, Debug)] +pub struct InitialPlacement { + pub kind_index: usize, + pub player_index: i32, + pub transform: HexTransform, + pub initial_damage: i32, +} + +impl InitialPlacement { + pub fn new(kind_index: usize, player_index: i32, transform: HexTransform) -> InitialPlacement { + InitialPlacement { + kind_index, + player_index, + transform, + initial_damage: 0, + } + } +} + +/// Full game configuration. Mirrors `StarGambitConfig`. The notation is +/// fixed (OGSGN) and so is not stored. +#[derive(Clone, Debug)] +pub struct StarGambitConfig { + pub unit_kinds: Vec, + /// Placement options per unit-kind index (portal has none). + pub placements: Vec>, + pub initial_placements: Vec, + pub counts: Vec, + pub board_size: i32, +} + +impl StarGambitConfig { + pub fn from_star_config(sc: &StarConfig) -> StarGambitConfig { + let b = sc.board_size; + let initial_placements = vec![ + InitialPlacement::new(3, 0, HexTransform::from_translate(scale(hex::D, b))), + InitialPlacement::new(3, 1, HexTransform::new(scale(hex::A, b), 3)), + ]; + Self::from_star_config_custom(sc, initial_placements) + } + + pub fn from_star_config_custom( + sc: &StarConfig, + initial_placements: Vec, + ) -> StarGambitConfig { + StarGambitConfig { + unit_kinds: base_units(), + placements: base_placements(), + initial_placements, + counts: sc.counts.clone(), + board_size: sc.board_size, + } + } +} + +fn scale(h: Hex, s: i32) -> Hex { + Hex::new(h.x * s, h.y * s, h.z * s) +} + +/// The four base unit kinds, in canonical order: +/// `[Fighter, Cruiser, Dreadnought, Portal]`. Mirrors `BaseStarGambit`. +pub fn base_units() -> Vec { + vec![fighter(), cruiser(), dreadnought(), portal()] +} + +pub fn fighter() -> KindOfUnit { + KindOfUnit { + name: "Fighter".to_string(), + body: vec![Hex::new(0, 0, 0)], + move_: 2, + ray_attacks: vec![RayAttack::new("bow", HexFace::A, Hex::ORIGIN)], + moves: vec![ + Move::new("forward", hex::A, 0), + Move::new("Right", hex::B, 1), + Move::new("Left", hex::F, 5), + ], + health: 3, + } +} + +pub fn cruiser() -> KindOfUnit { + KindOfUnit { + name: "Cruiser".to_string(), + body: vec![Hex::ORIGIN, hex::D], + move_: 1, + ray_attacks: vec![ + RayAttack::new("L", HexFace::F, Hex::ORIGIN), + RayAttack::new("F", HexFace::A, Hex::ORIGIN), + RayAttack::new("R", HexFace::B, Hex::ORIGIN), + ], + moves: vec![ + Move::new("a", hex::A, 0), + Move::new("a", hex::B, 1), + Move::new("a", hex::F, 5), + Move::new("a", hex::C, 1), + Move::new("a", hex::E, 5), + ], + health: 4, + } +} + +pub fn dreadnought() -> KindOfUnit { + KindOfUnit { + name: "Dreadnought".to_string(), + body: vec![Hex::ORIGIN, hex::D, hex::E], + move_: 1, + ray_attacks: vec![ + RayAttack::new("L2", HexFace::A, hex::E), + RayAttack::new("L1", HexFace::A, Hex::ORIGIN), + RayAttack::new("R1", HexFace::B, Hex::ORIGIN), + RayAttack::new("R2", HexFace::B, hex::D), + ], + moves: vec![ + Move::new("a", hex::A, 0), + Move::new("a", hex::B, 0), + Move::new("a", hex::C, 1), + Move::new("a", hex::F, 5), + ], + health: 6, + } +} + +pub fn portal() -> KindOfUnit { + KindOfUnit { + name: "Portal".to_string(), + body: vec![Hex::ORIGIN, hex::F, hex::B], + move_: 0, + ray_attacks: vec![], + moves: vec![], + health: 5, + } +} + +/// Placement options per unit-kind index. Mirrors `BaseStarGambit.placements` +/// (portal index 3 has no placements). +pub fn base_placements() -> Vec> { + vec![ + // fighter + vec![ + HexTransform::new(Hex::ORIGIN, 5), + HexTransform::IDENTITY, + HexTransform::new(Hex::ORIGIN, 1), + ], + // cruiser + vec![ + HexTransform::new(hex::F, -1), + HexTransform::from_translate(hex::A), + HexTransform::new(hex::B, 1), + ], + // dreadnought + vec![ + HexTransform::new(hex::F, -2), + HexTransform::from_translate(hex::A), + HexTransform::new(hex::A, -1), + HexTransform::new(hex::B, 1), + ], + // portal (none) + vec![], + ] +} + +/// The standard Star Gambit config: board size 5, counts `[3,2,1,0]`. +pub fn base_config() -> StarGambitConfig { + StarGambitConfig::from_star_config(&StarConfig::new(5)) +} diff --git a/stellar/src/engine.rs b/stellar/src/engine.rs new file mode 100644 index 0000000..60fc0d0 --- /dev/null +++ b/stellar/src/engine.rs @@ -0,0 +1,679 @@ +//! The game engine and SGN interpreter, ported from +//! `game_engine.dart` + the `star_gambit_notation` codes. +//! +//! The single public entry point mirroring Dart's `GameEngine.resolve` is +//! [`GameEngine::execute`]. + +use std::collections::HashMap; +use std::rc::Rc; + +use crate::config::StarGambitConfig; +use crate::hex::{self, Hex, HexTransform}; +use crate::model::Unit; +use crate::result::{GameResult, LossReason}; +use crate::zobrist; + +/// An error produced while interpreting SGN. Mirrors the Dart `SGNError` +/// hierarchy (`InvalidMoveError` / `SyntaxError`); the precise sub-classes +/// of `InvalidMoveError` are collapsed into a single message string. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum SgnError { + InvalidMove { command: String, message: String }, + Syntax(String), +} + +fn invalid(command: &str, message: &str) -> SgnError { + SgnError::InvalidMove { + command: command.to_string(), + message: message.to_string(), + } +} + +/// Splits `s` on the first occurrence of `sep`. Mirrors `splitOnFirst`. +fn split_on_first(s: &str, sep: char) -> Option<(&str, &str)> { + let i = s.find(sep)?; + Some((&s[..i], &s[i + sep.len_utf8()..])) +} + +/// The full game state. Field-for-field parallel to the Dart `GameEngine`. +#[derive(Clone)] +pub struct GameEngine { + pub config: Rc, + pub whos_turn: i32, + pub result: Option, + pub units: Vec, + pub can_end_turn: bool, + pub game_log: Vec, + pub time_control: Option, + pub player0time: Option, + pub player1time: Option, + pub timestamp: i32, + /// Millisecond clock start. Only set in local games (unused here). + pub clock_start: Option, + pub local: bool, + pub previous_state_hashes: HashMap, + pub zobrist_hash: u64, + pub previous: Option>, +} + +impl GameEngine { + /// Builds the initial state for a config. Mirrors `GameEngine.initial`. + pub fn initial(config: StarGambitConfig, time_control: Option, local: bool) -> GameEngine { + let mut units = Vec::new(); + for (i, p) in config.initial_placements.iter().enumerate() { + let kind = &config.unit_kinds[p.kind_index]; + units.push(kind.new_unit(p.player_index, i as i32, p.transform, p.initial_damage)); + } + let player_time = time_control.map(|t| t * 1000); + let mut game = GameEngine { + config: Rc::new(config), + whos_turn: 0, + result: None, + units, + can_end_turn: false, + game_log: Vec::new(), + time_control, + player0time: player_time, + player1time: player_time, + timestamp: 0, + clock_start: None, + local, + previous_state_hashes: HashMap::new(), + zobrist_hash: 0, + previous: None, + }; + game.zobrist_hash = + zobrist::compute(&game.units, &game.config, game.whos_turn, game.can_end_turn); + game + } + + /// Convenience: a fresh, non-local, untimed game. + pub fn new_game(config: StarGambitConfig) -> GameEngine { + GameEngine::initial(config, None, false) + } + + pub fn action_count(&self) -> usize { + self.game_log.len() + } + + // ---- queries ------------------------------------------------------- + + fn active_units(&self) -> impl Iterator { + self.units.iter().filter(|u| u.damage < u.kind.health) + } + + fn unit_at(&self, hex: Hex) -> Option<&Unit> { + self.active_units().find(|u| u.body().contains(&hex)) + } + + fn is_out_of_bounds(&self, hex: Hex) -> bool { + hex.ring() > self.config.board_size + } + + fn current_players_placement_hex(&self) -> Hex { + let n = self.config.board_size - 1; + let v = if self.whos_turn == 0 { hex::D } else { hex::A }; + Hex::new(v.x * n, v.y * n, v.z * n) + } + + /// Players who started with at least one portal. Mirrors + /// `playersWithPortal`. + fn player_had_portal(&self, player: i32) -> bool { + self.config.initial_placements.iter().any(|p| { + p.player_index == player && self.config.unit_kinds[p.kind_index].name == "Portal" + }) + } + + fn placement_transform(&self, kind_id: usize, placement_id: usize) -> Option { + let placements = self.config.placements.get(kind_id)?; + let placement = *placements.get(placement_id)?; + let placement = if self.whos_turn == 1 { + HexTransform::new(Hex::ORIGIN, 3).combine(placement) + } else { + placement + }; + let base = HexTransform::from_translate(self.current_players_placement_hex()); + Some(base.combine(placement)) + } + + fn is_placement_legal(&self, kind_id: usize, placement_id: usize) -> bool { + let transform = match self.placement_transform(kind_id, placement_id) { + Some(t) => t, + None => return false, + }; + let kind = &self.config.unit_kinds[kind_id]; + let placed_body: Vec = kind.body.iter().map(|&m| transform.from_local(m)).collect(); + !self + .active_units() + .any(|u| u.body().iter().any(|h| placed_body.contains(h))) + } + + /// Undeployed unit counts per kind index for `player` (only kinds with a + /// positive remaining count). Mirrors `undeployedUnits`. + fn undeployed_units(&self, player: i32) -> Vec<(usize, i32)> { + let mut out = Vec::new(); + for (i, kind) in self.config.unit_kinds.iter().enumerate() { + let on_board = self + .units + .iter() + .filter(|u| u.kind.name == kind.name && u.owner == player) + .count() as i32; + let left = self.config.counts[i] - on_board; + if left > 0 { + out.push((i, left)); + } + } + out + } + + fn can_place(&self) -> bool { + self.undeployed_units(self.whos_turn) + .iter() + .any(|&(kind_id, _)| self.has_legal_placement(kind_id)) + } + + fn has_legal_placement(&self, kind_id: usize) -> bool { + let n = self + .config + .placements + .get(kind_id) + .map(|p| p.len()) + .unwrap_or(0); + (0..n).any(|pid| self.is_placement_legal(kind_id, pid)) + } + + fn would_collide(&self, unit_id: usize, move_id: usize) -> bool { + let unit = &self.units[unit_id]; + let m = &unit.kind.moves[move_id]; + let new_position = unit.position.combine(m.to_transform()); + let hexes = unit.kind.body_transformed(new_position); + hexes.iter().any(|&h| match self.unit_at(h) { + Some(u) => u.id != unit_id as i32, + None => false, + }) + } + + /// Whether the unit has at least one legal move. Equivalent to + /// `validMoves(unitId).isNotEmpty`. + fn has_valid_move(&self, unit_id: usize) -> bool { + let unit = &self.units[unit_id]; + if unit.kind.move_ <= unit.moves_this_turn { + return false; + } + let board_size = self.config.board_size; + unit.kind.moves.iter().enumerate().any(|(id, m)| { + !self.would_collide(unit_id, id) + && unit + .kind + .body_transformed(unit.position.combine(m.to_transform())) + .iter() + .all(|h| h.ring() <= board_size) + }) + } + + /// The attack ids that can hit an enemy this turn (first target per + /// attack). Mirrors `targets`; we only need its (non-)emptiness, but the + /// blocking/ordering logic is reproduced faithfully. + fn targets(&self, unit_id: usize) -> Vec<(usize, Hex, i32)> { + let unit = &self.units[unit_id]; + let mut ret: Vec<(usize, Hex, i32)> = Vec::new(); + let mut blocked: Vec = Vec::new(); + let mut have: Vec = Vec::new(); + + for (i, threat_local) in unit.kind.threats() { + let global = unit.position.from_local(threat_local); + + let hits_friendly = self + .active_units() + .filter(|u| u.owner == unit.owner) + .any(|u| u.body().contains(&global)); + if hits_friendly { + blocked.push(i); + } + + if !blocked.contains(&i) + && !have.contains(&i) + && !unit.fired_this_turn.contains(&(i as i32)) + && self + .active_units() + .filter(|u| u.owner != unit.owner) + .any(|u| u.body().contains(&global)) + { + let damage = unit.kind.ray_attacks[i].damage_at(threat_local); + ret.push((i, threat_local, damage)); + have.push(i); + } + } + ret + } + + fn active_player_has_no_options(&self) -> bool { + if self.can_end_turn { + return false; + } + for (id, unit) in self.units.iter().enumerate() { + if unit.damage >= unit.kind.health || unit.owner != self.whos_turn { + continue; + } + if self.has_valid_move(id) || !self.targets(id).is_empty() { + return false; + } + } + if self.can_place() { + return false; + } + true + } + + pub fn is_draw(&self) -> bool { + self.previous_state_hashes.values().any(|&v| v > 2) + } + + pub fn compute_result(&self) -> Option { + if self.active_player_has_no_options() { + return Some(GameResult::Win { + winner: if self.whos_turn == 1 { 0 } else { 1 }, + reason: LossReason::NoOptions, + }); + } + + let portal0 = self + .active_units() + .any(|u| u.kind.name == "Portal" && u.owner == 0); + let portal1 = self + .active_units() + .any(|u| u.kind.name == "Portal" && u.owner == 1); + if self.player_had_portal(0) && !portal0 { + return Some(GameResult::Win { + winner: 1, + reason: LossReason::NoPortal, + }); + } + if self.player_had_portal(1) && !portal1 { + return Some(GameResult::Win { + winner: 0, + reason: LossReason::NoPortal, + }); + } + + let p0_has_ships = self + .active_units() + .any(|u| u.kind.name != "Portal" && u.owner == 0); + let p1_has_ships = self + .active_units() + .any(|u| u.kind.name != "Portal" && u.owner == 1); + + if !p0_has_ships && self.undeployed_units(0).is_empty() { + return Some(GameResult::Win { + winner: 1, + reason: LossReason::NoShips, + }); + } else if !p1_has_ships && self.undeployed_units(1).is_empty() { + return Some(GameResult::Win { + winner: 0, + reason: LossReason::NoShips, + }); + } + + if self.is_draw() { + return Some(GameResult::Draw); + } + + None + } + + // ---- mutators (return new states) ---------------------------------- + + fn add_unit(&self, kind_id: usize, owner: i32, position: HexTransform) -> GameEngine { + let mut g = self.clone(); + let id = g.units.len() as i32; + let kind = &self.config.unit_kinds[kind_id]; + g.units.push(kind.new_unit(owner, id, position, 0)); + g + } + + fn damage_unit_at(&self, hex: Hex, damage: i32) -> GameEngine { + match self.unit_at(hex) { + None => self.clone(), + Some(u) => { + let id = u.id as usize; + let mut g = self.clone(); + g.units[id] = self.units[id].deal_damage(damage); + g + } + } + } + + // ---- SGN codes ----------------------------------------------------- + + /// Mirrors `mustEndTurnAfterPlacement`: only END may follow a placement. + fn must_end_turn_after_placement(&self, command: &str) -> Result<(), SgnError> { + if let Some(last) = self.game_log.last() { + if last.contains('@') && !command.contains("END") { + return Err(invalid( + command, + "Only step allowed after placement is end.", + )); + } + } + Ok(()) + } + + fn apply_place(&self, command: &str) -> Result { + self.must_end_turn_after_placement(command)?; + let (left, right) = + split_on_first(command, '@').ok_or_else(|| invalid(command, "Invalid Kind Id"))?; + let kind_id: usize = left + .parse() + .map_err(|_| invalid(command, "Invalid Kind Id"))?; + if kind_id >= self.config.unit_kinds.len() { + return Err(invalid(command, "Invalid Kind Id")); + } + let placements_len = self + .config + .placements + .get(kind_id) + .map(|p| p.len()) + .unwrap_or(0); + let placement_id: usize = right + .parse() + .map_err(|_| invalid(command, "Invalid Placement Id"))?; + if placement_id > placements_len { + return Err(invalid(command, "Invalid Placement Id")); + } + if !self.is_placement_legal(kind_id, placement_id) { + return Err(invalid(command, "Illegal placement")); + } + // Enforce reserves: refuse to deploy a kind the active player has + // exhausted. (Mirrors the new `C_Place` check in star_engine.) + if !self + .undeployed_units(self.whos_turn) + .iter() + .any(|&(k, _)| k == kind_id) + { + return Err(invalid(command, "No units of that kind in reserve")); + } + let transform = self + .placement_transform(kind_id, placement_id) + .ok_or_else(|| invalid(command, "Illegal placement"))?; + let mut g = self.add_unit(kind_id, self.whos_turn, transform); + g.can_end_turn = true; + Ok(g) + } + + fn apply_move(&self, command: &str) -> Result { + self.must_end_turn_after_placement(command)?; + let (left, right) = + split_on_first(command, '+').ok_or_else(|| invalid(command, "Invalid Unit Id"))?; + let unit_id: usize = left + .parse() + .map_err(|_| invalid(command, "Invalid Unit Id"))?; + if unit_id >= self.units.len() { + return Err(invalid(command, "Invalid Unit Id")); + } + if self.units[unit_id].owner != self.whos_turn { + return Err(invalid(command, "Cannot move opponent's units")); + } + if self.units[unit_id].remaining_health() <= 0 { + return Err(invalid(command, "A destroyed unit cannot move")); + } + let move_id: usize = right + .parse() + .map_err(|_| invalid(command, "Invalid Move Id"))?; + + let unit = &self.units[unit_id]; + if unit.kind.move_ <= unit.moves_this_turn { + return Err(invalid(command, "Unit has no moves left")); + } + if move_id >= unit.kind.moves.len() { + return Err(invalid(command, "Invalid Move Id")); + } + let new_unit = unit.moved(move_id); + if new_unit.body().iter().any(|&h| self.is_out_of_bounds(h)) { + return Err(invalid(command, "Invalid Move Id")); + } + if self.would_collide(unit_id, move_id) { + return Err(invalid(command, "Invalid Move Id")); + } + let mut g = self.clone(); + g.units[unit_id] = new_unit; + g.can_end_turn = true; + Ok(g) + } + + fn apply_attack(&self, command: &str) -> Result { + self.must_end_turn_after_placement(command)?; + let (left, right) = + split_on_first(command, '*').ok_or_else(|| invalid(command, "Invalid Unit Id"))?; + let unit_id: usize = left + .parse() + .map_err(|_| invalid(command, "Invalid Unit Id"))?; + if unit_id >= self.units.len() { + return Err(invalid(command, "Invalid Unit Id")); + } + if self.units[unit_id].owner != self.whos_turn { + return Err(invalid(command, "Cannot fire opponent's guns")); + } + let attack_id: usize = right + .parse() + .map_err(|_| invalid(command, "Invalid Attack Id"))?; + let unit = &self.units[unit_id]; + if attack_id >= unit.kind.ray_attacks.len() { + return Err(invalid(command, "Invalid Unit Id")); + } + if unit.fired_this_turn.contains(&(attack_id as i32)) { + return Err(invalid( + command, + "This gun has already been fired this turn", + )); + } + if unit.remaining_health() <= 0 { + return Err(invalid(command, "A destroyed unit cannot fire")); + } + + let attack = &unit.kind.ray_attacks[attack_id]; + let threats: Vec = attack + .targets() + .into_iter() + .map(|t| unit.position.from_local(t)) + .collect(); + + for (i, threat) in threats.into_iter().enumerate() { + if let Some(target) = self.unit_at(threat) { + if target.owner != unit.owner { + let mut fired = self.clone(); + fired.units[unit_id] = self.units[unit_id].with_fired(attack_id as i32); + let mut g = fired.damage_unit_at(threat, 2 - i as i32); + g.can_end_turn = true; + return Ok(g); + } else { + return Err(invalid(command, "Cannot fire on your own pieces")); + } + } + } + Err(invalid(command, "Attack must hit a target")) + } + + fn apply_end_turn(&self, command: &str) -> Result { + let (_, time_string) = + split_on_first(command, ':').ok_or_else(|| invalid(command, "Invalid end turn"))?; + if !self.can_end_turn { + return Err(invalid(command, "Cannot end turn yet")); + } + let time: i32 = time_string + .parse() + .map_err(|_| invalid(command, &format!("'{time_string}' is not a valid time")))?; + let time_taken = time - self.timestamp; + + let mut g = self.clone(); + g.can_end_turn = false; + g.timestamp = time; + g.whos_turn = if self.whos_turn == 1 { 0 } else { 1 }; + for u in g.units.iter_mut() { + u.fired_this_turn.clear(); + u.moves_this_turn = 0; + } + // Local games stamp clock_start here; non-local games (all the proof + // corpus) never take this branch. + if self.local && self.action_count() == 3 && self.clock_start.is_none() { + g.clock_start = Some(self.timestamp as i64); + } + if self.time_control.is_some() { + g.player0time = if self.whos_turn == 0 { + Some(self.player0time.unwrap() - time_taken) + } else { + self.player0time + }; + g.player1time = if self.whos_turn == 1 { + Some(self.player1time.unwrap() - time_taken) + } else { + self.player1time + }; + } + if let Some(r) = g.compute_result() { + g.result = Some(r); + } + Ok(g) + } + + fn apply_game_end(&self, command: &str) -> Result { + if command.contains("==") { + if self.is_draw() { + let mut g = self.clone(); + g.result = Some(GameResult::Draw); + return Ok(g); + } else { + return Err(invalid(command, "Threefold repetition not met")); + } + } + let (loser, reason) = + split_on_first(command, '=').ok_or_else(|| invalid(command, "Invalid game end"))?; + let player_id: i32 = loser + .parse() + .map_err(|_| invalid(command, &format!("\"{loser}\" is not a valid player ID")))?; + if player_id != 0 && player_id != 1 { + return Err(invalid( + command, + &format!("\"{loser}\" is not a valid player ID"), + )); + } + + let mut current = self.clone(); + match reason { + "FF" => {} + "T" => { + if self.time_control.is_none() || self.time_control == Some(0) { + return Err(invalid(command, "No time control set")); + } + if self.clock_start.is_none() { + return Err(invalid(command, "Clock not started")); + } + if player_id == 0 { + current.player0time = Some(0); + } else { + current.player1time = Some(0); + } + } + "P" => { + if !self.player_had_portal(player_id) { + return Err(invalid( + command, + &format!("Player {player_id} never had a portal"), + )); + } + if self + .active_units() + .any(|u| u.owner == player_id && u.kind.name == "Portal") + { + return Err(invalid( + command, + &format!("Player {player_id} still has portal"), + )); + } + } + "W" => { + let has_active = self + .active_units() + .any(|u| u.owner == player_id && u.kind.name != "Portal"); + let has_undeployed = !self.undeployed_units(player_id).is_empty(); + if has_active || has_undeployed { + return Err(invalid( + command, + &format!("Player {player_id} still has ships"), + )); + } + } + "N" => { + if !self.active_player_has_no_options() { + return Err(invalid( + command, + &format!("Player {player_id} still has options"), + )); + } + } + _ => { + return Err(invalid( + command, + &format!("\"{reason}\" is not a valid loss reason"), + )); + } + } + + let reason_enum = match reason { + "FF" => LossReason::Forfeit, + "T" => LossReason::NoTime, + "P" => LossReason::NoPortal, + "W" => LossReason::NoShips, + "N" => LossReason::NoOptions, + _ => unreachable!(), + }; + current.result = Some(GameResult::Win { + winner: if player_id == 0 { 1 } else { 0 }, + reason: reason_enum, + }); + Ok(current) + } + + /// Interprets a whitespace-separated SGN string against this state, + /// returning the resulting state. + pub fn execute(&self, sgn: &str) -> Result { + let mut game = self.clone(); + for command in sgn.split_whitespace() { + let applied = if command.starts_with("END") { + game.apply_end_turn(command) + } else if command.contains('=') { + game.apply_game_end(command) + } else if command.contains('*') { + game.apply_attack(command) + } else if command.contains('+') { + game.apply_move(command) + } else if command.contains('@') { + game.apply_place(command) + } else { + Err(SgnError::Syntax(command.to_string())) + }; + + let mut next = applied?; + next.game_log = game.game_log.clone(); + next.game_log.push(command.to_string()); + next.zobrist_hash = + zobrist::compute(&next.units, &next.config, next.whos_turn, next.can_end_turn); + // No `previous` back-link is kept. It is never read, and chaining it + // makes `clone()` walk the entire game history — so a caller that + // probes many candidate actions per turn (a bot, move generation) + // goes quadratic. Keeping state a flat, O(1)-to-clone value avoids + // that trap by default, which is why every caller used to null this + // field by hand. + next.previous = None; + + // Threefold-repetition bookkeeping happens only at turn boundaries. + if command.starts_with("END:") { + let h = next.zobrist_hash; + *next.previous_state_hashes.entry(h).or_insert(0) += 1; + } + + game = next; + } + Ok(game) + } +} diff --git a/stellar/src/hex.rs b/stellar/src/hex.rs new file mode 100644 index 0000000..9f529db --- /dev/null +++ b/stellar/src/hex.rs @@ -0,0 +1,151 @@ +//! Hex geometry, ported from `shared/hexag`. +//! +//! The Dart engine models positions with `FractionalHex` (doubles) and a +//! `num` rotation, but during real SGN play every translation is an +//! integer-valued cube coordinate and every rotation is an integer number +//! of sixths. The continuous (trigonometric) rotation path in +//! `HexRotator.rotate` is only ever hit for fractional rotations, which the +//! notation never produces. We therefore model translation with an integer +//! [`Hex`] and rotation with an `i32`, which lets the whole engine run on +//! exact integer arithmetic while staying bit-identical to the Dart state. + +/// A hex on the grid in cube coordinates. Invariant: `x + y + z == 0`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct Hex { + pub x: i32, + pub y: i32, + pub z: i32, +} + +impl Hex { + pub const ORIGIN: Hex = Hex { x: 0, y: 0, z: 0 }; + + pub const fn new(x: i32, y: i32, z: i32) -> Hex { + Hex { x, y, z } + } + + /// Ring number: 0 at the origin, growing outward. Equals + /// `(|x| + |y| + |z|) / 2`. + pub fn ring(self) -> i32 { + (self.x.abs() + self.y.abs() + self.z.abs()) / 2 + } +} + +impl std::ops::Add for Hex { + type Output = Hex; + fn add(self, o: Hex) -> Hex { + Hex::new(self.x + o.x, self.y + o.y, self.z + o.z) + } +} + +impl std::ops::Sub for Hex { + type Output = Hex; + fn sub(self, o: Hex) -> Hex { + Hex::new(self.x - o.x, self.y - o.y, self.z - o.z) + } +} + +/// The six faces of a hexagon, clockwise. Matches Dart `HexFace`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum HexFace { + A, + B, + C, + D, + E, + F, +} + +impl HexFace { + pub fn index(self) -> usize { + match self { + HexFace::A => 0, + HexFace::B => 1, + HexFace::C => 2, + HexFace::D => 3, + HexFace::E => 4, + HexFace::F => 5, + } + } + + /// The unit direction vector for this face. + pub fn vector(self) -> Hex { + FACE_DIRECTIONS[self.index()] + } +} + +/// Direction vectors, indexed A..F. Mirrors `HexVectors.faceDirections`. +pub const A: Hex = Hex { x: 0, y: -1, z: 1 }; +pub const B: Hex = Hex { x: 1, y: -1, z: 0 }; +pub const C: Hex = Hex { x: 1, y: 0, z: -1 }; +pub const D: Hex = Hex { x: 0, y: 1, z: -1 }; +pub const E: Hex = Hex { x: -1, y: 1, z: 0 }; +pub const F: Hex = Hex { x: -1, y: 0, z: 1 }; + +pub const FACE_DIRECTIONS: [Hex; 6] = [A, B, C, D, E, F]; + +/// Discrete rotation about the origin. `rot` is in sixths of a turn, +/// clockwise; only `rot mod 6` matters. Mirrors the `_hexRotators` table. +pub fn discrete_rotate(h: Hex, rot: i32) -> Hex { + match rot.rem_euclid(6) { + 0 => Hex::new(h.x, h.y, h.z), + 1 => Hex::new(-h.y, -h.z, -h.x), + 2 => Hex::new(h.z, h.x, h.y), + 3 => Hex::new(-h.x, -h.y, -h.z), + 4 => Hex::new(h.y, h.z, h.x), + 5 => Hex::new(-h.z, -h.x, -h.y), + _ => unreachable!(), + } +} + +/// A translate/rotate transform. `translate` is the location of local +/// `Hex(0,0,0)` in the outer coordinate system; the local frame is also +/// rotated clockwise by `rotate` sixths of a turn. +/// +/// `rotate` is kept as the raw accumulated value (not reduced mod 6) so that +/// transform equality matches Dart's `HexTransform == ` (which compares the +/// raw `rotate`). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct HexTransform { + pub translate: Hex, + pub rotate: i32, +} + +impl HexTransform { + pub const IDENTITY: HexTransform = HexTransform { + translate: Hex::ORIGIN, + rotate: 0, + }; + + pub const fn new(translate: Hex, rotate: i32) -> HexTransform { + HexTransform { translate, rotate } + } + + pub const fn from_translate(translate: Hex) -> HexTransform { + HexTransform { + translate, + rotate: 0, + } + } + + /// Converts a hex in local coordinates to the outer coordinate system. + /// Exact (integer) because rotation is always discrete here. + pub fn from_local(self, h: Hex) -> Hex { + discrete_rotate(h, self.rotate) + self.translate + } + + /// Combines transforms: `self` is a position, `other` a move taken from + /// it. NOT commutative. Mirrors `HexTransform.operator+`. + pub fn combine(self, other: HexTransform) -> HexTransform { + let om = other.rotate.rem_euclid(6); + let rot = if om > 3 { + self.rotate + om - 6 + } else { + self.rotate + other.rotate + }; + HexTransform { + translate: self.from_local(other.translate), + rotate: rot, + } + } +} diff --git a/stellar/src/lib.rs b/stellar/src/lib.rs new file mode 100644 index 0000000..f0e2a3c --- /dev/null +++ b/stellar/src/lib.rs @@ -0,0 +1,21 @@ +//! `stellar` — a Rust reimplementation of the Star Gambit game logic. +//! +//! This crate mirrors the Dart `star_engine` package. The single public +//! entry point of interest is [`engine::GameEngine::execute`], the analogue +//! of Dart's `GameEngine.resolve`: it interprets an SGN string against a +//! game state and returns the resulting state. +//! +//! Geometry runs on exact integer arithmetic (see [`hex`]) because the +//! notation never produces a fractional rotation, so the Dart `FractionalHex` +//! coordinates are always integer-valued during play. + +pub mod config; +pub mod engine; +pub mod hex; +pub mod model; +pub mod result; +pub mod zobrist; + +pub use config::{base_config, StarConfig, StarGambitConfig}; +pub use engine::{GameEngine, SgnError}; +pub use result::{GameResult, LossReason}; diff --git a/stellar/src/model.rs b/stellar/src/model.rs new file mode 100644 index 0000000..ed6230d --- /dev/null +++ b/stellar/src/model.rs @@ -0,0 +1,165 @@ +//! Game piece models, ported from `shared/star_engine/lib/src/model`. + +use crate::hex::{Hex, HexFace, HexTransform}; + +/// A directional ray attack. Mirrors `RayAttack`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RayAttack { + pub name: String, + pub face: HexFace, + pub source: Hex, +} + +impl RayAttack { + pub fn new(name: &str, face: HexFace, source: Hex) -> RayAttack { + RayAttack { + name: name.to_string(), + face, + source, + } + } + + fn direction(&self) -> Hex { + self.face.vector() + } + + /// The two hexes this attack threatens, nearest first. Mirrors + /// `RayAttack.targets` (which yields source+dir then source+2dir). + pub fn targets(&self) -> Vec { + let dir = self.direction(); + vec![self.source + dir, self.source + dir + dir] + } + + pub fn hits(&self, hex: Hex) -> bool { + self.targets().contains(&hex) + } + + /// Damage dealt at a target hex: 2 at range 1, 1 at range 2. + pub fn damage_at(&self, hex: Hex) -> i32 { + if !self.hits(hex) { + return 0; + } + let ring = (hex - self.source).ring() - 1; + 2 - ring + } +} + +/// A move a unit can make. Mirrors `Move`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Move { + pub name: String, + pub destination: Hex, + /// Final facing; corresponds to a sixth-of-a-turn rotation. + pub direction: i32, +} + +impl Move { + pub fn new(name: &str, destination: Hex, direction: i32) -> Move { + Move { + name: name.to_string(), + destination, + direction, + } + } + + pub fn to_transform(&self) -> HexTransform { + HexTransform::new(self.destination, self.direction.rem_euclid(6)) + } +} + +/// A kind of unit (Fighter, Cruiser, ...). Mirrors `KindOfUnit`. Only the +/// fields exercised by the engine logic are modelled. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct KindOfUnit { + pub name: String, + pub body: Vec, + /// Number of moves per turn (`move` in Dart). + pub move_: i32, + pub ray_attacks: Vec, + pub moves: Vec, + pub health: i32, +} + +impl KindOfUnit { + /// Body hexes expressed in the given transform's outer coordinates. + pub fn body_transformed(&self, transform: HexTransform) -> Vec { + self.body.iter().map(|&h| transform.from_local(h)).collect() + } + + pub fn new_unit(&self, owner: i32, id: i32, position: HexTransform, initial_damage: i32) -> Unit { + Unit { + id, + kind: self.clone(), + position, + damage: initial_damage, + fired_this_turn: Vec::new(), + moves_this_turn: 0, + owner, + } + } + + /// `(attackIndex, localTargetHex)` pairs, attack order, nearest target + /// first within each attack. Mirrors `KindOfUnit.threats`. + pub fn threats(&self) -> Vec<(usize, Hex)> { + let mut out = Vec::new(); + for (i, attack) in self.ray_attacks.iter().enumerate() { + for t in attack.targets() { + out.push((i, t)); + } + } + out + } +} + +/// A placed unit on the board. Mirrors `Unit`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Unit { + pub id: i32, + pub kind: KindOfUnit, + pub position: HexTransform, + pub damage: i32, + /// Attack indices already fired this turn (kept sorted & unique). + pub fired_this_turn: Vec, + pub moves_this_turn: i32, + pub owner: i32, +} + +impl Unit { + /// Body hexes in board (outer) coordinates. + pub fn body(&self) -> Vec { + self.kind + .body + .iter() + .map(|&h| self.position.from_local(h)) + .collect() + } + + pub fn remaining_health(&self) -> i32 { + (self.kind.health - self.damage).max(0) + } + + /// Applies a move by index, advancing position and incrementing the + /// per-turn move counter. Mirrors `Unit.move` + `moveTransform`. + pub fn moved(&self, move_id: usize) -> Unit { + let m = &self.kind.moves[move_id]; + let mut u = self.clone(); + u.position = self.position.combine(m.to_transform()); + u.moves_this_turn = self.moves_this_turn + 1; + u + } + + pub fn deal_damage(&self, amount: i32) -> Unit { + let mut u = self.clone(); + u.damage = self.damage + amount; + u + } + + pub fn with_fired(&self, attack_id: i32) -> Unit { + let mut u = self.clone(); + if !u.fired_this_turn.contains(&attack_id) { + u.fired_this_turn.push(attack_id); + u.fired_this_turn.sort_unstable(); + } + u + } +} diff --git a/stellar/src/result.rs b/stellar/src/result.rs new file mode 100644 index 0000000..93d8d71 --- /dev/null +++ b/stellar/src/result.rs @@ -0,0 +1,41 @@ +//! Game results, ported from `star_gambit_notation/result.dart`. + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LossReason { + NoPortal, + NoTime, + NoShips, + NoOptions, + Forfeit, +} + +impl LossReason { + fn code(self) -> &'static str { + match self { + LossReason::NoPortal => "P", + LossReason::NoTime => "T", + LossReason::NoShips => "W", + LossReason::NoOptions => "N", + LossReason::Forfeit => "FF", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GameResult { + Win { winner: i32, reason: LossReason }, + Draw, +} + +impl GameResult { + /// Canonical SGN encoding, matching `GameResult.toSGN`. + pub fn to_sgn(self) -> String { + match self { + GameResult::Win { winner, reason } => { + let loser = if winner == 0 { 1 } else { 0 }; + format!("{}={}", loser, reason.code()) + } + GameResult::Draw => "1==0".to_string(), + } + } +} diff --git a/stellar/src/zobrist.rs b/stellar/src/zobrist.rs new file mode 100644 index 0000000..ba422bd --- /dev/null +++ b/stellar/src/zobrist.rs @@ -0,0 +1,246 @@ +//! Zobrist hashing, ported from `shared/star_engine/lib/src/zobrist.dart`. +//! +//! The Dart table is generated lazily from `Random(42)`. To produce +//! bit-identical hashes we reproduce Dart's `_Random` exactly (Multiply +//! With Carry, base 2^32, plus the Thomas Wang 64-bit seed mix) — see +//! `sdk/lib/_internal/vm/lib/math_patch.dart`. The table is then drawn from +//! that stream in the same order Dart's nested `List.generate` calls use. + +use crate::config::StarGambitConfig; +use crate::model::Unit; +use std::sync::OnceLock; + +const MAX_COORD: i32 = 10; +const COORD_RANGE: i32 = MAX_COORD * 2 + 1; // 21 +const ROTATIONS: usize = 6; +const POS_COUNT: usize = (COORD_RANGE as usize) * (COORD_RANGE as usize) * ROTATIONS; +const MAX_DAMAGE: i32 = 7; +const MAX_REMAINING_MOVES: i32 = 3; +const UNIT_TYPE_COUNT: usize = 4; +const PLAYER_COUNT: usize = 2; +const MAX_UNDEPLOYED: i32 = 4; + +/// Reproduction of Dart's `_Random` (VM MWC implementation). +struct DartRandom { + state: u64, +} + +impl DartRandom { + fn new(seed: u64) -> DartRandom { + let mut r = DartRandom { + state: Self::setup_seed(seed), + }; + // The factory cranks the generator four times after seeding. + for _ in 0..4 { + r.next_state(); + } + r + } + + /// Thomas Wang 64-bit integer hash, matching `_Random._setupSeed`. + fn setup_seed(mut n: u64) -> u64 { + n = (!n).wrapping_add(n << 21); + n ^= n >> 24; + n = n.wrapping_mul(265); + n ^= n >> 14; + n = n.wrapping_mul(21); + n ^= n >> 28; + n = n.wrapping_add(n << 31); + if n == 0 { + n = 0x5a17; + } + n + } + + fn next_state(&mut self) { + const MWC_A: u64 = 0xffffda61; + let lo = self.state & 0xFFFF_FFFF; + let hi = self.state >> 32; + self.state = MWC_A.wrapping_mul(lo).wrapping_add(hi); + } + + /// `nextInt(1 << 26)` — the power-of-two fast path. + fn next_int_pow2_26(&mut self) -> u64 { + self.next_state(); + self.state & 0xFFFF_FFFF & ((1 << 26) - 1) + } + + /// Matches `_nextRandom` in zobrist.dart: + /// `rng.nextInt(1 << 26) << 26 | rng.nextInt(1 << 26)`. + fn next_random(&mut self) -> u64 { + let high = self.next_int_pow2_26(); + let low = self.next_int_pow2_26(); + (high << 26) | low + } +} + +struct ZobristTable { + /// `pos[typeIndex][player][posIndex]` + pos: Vec>>, + /// `dmg[posIndex][damage]` + dmg: Vec>, + /// `moves[posIndex][remainingMoves]` + moves: Vec>, + /// `undeployed[typeIndex][player][count]` + undeployed: Vec>>, + turn: u64, + can_end_turn: u64, +} + +impl ZobristTable { + fn build() -> ZobristTable { + let mut rng = DartRandom::new(42); + + // Order matters: nested List.generate fills the innermost dimension + // first, outer index 0 fully before index 1, etc. + let pos = (0..UNIT_TYPE_COUNT) + .map(|_| { + (0..PLAYER_COUNT) + .map(|_| (0..POS_COUNT).map(|_| rng.next_random()).collect()) + .collect() + }) + .collect(); + + let dmg = (0..POS_COUNT) + .map(|_| (0..MAX_DAMAGE).map(|_| rng.next_random()).collect()) + .collect(); + + let moves = (0..POS_COUNT) + .map(|_| (0..MAX_REMAINING_MOVES).map(|_| rng.next_random()).collect()) + .collect(); + + let undeployed = (0..UNIT_TYPE_COUNT) + .map(|_| { + (0..PLAYER_COUNT) + .map(|_| (0..MAX_UNDEPLOYED).map(|_| rng.next_random()).collect()) + .collect() + }) + .collect(); + + let turn = rng.next_random(); + let can_end_turn = rng.next_random(); + + ZobristTable { + pos, + dmg, + moves, + undeployed, + turn, + can_end_turn, + } + } +} + +fn table() -> &'static ZobristTable { + static TABLE: OnceLock = OnceLock::new(); + TABLE.get_or_init(ZobristTable::build) +} + +/// Type index 0..3 for a unit kind, by name. Mirrors `_typeIndex`. +fn type_index(name: &str) -> Option { + match name { + "Fighter" => Some(0), + "Cruiser" => Some(1), + "Dreadnought" => Some(2), + "Portal" => Some(3), + _ => None, + } +} + +fn pos_index(t: &crate::hex::HexTransform) -> usize { + let x = (t.translate.x + MAX_COORD) as usize; + let y = (t.translate.y + MAX_COORD) as usize; + let r = t.rotate.rem_euclid(ROTATIONS as i32) as usize; + (x * COORD_RANGE as usize + y) * ROTATIONS + r +} + +/// Computes the Zobrist hash for the given game state. Mirrors +/// `ZobristHash.compute`. +pub fn compute( + units: &[Unit], + config: &StarGambitConfig, + whos_turn: i32, + can_end_turn: bool, +) -> u64 { + let t = table(); + let mut hash: u64 = 0; + + if whos_turn == 1 { + hash ^= t.turn; + } + if can_end_turn { + hash ^= t.can_end_turn; + } + + for unit in units { + // active units only + if unit.damage >= unit.kind.health { + continue; + } + let ti = match type_index(&unit.kind.name) { + Some(ti) => ti, + None => continue, + }; + let pi = pos_index(&unit.position); + hash ^= t.pos[ti][unit.owner as usize][pi]; + if unit.damage > 0 { + let d = (unit.damage - 1).min(MAX_DAMAGE - 1) as usize; + hash ^= t.dmg[pi][d]; + } + let remaining = unit.kind.move_ - unit.moves_this_turn; + if remaining > 0 { + let m = (remaining - 1).min(MAX_REMAINING_MOVES - 1) as usize; + hash ^= t.moves[pi][m]; + } + } + + for ti in 0..UNIT_TYPE_COUNT { + for player in 0..PLAYER_COUNT { + let kind = &config.unit_kinds[ti]; + let on_board = units + .iter() + .filter(|u| u.kind.name == kind.name && u.owner as usize == player) + .count() as i32; + let count = config.counts[ti] - on_board; + if count > 0 { + let c = (count - 1).min(MAX_UNDEPLOYED - 1) as usize; + hash ^= t.undeployed[ti][player][c]; + } + } + } + + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dart_random_matches_reference() { + // Reference values captured from Dart `Random(42)` on this machine. + let mut r = DartRandom::new(42); + let expected_raw: [u64; 12] = [ + 10128435, 53601710, 40543684, 16750685, 44404661, 40243304, 14830479, 15562557, + 53045552, 33625016, 10775807, 2123492, + ]; + for (i, &e) in expected_raw.iter().enumerate() { + assert_eq!(r.next_int_pow2_26(), e, "raw nextInt mismatch at {i}"); + } + + let mut r2 = DartRandom::new(42); + let expected_nr: [u64; 8] = [ + 679707820549550, + 2720840592365661, + 2979946396258408, + 995256613828413, + 3559826768597944, + 723152168576740, + 1853487991087475, + 777377128223002, + ]; + for (i, &e) in expected_nr.iter().enumerate() { + assert_eq!(r2.next_random(), e, "nextRandom mismatch at {i}"); + } + } +} diff --git a/stellar/tests/differential.rs b/stellar/tests/differential.rs new file mode 100644 index 0000000..c647069 --- /dev/null +++ b/stellar/tests/differential.rs @@ -0,0 +1,112 @@ +//! Differential proof: replay a corpus of real SGN games through the Rust +//! `GameEngine::execute` and assert the resulting state is byte-for-byte +//! identical to the snapshot produced by the Dart `star_engine`'s +//! `GameEngine.resolve`. +//! +//! The golden file `golden/states.json` is generated by +//! `golden_gen/bin/gen_golden.dart`. Each entry carries the inputs +//! (board size, ship counts, SGN) plus the Dart-computed state, so the two +//! engines are driven by exactly the same corpus. + +use serde_json::{json, Value}; +use stellar::{GameEngine, StarConfig, StarGambitConfig}; + +fn dump_state(g: &GameEngine) -> Value { + let mut reps: Vec = g.previous_state_hashes.values().copied().collect(); + reps.sort_unstable(); + + let units: Vec = g + .units + .iter() + .map(|u| { + let mut fired = u.fired_this_turn.clone(); + fired.sort_unstable(); + json!({ + "id": u.id, + "kind": u.kind.name, + "owner": u.owner, + "damage": u.damage, + "movesThisTurn": u.moves_this_turn, + "fired": fired, + "x": u.position.translate.x, + "y": u.position.translate.y, + "z": u.position.translate.z, + "rotate": u.position.rotate, + }) + }) + .collect(); + + json!({ + "zobrist": g.zobrist_hash.to_string(), + "whosTurn": g.whos_turn, + "canEndTurn": g.can_end_turn, + "timestamp": g.timestamp, + "result": g.result.map(|r| r.to_sgn()), + "isDraw": g.is_draw(), + "repetitionValues": reps, + "gameLog": g.game_log, + "units": units, + }) +} + +fn assert_state_eq(name: &str, expected: &Value, actual: &Value) { + if expected == actual { + return; + } + let eo = expected.as_object().expect("dart state is an object"); + let empty = serde_json::Map::new(); + let ao = actual.as_object().unwrap_or(&empty); + let mut keys: Vec<&String> = eo.keys().chain(ao.keys()).collect(); + keys.sort(); + keys.dedup(); + for k in keys { + let ev = eo.get(k).unwrap_or(&Value::Null); + let av = ao.get(k).unwrap_or(&Value::Null); + if ev != av { + eprintln!("[{name}] field `{k}` differs:\n dart = {ev}\n rust = {av}"); + } + } + panic!("state mismatch for case `{name}`"); +} + +#[test] +fn rust_execute_matches_dart_resolve() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/golden/states.json"); + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("read {path}: {e} (run golden_gen/bin/gen_golden.dart first)")); + let cases: Vec = serde_json::from_str(&raw).expect("parse golden json"); + + assert!(!cases.is_empty(), "golden corpus is empty"); + let mut checked = 0; + + for case in &cases { + let name = case["name"].as_str().unwrap(); + let board_size = case["boardSize"].as_i64().unwrap() as i32; + let counts: Vec = case["counts"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_i64().unwrap() as i32) + .collect(); + let sgn = case["sgn"].as_str().unwrap(); + let is_error = case["error"].as_bool().unwrap(); + + let config = StarGambitConfig::from_star_config(&StarConfig::with_counts(board_size, counts)); + let game = GameEngine::new_game(config); + let result = game.execute(sgn); + + if is_error { + assert!( + result.is_err(), + "case `{name}`: expected execute to error, but it succeeded" + ); + } else { + let g = result + .unwrap_or_else(|e| panic!("case `{name}`: execute errored unexpectedly: {e:?}")); + assert_state_eq(name, &case["state"], &dump_state(&g)); + } + checked += 1; + } + + eprintln!("differential proof: {checked} cases matched Dart exactly"); +} -- 2.51.2