From 08629eedd5faae3ce6e5c1b79a168cfb194dd207 Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Sun, 27 Sep 2026 21:20:46 +0000 Subject: [PATCH 1/7] refactor(hexmap): facade calls Bits and Dilation directly, lower bench_map budgets, README determinism - HexMap::ring calls Bits::and and Dilation::dilate / expand_small; the three BfsInternal delegations are removed (gas unchanged: ring 99_903). - bench_map budgets lowered after P1 (compute_distribution 203k, Caver keep_component variants 561k / 101k / 1.167M). - README: current gas figures, Rng bias bound (2^-56 per pool), determinism and stream stability from 1.8.0, endpoints on walls panic (unlike origami_map). - GAS.md: F1 section, L2 A* section marked dropped. Co-Authored-By: Claude Opus 5.5 --- crates/hexmap/GAS.md | 26 +++++++++++- crates/hexmap/README.md | 56 +++++++++++++++++-------- crates/hexmap/src/finders/bfs.cairo | 17 -------- crates/hexmap/src/map.cairo | 20 ++++----- crates/hexmap/src/tests/bench_map.cairo | 13 +++--- 5 files changed, 79 insertions(+), 53 deletions(-) diff --git a/crates/hexmap/GAS.md b/crates/hexmap/GAS.md index 91acf2c..a5f171b 100644 --- a/crates/hexmap/GAS.md +++ b/crates/hexmap/GAS.md @@ -444,7 +444,8 @@ stopped after three consecutive ideas below 2 % (loops x8, window backtracking, ## L2 A* baseline -_To be filled by lot L2._ +Dropped: the bit-parallel BFS (L1) and Dial (L3) cover unweighted and weighted searches; the A* +and heap modules were removed by L8. ## L3 Dial @@ -1520,7 +1521,8 @@ L0-L7 and S1 merged), "after" is the head of the P1 pull request (rebased on the Kept local: the byte-count select of `Spreader` (one module uses it), the `Frontier` dilation and backtracking masks of `Dial` (they need the `Dilation`), the walker's pool loop (locals, both limbs of every permutation, `Rng::split216`). `BfsInternal::and`, `expand` and `expand_small` -remain as one-line delegations to `Bits::and` and `Dilation` because `map.cairo` (L8) calls them. +remained as one-line delegations to `Bits::and` and `Dilation` for `map.cairo` (L8); F1 removed +them, the facade calls `Bits::and` and `Dilation::dilate` / `expand_small` directly. ### Microbenchmarks (per op = (test - loop) / 100, loop 142_090) @@ -1837,3 +1839,23 @@ tiles). | `bench_spreader_generate_sparse5_17x14_1` | 149_625 / 139_223 | 145_545 / 135_143 | -2.7 % / -2.9 % | | `bench_spreader_generate_sparse5_17x14_2` | 149_865 / 143_721 | 145_445 / 139_301 | -2.9 % / -3.1 % | +## F1 Release preparation + +Measured with scarb 2.19.4, snforge 0.61.0 (sierra gas), `snforge test -p origami_hexmap`. + +* **Facade.** `HexMap::ring` calls `Bits::and` and `Dilation::dilate` / `expand_small` directly; + the three `BfsInternal` delegations are gone. `bench_map_ring` 99_903, `bench_map_ring_7x7` + 46_403, `bench_map_ring_open_edge` 167_673: unchanged (the delegations were + `#[inline(always)]`). `keep_component` stays on `Bfs::reachable` for its edge semantics. +* **Budgets lowered** (more than 5 % under their L8 budget after P1, rule of this file): + +| Test | Measured | Budget before | Budget | +|---|---:|---:|---:| +| `bench_map_compute_distribution` | 193_168 | 218_000 | 203_000 | +| `bench_map_direct_compute_distribution` | 193_168 | 218_000 | 203_000 | +| `bench_map_variant_keep_component_caver` | 533_849 | 610_000 | 561_000 | +| `bench_map_variant_keep_component_caver_7x7` | 95_333 | 177_000 | 101_000 | +| `bench_map_variant_keep_component_caver_maze` | 1_111_093 | 1_306_000 | 1_167_000 | + +* **Package.** `.scarbignore` keeps `src/tests/`, `src/helpers/printer.cairo` (test-only modules, + declared under `#[cfg(target: "test")]`) and this file out of the published package. diff --git a/crates/hexmap/README.md b/crates/hexmap/README.md index ccc9114..583810e 100644 --- a/crates/hexmap/README.md +++ b/crates/hexmap/README.md @@ -5,7 +5,7 @@ pathfinding and range queries on bitmaps packed in a single `felt252`. It is the of [`origami_map`](../map) and mirrors its `Map` API name for name. Every operation is bit-parallel: a whole board is processed by a few field products and bitwise -operations per step, so a 17x14 cave, its entrance, 10 objects and a path cost about 1.2M gas, +operations per step, so a 17x14 cave, its entrance, 10 objects and a path cost about 1.14M gas, more than 100 times less than the same scenario on `origami_map` (see [Gas](#gas)). ## Installation @@ -180,29 +180,51 @@ let open: bool = map.is_walkable(113); `distance_to` returns `None` and `Some(0)` respectively. `hex_distance`, `neighbor` and `is_walkable` do not check their positions. +**Endpoints on walls panic.** This differs from `origami_map`, whose `search_path` returns an +empty path when the start or the target is a wall. In `origami_hexmap` an empty path only means +"unreachable" (or `from == to`); test `is_walkable` first if an endpoint may be a wall. + +## Randomness and determinism + +- **Deterministic.** A seed gives the same map, the same objects and the same walk for a given + package version: the generators draw from a Poseidon stream of the seed only (no block data, no + caller). Store the seed, not the grid, when the version is pinned. +- **Streams are part of the API from 1.8.0 on.** Each generator (`new_maze`, `new_cave`, + `new_random_walk`, `open_with_corridor`, `open_with_maze`, `compute_distribution`) has a test + pinning the exact grid of one seed; a change of those outputs is a breaking change. +- **Bias.** The internal `Rng` draws bounded integers from a 128-bit pool (one Poseidon + permutation) refilled below 2^64: the draws of one pool are within `2^-56` of independent + uniform draws for bounds up to 251 (`2^-54.5` for a shuffle of 6). `compute_distribution` is + uniform over the `count`-subsets of the walkable tiles up to these sources (its Poseidon key + words add `< 2^-51.3` per call, see `GAS.md`, L7). +- **Not a secret.** The seed decides everything: use a commit-reveal scheme or a VRF for the + seed when players must not predict the map (see `origami_security`). + ## Gas -Sierra gas measured by snforge 0.61.0 (scarb 2.19.4) in [`bench_map.cairo`](./src/tests/bench_map.cairo), +Sierra gas measured by snforge 0.61.0 (scarb 2.19.4) in [`bench_map.cairo`](https://github.com/dojoengine/origami/blob/main/crates/hexmap/src/tests/bench_map.cairo), each test including a ~14k test baseline. The facade adds no gas over the library call it forwards -to (identical or lower figures on every function). Details and the per-lot measurements: [GAS.md](./GAS.md). +to (identical or lower figures on every function). Details and the per-lot measurements: +[GAS.md](https://github.com/dojoengine/origami/blob/main/crates/hexmap/GAS.md) (in the repository, +not in the package). | Function | Input | Gas | |---|---|---:| | `new_empty` | 17x14 | 18k | -| `new_maze` | 17x14, order 0 (89 tiles carved) | 2.87M | -| `new_cave` | 17x14, order 3 | 145k | -| `new_cave` | 7x7, order 3 | 84k | -| `new_random_walk` | 17x14, 200 steps | 1.00M (~4.8k per step) | +| `new_maze` | 17x14, order 0 (90 tiles carved) | 2.90M | +| `new_cave` | 17x14, order 3 | 142k | +| `new_cave` | 7x7, order 3 | 81k | +| `new_random_walk` | 17x14, 200 steps | 969k (~4.8k per step) | | `new_hexagon` | radius 6 | 126k | | `open_with_corridor` | 17x14 cave, entrance 8 | 63k | -| `keep_component` / `reachable` | 17x14 cave / maze | 555k / 1.15M | -| `keep_component` / `reachable` | 7x7 cave | 102k | -| `compute_distribution` | 17x14 cave, 10 objects | 207k | +| `keep_component` / `reachable` | 17x14 cave / maze | 536k / 1.11M | +| `keep_component` / `reachable` | 7x7 cave | 98k | +| `compute_distribution` | 17x14 cave, 10 objects | 193k | | `search_path` | 17x14 cave, 24 steps | 706k | | `search_path` | 7x7, 6 steps | 132k | | `distance_to` | 17x14 cave, 24 steps | 502k | -| `search_path_weighted` | 17x14 cave, 2 cost classes | 1.43M | -| `field_of_movement` | 17x14 cave, budget 6, 2 classes | 262k | +| `search_path_weighted` | 17x14 cave, 2 cost classes | 1.41M | +| `field_of_movement` | 17x14 cave, budget 6, 2 classes | 254k | | `range` | 17x14 cave, radius 4 | 104k | | `ring` | 17x14 cave, radius 4 | 100k | | `ring` | 7x7 cave, radius 2 | 46k | @@ -221,12 +243,12 @@ backtracking step ~8k; a weighted time step ~37k with 2 cost classes. | Step | hexmap 17x14 | hexmap 7x7 | origami_map 18x14 | |---|---:|---:|---:| -| `new_cave` | 145k | 84k | 126.0M | -| `keep_component` | 189k | 58k | - | -| `open_with_corridor` | 163k | 48k | 60k | -| `compute_distribution(10)` | 189k | 119k | 8.96M | +| `new_cave` | 142k | 81k | 126.0M | +| `keep_component` | 182k | 55k | - | +| `open_with_corridor` | 161k | 48k | 60k | +| `compute_distribution(10)` | 177k | 111k | 8.96M | | `search_path` | 476k (15 steps) | 177k (6 steps) | 10.77M (25 steps) | -| **Total** | **1.16M** | **486k** | **145.8M** | +| **Total** | **1.14M** | **471k** | **145.8M** | ## Bitmaps and `u252` diff --git a/crates/hexmap/src/finders/bfs.cairo b/crates/hexmap/src/finders/bfs.cairo index 3dcab45..21f7c8e 100644 --- a/crates/hexmap/src/finders/bfs.cairo +++ b/crates/hexmap/src/finders/bfs.cairo @@ -1177,23 +1177,6 @@ pub(crate) impl BfsInternal of BfsInternalTrait { let (_, odd) = LayoutTrait::parity(width, next); (next, Bits::pow(next), odd) } - /// `u256` AND with the bitwise builtin, see `Bits::and` (used by the `HexMap` facade). - #[inline(always)] - fn and(lhs: u256, rhs: u256) -> u256 { - Bits::and(lhs, rhs) - } - /// Hex dilation of a frontier given by its limbs and as a felt, see `Dilation::dilate` - /// (used by the `HexMap` facade). - #[inline(always)] - fn expand(step: @Dilation, low: u128, high: u128, felt: felt252) -> (u128, u128) { - step.dilate(low, high, felt) - } - - /// Hex dilation on a single limb, see `Dilation::expand_small` (used by the `HexMap` facade). - #[inline(always)] - fn expand_small(step: @Dilation, frontier: u128) -> u128 { - step.expand_small(frontier) - } } #[cfg(test)] diff --git a/crates/hexmap/src/map.cairo b/crates/hexmap/src/map.cairo index 3d7a7b9..da566f1 100644 --- a/crates/hexmap/src/map.cairo +++ b/crates/hexmap/src/map.cairo @@ -15,7 +15,7 @@ use origami_hexmap::generators::walker::Walker; use origami_hexmap::helpers::asserter::Asserter; use origami_hexmap::helpers::bits::Bits; use origami_hexmap::helpers::geometry::Geometry; -use origami_hexmap::helpers::layout::LayoutTrait; +use origami_hexmap::helpers::layout::{DilationTrait, LayoutTrait}; use origami_hexmap::types::direction::Direction; // Constants @@ -315,10 +315,10 @@ pub impl HexMapImpl of HexMapTrait { return outer - Bfs::tiles_within_range(grid, width, height, position, radius - 1); } if radius == 1 { - return Bits::to_felt(BfsInternal::and(centre.around.into(), open)); + return Bits::to_felt(Bits::and(centre.around.into(), open)); } // [Compute] Balls of radius `radius - 1` and `radius - 2` - let first = BfsInternal::and((centre.around + power).into(), free); + let first = Bits::and((centre.around + power).into(), free); let edges = grid - Bits::to_felt(free); if width * height <= SMALL_SIZE { let (ball, inner) = BfsInternal::flood_small(@step, first.low, free.low, radius - 2); @@ -328,14 +328,14 @@ pub impl HexMapImpl of HexMapTrait { inner }; // [Compute] Interior tiles of the ring - let near = BfsInternal::expand_small(@step, ball.try_into().unwrap()); + let near = step.expand_small(ball.try_into().unwrap()); let ring: felt252 = (near & free.low).into() - ball; if edges == 0 { return ring; } // [Return] Plus the edge tiles next to the ball but not to the inner ball let edges: u128 = edges.try_into().unwrap(); - let far = BfsInternal::expand_small(@step, inner.try_into().unwrap()); + let far = step.expand_small(inner.try_into().unwrap()); return ring + (near & edges).into() - (far & edges).into(); } let (ball, inner) = BfsInternal::flood(@step, first, free, radius - 2); @@ -346,20 +346,18 @@ pub impl HexMapImpl of HexMapTrait { }; // [Compute] Interior tiles of the ring let wide: u256 = ball.into(); - let (low, high) = BfsInternal::expand(@step, wide.low, wide.high, ball); + let (low, high) = step.dilate(wide.low, wide.high, ball); let near = u256 { low, high }; - let ring = Bits::to_felt(BfsInternal::and(near, free)) - ball; + let ring = Bits::to_felt(Bits::and(near, free)) - ball; if edges == 0 { return ring; } // [Return] Plus the edge tiles next to the ball but not to the inner ball let edges: u256 = edges.into(); let wide: u256 = inner.into(); - let (low, high) = BfsInternal::expand(@step, wide.low, wide.high, inner); + let (low, high) = step.dilate(wide.low, wide.high, inner); let far = u256 { low, high }; - ring - + Bits::to_felt(BfsInternal::and(near, edges)) - - Bits::to_felt(BfsInternal::and(far, edges)) + ring + Bits::to_felt(Bits::and(near, edges)) - Bits::to_felt(Bits::and(far, edges)) } /// Neighbour of a position, `None` outside the board. diff --git a/crates/hexmap/src/tests/bench_map.cairo b/crates/hexmap/src/tests/bench_map.cairo index 34ff9b8..7f05511 100644 --- a/crates/hexmap/src/tests/bench_map.cairo +++ b/crates/hexmap/src/tests/bench_map.cairo @@ -149,7 +149,8 @@ fn bench_map_direct_open_with_maze() { Digger::maze(17, 14, 0, MAZE_ENTRANCE, CAVE_17X14, SEED); } -// Connectivity: the facade uses `Bfs::reachable`, `Caver::keep_component` is the loser +// Connectivity: the facade uses `Bfs::reachable` for its edge semantics (open edge tiles next to +// the component are kept); `Caver::keep_component` floods the interior only, without the checks #[test] #[available_gas(l2_gas: 583000)] @@ -165,7 +166,7 @@ fn bench_map_direct_keep_component() { } #[test] -#[available_gas(l2_gas: 610000)] +#[available_gas(l2_gas: 561000)] fn bench_map_variant_keep_component_caver() { Caver::keep_component(CAVE_17X14, 17, 14, CAVE_17X14_FAR_FROM); } @@ -178,7 +179,7 @@ fn bench_map_keep_component_7x7() { } #[test] -#[available_gas(l2_gas: 177000)] +#[available_gas(l2_gas: 101000)] fn bench_map_variant_keep_component_caver_7x7() { Caver::keep_component(CAVE_7X7, 7, 7, CAVE_7X7_FAR_FROM); } @@ -191,7 +192,7 @@ fn bench_map_keep_component_maze() { } #[test] -#[available_gas(l2_gas: 1306000)] +#[available_gas(l2_gas: 1167000)] fn bench_map_variant_keep_component_caver_maze() { Caver::keep_component(MAZE_17X14, 17, 14, MAZE_17X14_FAR_FROM); } @@ -199,13 +200,13 @@ fn bench_map_variant_keep_component_caver_maze() { // Distribution #[test] -#[available_gas(l2_gas: 218000)] +#[available_gas(l2_gas: 203000)] fn bench_map_compute_distribution() { cave().compute_distribution(10, SEED); } #[test] -#[available_gas(l2_gas: 218000)] +#[available_gas(l2_gas: 203000)] fn bench_map_direct_compute_distribution() { Spreader::generate(CAVE_17X14, 17, 14, 10, SEED); } From 9e4a2c676158a782bc76fb143511443bd2f5d047 Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Sun, 27 Sep 2026 21:20:51 +0000 Subject: [PATCH 2/7] chore: keep test-only files out of the published packages .scarbignore in every crate: integration tests, and for origami_hexmap the src/tests benchmarks, the printer and GAS.md (origami_map: its printer). The modules are declared under #[cfg(target: "test")], so the package verification build compiles without them. origami_hexmap: 943.98 KiB -> 398.88 KiB. Co-Authored-By: Claude Opus 5.5 --- crates/defi/.scarbignore | 3 +++ crates/hexmap/.scarbignore | 9 +++++++++ crates/map/.scarbignore | 6 ++++++ crates/random/.scarbignore | 3 +++ crates/rating/.scarbignore | 3 +++ crates/security/.scarbignore | 3 +++ 6 files changed, 27 insertions(+) create mode 100644 crates/defi/.scarbignore create mode 100644 crates/hexmap/.scarbignore create mode 100644 crates/map/.scarbignore create mode 100644 crates/random/.scarbignore create mode 100644 crates/rating/.scarbignore create mode 100644 crates/security/.scarbignore diff --git a/crates/defi/.scarbignore b/crates/defi/.scarbignore new file mode 100644 index 0000000..5f9903e --- /dev/null +++ b/crates/defi/.scarbignore @@ -0,0 +1,3 @@ +# Kept out of the published package (`scarb package`), still in the repository. +# Integration tests (README examples). +/tests/ diff --git a/crates/hexmap/.scarbignore b/crates/hexmap/.scarbignore new file mode 100644 index 0000000..5492aa9 --- /dev/null +++ b/crates/hexmap/.scarbignore @@ -0,0 +1,9 @@ +# Kept out of the published package (`scarb package`), still in the repository. +# Integration tests (README examples). +/tests/ +# Test-only modules: declared under `#[cfg(target: "test")]` in `src/lib.cairo`, so the +# verification build and dependents compile without them. +/src/tests/ +/src/helpers/printer.cairo +# Measurements, linked from the README on GitHub. +/GAS.md diff --git a/crates/map/.scarbignore b/crates/map/.scarbignore new file mode 100644 index 0000000..36e599b --- /dev/null +++ b/crates/map/.scarbignore @@ -0,0 +1,6 @@ +# Kept out of the published package (`scarb package`), still in the repository. +# Integration tests (README examples). +/tests/ +# Test-only module: declared under `#[cfg(target: "test")]` in `src/lib.cairo`, so the +# verification build and dependents compile without it. +/src/helpers/printer.cairo diff --git a/crates/random/.scarbignore b/crates/random/.scarbignore new file mode 100644 index 0000000..5f9903e --- /dev/null +++ b/crates/random/.scarbignore @@ -0,0 +1,3 @@ +# Kept out of the published package (`scarb package`), still in the repository. +# Integration tests (README examples). +/tests/ diff --git a/crates/rating/.scarbignore b/crates/rating/.scarbignore new file mode 100644 index 0000000..5f9903e --- /dev/null +++ b/crates/rating/.scarbignore @@ -0,0 +1,3 @@ +# Kept out of the published package (`scarb package`), still in the repository. +# Integration tests (README examples). +/tests/ diff --git a/crates/security/.scarbignore b/crates/security/.scarbignore new file mode 100644 index 0000000..5f9903e --- /dev/null +++ b/crates/security/.scarbignore @@ -0,0 +1,3 @@ +# Kept out of the published package (`scarb package`), still in the repository. +# Integration tests (README examples). +/tests/ From 333adb9e9baf99cc7320fda7a933e5063e511713 Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Sun, 27 Sep 2026 21:20:51 +0000 Subject: [PATCH 3/7] test: compile every crate README example, make the GDA fields public - tests/readme.cairo in each crate runs the README examples against the public API (integration tests, not packaged). - origami_defi: DiscreteGDA and ContinuousGDA fields are pub, as LinearVRGDA's: the README example could not compile outside the crate. - defi and security READMEs: the missing imports. Co-Authored-By: Claude Opus 5.5 --- crates/defi/README.md | 10 +- crates/defi/src/auction/gda.cairo | 14 +-- crates/defi/tests/readme.cairo | 63 +++++++++++++ crates/hexmap/tests/readme.cairo | 142 +++++++++++++++++++++++++++++ crates/map/tests/readme.cairo | 76 +++++++++++++++ crates/random/tests/readme.cairo | 43 +++++++++ crates/rating/tests/readme.cairo | 22 +++++ crates/security/README.md | 1 + crates/security/tests/readme.cairo | 19 ++++ 9 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 crates/defi/tests/readme.cairo create mode 100644 crates/hexmap/tests/readme.cairo create mode 100644 crates/map/tests/readme.cairo create mode 100644 crates/random/tests/readme.cairo create mode 100644 crates/rating/tests/readme.cairo create mode 100644 crates/security/tests/readme.cairo diff --git a/crates/defi/README.md b/crates/defi/README.md index 427a071..023b74b 100644 --- a/crates/defi/README.md +++ b/crates/defi/README.md @@ -98,7 +98,12 @@ let price = gda.purchase_price(time_since_last, quantity); These examples demonstrate how to create instances of the `DiscreteGDA` and `ContinuousGDA` structures, and how to utilize their `purchase_price` methods to calculate the price for purchasing specific quantities at given times. -You'll need the `fixed` package in your project to build the `Fixed` values: `use fixed::{FixedTrait, ONE};`. +The imports of these examples (the `fixed` package builds the `Fixed` values): + +```rust +use fixed::{FixedTrait, ONE}; +use origami_defi::auction::gda::{ContinuousGDA, ContinuousGDATrait, DiscreteGDA, DiscreteGDATrait}; +``` ## Conclusion @@ -169,8 +174,11 @@ Make sure to import the required dependencies at the beginning of your Cairo fil ```rust use fixed::{Fixed, FixedTrait}; +use origami_defi::auction::vrgda::{LinearVRGDA, LogisticVRGDA, VRGDATargetTimeTrait, VRGDATrait}; ``` +Every example of this README is compiled by `tests/readme.cairo`. + These examples show you how to create instances of both `LinearVRGDA` and `LogisticVRGDA` and how to use their methods to calculate the target sale time and VRGDA price. ## Conclusion diff --git a/crates/defi/src/auction/gda.cairo b/crates/defi/src/auction/gda.cairo index 48534c3..9a391bc 100644 --- a/crates/defi/src/auction/gda.cairo +++ b/crates/defi/src/auction/gda.cairo @@ -14,10 +14,10 @@ use super::helpers::{FixedStorePacking, mul_div}; /// the auction has started. #[derive(Copy, Drop, Serde, starknet::Store)] pub struct DiscreteGDA { - sold: Fixed, - initial_price: Fixed, - scale_factor: Fixed, - decay_constant: Fixed, + pub sold: Fixed, + pub initial_price: Fixed, + pub scale_factor: Fixed, + pub decay_constant: Fixed, } #[generate_trait] @@ -70,9 +70,9 @@ pub impl DiscreteGDAImpl of DiscreteGDATrait { /// emission rate, decay constant, and the time since the last purchase in days. #[derive(Copy, Drop, Serde, starknet::Store)] pub struct ContinuousGDA { - initial_price: Fixed, - emission_rate: Fixed, - decay_constant: Fixed, + pub initial_price: Fixed, + pub emission_rate: Fixed, + pub decay_constant: Fixed, } #[generate_trait] diff --git a/crates/defi/tests/readme.cairo b/crates/defi/tests/readme.cairo new file mode 100644 index 0000000..ab28400 --- /dev/null +++ b/crates/defi/tests/readme.cairo @@ -0,0 +1,63 @@ +//! The examples of `README.md`, compiled against the public API. + +use fixed::{Fixed, FixedTrait, ONE}; +use origami_defi::auction::gda::{ContinuousGDA, ContinuousGDATrait, DiscreteGDA, DiscreteGDATrait}; +use origami_defi::auction::vrgda::{LinearVRGDA, LogisticVRGDA, VRGDATargetTimeTrait, VRGDATrait}; + +#[test] +fn test_readme_discrete_gda() { + let gda = DiscreteGDA { + sold: FixedTrait::from_int(0), + initial_price: FixedTrait::from_int(100), + scale_factor: FixedTrait::from_ratio(11, 10), // 1.1 + decay_constant: FixedTrait::from_ratio(1, 2) // 0.5 + }; + let time_since_start = FixedTrait::from_int(2); // 2 days since the start + let quantity = FixedTrait::from_int(5); // Quantity to purchase + let price = gda.purchase_price(time_since_start, quantity); + assert!(price > FixedTrait::from_int(0)); +} + +#[test] +fn test_readme_continuous_gda() { + let gda = ContinuousGDA { + initial_price: FixedTrait::from_int(1000), + emission_rate: ONE, + decay_constant: FixedTrait::from_ratio(1, 2), + }; + let time_since_last = FixedTrait::from_int(1); // 1 day since the last purchase + let quantity = FixedTrait::from_int(3); // Quantity to purchase + let price = gda.purchase_price(time_since_last, quantity); + assert!(price > FixedTrait::from_int(0)); +} + +#[test] +fn test_readme_linear_vrgda() { + let auction = LinearVRGDA { + target_price: FixedTrait::from_ratio(6942, 100), // 69.42 + decay_constant: FixedTrait::from_ratio(31, 100), // 0.31 + target_units_per_time: FixedTrait::from_int(2), + }; + let sold_quantity: Fixed = FixedTrait::from_int(10); + let time_since_start: Fixed = FixedTrait::from_int(5); + let target_sale_time = auction.get_target_sale_time(sold_quantity); + let price = auction.get_vrgda_price(time_since_start, sold_quantity); + assert!(target_sale_time == FixedTrait::from_int(5)); + assert!(price > FixedTrait::from_int(0)); +} + +#[test] +fn test_readme_logistic_vrgda() { + let auction = LogisticVRGDA { + target_price: FixedTrait::from_ratio(6942, 100), // 69.42 + decay_constant: FixedTrait::from_ratio(31, 100), // 0.31 + max_sellable: FixedTrait::from_int(6392), + time_scale: FixedTrait::from_ratio(23, 10000) // 0.0023 + }; + let sold_quantity: Fixed = FixedTrait::from_int(10); + let time_since_start: Fixed = FixedTrait::from_int(5); + let target_sale_time = auction.get_target_sale_time(sold_quantity); + let price = auction.get_vrgda_price(time_since_start, sold_quantity); + assert!(target_sale_time > FixedTrait::from_int(0)); + assert!(price > FixedTrait::from_int(0)); +} diff --git a/crates/hexmap/tests/readme.cairo b/crates/hexmap/tests/readme.cairo new file mode 100644 index 0000000..693b190 --- /dev/null +++ b/crates/hexmap/tests/readme.cairo @@ -0,0 +1,142 @@ +//! The examples of `README.md`, compiled against the public API (integration test: the +//! `pub(crate)` internals are out of reach here, as for a dependent package). + +use origami_hexmap::helpers::layout::LayoutTrait; +use origami_hexmap::{Direction, HexMap, HexMapTrait, U252Trait, u252}; + +const SEED: felt252 = 'SEED'; + +/// The README map: a 17x14 cave kept connected to 113 and opened from the edge tile 8. +fn opened() -> HexMap { + let mut map = HexMapTrait::new_cave(17, 14, 3, SEED); + map.keep_component(113); + map.open_with_corridor(8, 0); + map +} + +#[test] +fn test_readme_create() { + let seed = SEED; + let grid = HexMapTrait::new_cave(17, 14, 3, seed).grid; + // From an existing grid (no check) + let map = HexMapTrait::new(grid, 17, 14, seed); + assert!(map.grid == grid); + // Every interior tile walkable + let map = HexMapTrait::new_empty(17, 14, seed); + assert!(map.grid == LayoutTrait::interior(17, 14)); + // A maze, order 0 (dense) or 1 (sparse, walls at least 2 thick) + let map = HexMapTrait::new_maze(17, 14, 0, seed); + assert!(map.grid != 0); + // A cave: cellular automaton B4/S2, `order` generations, 3 is a good default + let map = HexMapTrait::new_cave(17, 14, 3, seed); + assert!(map.grid == grid); + // A random walk of `steps` steps from a random interior tile + let map = HexMapTrait::new_random_walk(17, 14, 500, seed); + assert!(map.grid != 0); + // A hexagon of radius 4 in a 11x11 board + let map = HexMapTrait::new_hexagon(4, seed); + assert!(map.width == 11 && map.height == 11); +} + +#[test] +fn test_readme_open() { + let seed = SEED; + let mut map = HexMapTrait::new_cave(17, 14, 3, seed); + // Keep the cave connected to tile 113 (flood fill) + map.keep_component(113); + let kept = map; + // Dig a corridor from the edge tile 8 until it touches an open tile + map.open_with_corridor(8, 0); + assert!(map.is_walkable(8)); + // Or grow a maze from an edge tile, merged with the open tiles it touches + let mut map = kept; + map.open_with_maze(8, 0); + assert!(map.is_walkable(8)); +} + +#[test] +fn test_readme_place_objects() { + let map = opened(); + let seed = SEED; + // 10 distinct walkable tiles, uniform, as a bitmap + let objects: felt252 = map.compute_distribution(10, seed); + let objects: u256 = objects.into(); + let grid: u256 = map.grid.into(); + assert!(objects & grid == objects); +} + +#[test] +fn test_readme_find_paths() { + let map = opened(); + let swamps = map.range(113, 2); + let mountains = map.ring(113, 4); + // Shortest path, from the target (included) to the start (excluded), empty if unreachable + let path: Span = map.search_path(8, 202); + assert!(path.len() == 15); + // Number of steps only, `None` if unreachable + let steps: Option = map.distance_to(8, 202); + assert!(steps == Option::Some(15)); + // Distance on an empty board, walls ignored + let d: u8 = map.hex_distance(8, 202); + assert!(d <= 15); + // Cheapest path with entry costs + let costs = array![swamps, mountains].span(); + let path: Span = map.search_path_weighted(8, 202, costs); + assert!(path.len() >= 15); +} + +#[test] +fn test_readme_query_areas() { + let map = opened(); + let costs = array![map.range(113, 2)].span(); + // Every tile reachable from 113 + let component: felt252 = map.reachable(113); + assert!(component == map.grid); + // Tiles within 4 steps (walls block), 113 included + let area: felt252 = map.range(113, 4); + // Tiles at exactly 4 steps + let ring: felt252 = map.ring(113, 4); + assert!(ring == area - map.range(113, 3)); + // Tiles reachable with a movement budget of 6 under the cost classes + let moves: felt252 = map.field_of_movement(113, 6, costs); + assert!(moves != 0); + // Neighbour, `None` outside the board + let next: Option = map.neighbor(113, Direction::NorthEast); + // 113 is on an even row: NorthEast is `i + W - 1` + assert!(next == Option::Some(113 + 17 - 1)); + let open: bool = map.is_walkable(113); + assert!(open); +} + +#[test] +#[should_panic(expected: 'Bfs: position not walkable')] +fn test_readme_endpoint_on_wall_panics() { + let map = opened(); + // Tile 0 is a corner of the wall ring + map.search_path(0, 202); +} + +#[test] +fn test_readme_u252() { + let map = opened(); + let seed = SEED; + let objects: u252 = map.compute_distribution(10, seed).into(); // free + let raw: felt252 = objects.into(); // free + assert!(raw == map.compute_distribution(10, seed)); + assert!(objects.value() == raw); +} + +#[test] +fn test_readme_migration() { + // `Hex { col: 0, row: 0 }` of `origami_map::hex` on a 17x14 board + let index = LayoutTrait::index(17, 0 + 1, 0 + 2); + let layout = LayoutTrait::new(17, 14); + let map = HexMapTrait::new_empty(17, 14, SEED); + // Six neighbours, as a bitmap + let neighbours: u256 = layout.neighbour_mask(index).into(); + let ring: u256 = map.ring(index, 1).into(); + let grid: u256 = map.grid.into(); + assert!(neighbours & grid == ring); + // `hex.is_neighbor(other)` + assert!(map.hex_distance(index, index + 1) == 1); +} diff --git a/crates/map/tests/readme.cairo b/crates/map/tests/readme.cairo new file mode 100644 index 0000000..80c2a4e --- /dev/null +++ b/crates/map/tests/readme.cairo @@ -0,0 +1,76 @@ +//! The examples of `README.md`, compiled against the public API. + +use origami_map::map::MapTrait; + +const WIDTH: u8 = 18; +const HEIGHT: u8 = 14; +const ORDER: u8 = 0; +const SEED: felt252 = 'SEED'; + +#[test] +fn test_readme_maze() { + let width = 18; + let height = 14; + let order = 0; + let seed = 'SEED'; + let maze_map = MapTrait::new_maze(width, height, order, seed); + assert!(maze_map.grid != 0); +} + +#[test] +fn test_readme_cave() { + let width = 18; + let height = 14; + let order = 3; + let seed = 'SEED'; + let cave_map = MapTrait::new_cave(width, height, order, seed); + // This seed yields an empty cave: the cellular automaton removes every tile + assert!(cave_map.grid == 0); +} + +#[test] +fn test_readme_random_walk() { + let width = 18; + let height = 14; + let steps = 500; + let seed = 'SEED'; + let random_walk_map = MapTrait::new_random_walk(width, height, steps, seed); + assert!(random_walk_map.grid != 0); +} + +#[test] +fn test_readme_open_with_corridor() { + let (width, height, order, seed) = (WIDTH, HEIGHT, ORDER, SEED); + let mut map = MapTrait::new_maze(width, height, order, seed); + let position = 1; + let order = 0; + map.open_with_corridor(position, order); +} + +#[test] +fn test_readme_open_with_maze() { + let (width, height, order, seed) = (WIDTH, HEIGHT, ORDER, SEED); + let mut map = MapTrait::new_maze(width, height, order, seed); + let position = 1; + let order = 0; + map.open_with_maze(position, order); +} + +#[test] +fn test_readme_search_path() { + let (width, height, order, seed) = (WIDTH, HEIGHT, ORDER, SEED); + let mut map = MapTrait::new_maze(width, height, order, seed); + map.open_with_corridor(1, 0); + let (start_position, end_position) = (1, 1 + 2 * WIDTH); + // Empty when the target is a wall or unreachable + let path = map.search_path(start_position, end_position); + assert!(path.len() == 0); +} + +#[test] +fn test_readme_compute_distribution() { + let (width, height, order, seed) = (WIDTH, HEIGHT, ORDER, SEED); + let map = MapTrait::new_maze(width, height, order, seed); + let distribution = map.compute_distribution(10, seed); + assert!(distribution != 0); +} diff --git a/crates/random/tests/readme.cairo b/crates/random/tests/readme.cairo new file mode 100644 index 0000000..f0fcad1 --- /dev/null +++ b/crates/random/tests/readme.cairo @@ -0,0 +1,43 @@ +//! The examples of `README.md`, compiled against the public API. + +use origami_random::deck::{Deck, DeckTrait}; +use origami_random::dice::{Dice, DiceTrait}; + +#[test] +fn test_readme_dice() { + // Create a new 6-sided dice with a seed + let mut dice: Dice = DiceTrait::new(6, 'SEED'); + // Roll the dice + let result = dice.roll(); + assert!(result >= 1 && result <= 6); +} + +#[test] +fn test_readme_deck() { + // Create a new deck with 52 cards and a seed + let mut deck: Deck = DeckTrait::new('SEED', 52); + // Draw a card from the deck + let card = deck.draw(); + // Discard a card back into the deck + deck.discard(card); + // Withdraw a specific card from the deck + deck.withdraw(10); + assert!(deck.remaining == 51); +} + +#[test] +fn test_readme_advanced() { + // Spawn 1 to 6 mobs + let mut dice = DiceTrait::new(6, 'SEED'); + let mut count = dice.roll(); + // Create a deck with the same number of cards as the number of mobs + let mut deck = DeckTrait::new('SEED', count.into()); + // Draw each mob from the deck and create an array of mob ids + let mut mob_ids: Array = array![]; + while count > 0 { + let mob_id = deck.draw(); + mob_ids.append(mob_id); + count -= 1; + } + assert!(mob_ids.len() >= 1); +} diff --git a/crates/rating/tests/readme.cairo b/crates/rating/tests/readme.cairo new file mode 100644 index 0000000..3ac5ba7 --- /dev/null +++ b/crates/rating/tests/readme.cairo @@ -0,0 +1,22 @@ +//! The examples of `README.md`, compiled against the public API. + +use origami_rating::elo::EloTrait; + +#[test] +fn test_readme_elo() { + // Calculate rating change for player A + let (change, is_negative) = EloTrait::rating_change( + 1200_u64, // Player A's current rating + 1400_u64, // Player B's current rating + 100_u16, // Outcome (100 = win, 50 = draw, 0 = loss) + 20_u8 // K-factor + ); + // Apply the rating change + let new_rating_a = if is_negative { + 1200 - change + } else { + 1200 + change + }; + println!("Player A's new rating: {}", new_rating_a); + assert!(new_rating_a == 1215); +} diff --git a/crates/security/README.md b/crates/security/README.md index 0b31007..e008e47 100644 --- a/crates/security/README.md +++ b/crates/security/README.md @@ -46,6 +46,7 @@ Here are some examples of how to use the Security crate: ### Commit/Reveal ```rust +use core::poseidon::poseidon_hash_span; use origami_security::commitment::{Commitment, CommitmentTrait}; // Create a new commitment diff --git a/crates/security/tests/readme.cairo b/crates/security/tests/readme.cairo new file mode 100644 index 0000000..ae24718 --- /dev/null +++ b/crates/security/tests/readme.cairo @@ -0,0 +1,19 @@ +//! The examples of `README.md`, compiled against the public API. + +use core::poseidon::poseidon_hash_span; +use origami_security::commitment::{Commitment, CommitmentTrait}; + +#[test] +fn test_readme_commit_reveal() { + // Create a new commitment + let mut commitment: Commitment = CommitmentTrait::new(); + // Commit to a value (in this case, a string) + let value = 'secret'; + let mut serialized = array![]; + value.serialize(ref serialized); + let hash = poseidon_hash_span(serialized.span()); + commitment.commit(hash); + // Later, reveal and verify the commitment + let is_valid = commitment.reveal('secret'); + assert(is_valid, 'Invalid reveal for commitment'); +} From cd36cbc0ab49aa2772ea7679e2c9e59443551fe0 Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Sun, 27 Sep 2026 21:20:56 +0000 Subject: [PATCH 4/7] docs: root README for 1.8.0 and CHANGELOG Crate list, scarb add @1.8.0 for the six crates, toolchain, what's new in 1.8.0 (toolchain, algebra removed, defi on fixed Q32.32, origami_hexmap gas figures from GAS.md), and a CHANGELOG with the 1.8.0 entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 44 ++++++++++++++++++++++++++----------- 2 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fe9cc7d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +All notable changes to the Origami crates. The six crates share one version. + +## [1.8.0] - unreleased + +First release published on the [scarbs.xyz](https://scarbs.xyz) registry: `origami_defi`, +`origami_hexmap`, `origami_map`, `origami_random`, `origami_rating`, `origami_security`. + +### Toolchain + +- Scarb and Cairo 2.19.4 (from 2.12.2); `origami_hexmap` is tested with Starknet Foundry 0.61.0. +- Registry metadata on every crate (description, license, repository, homepage, documentation, + readme, keywords); versions and license inherited from the workspace and resolved in the + packages. +- Test-only files (integration tests, `origami_hexmap` benchmarks and `GAS.md`, the printers) + are kept out of the packages with `.scarbignore`. +- Release workflow: on a `v*` tag, checks the tag against the crate versions, packages the six + crates and creates the GitHub release; publishes to scarbs.xyz the versions not yet there once + the `SCARB_REGISTRY_AUTH_TOKEN` secret is set. + +### Removed + +- `origami_algebra` (vectors and matrices) and its `cubit` dependency: use the + [`nalgebra`](https://scarbs.xyz/packages/nalgebra) or [`glam`](https://scarbs.xyz/packages/glam) + packages. + +### Changed + +- **`origami_defi`** runs on [`fixed`](https://scarbs.xyz/packages/fixed) 0.4.0 instead of + `cubit`: values are signed Q32.32 `fixed::Fixed` numbers, range `[-2^31, 2^31)`, resolution + `2^-32`. Only the inputs and the result have to fit: intermediate products are kept wide. The + domain limits of each function are documented (`# Domain`). Dependents add `fixed@0.4.0` to + build the values. + +### Added + +- **`origami_hexmap`**: hexagonal tile maps on a `felt252` bitmap (`W * H <= 251`), with the + `HexMap` facade that mirrors `origami_map::Map`: + - generators: `new_maze`, `new_cave`, `new_random_walk`, `new_hexagon`, `open_with_corridor`, + `open_with_maze`, `compute_distribution` (uniform), `keep_component`; + - finders: `search_path` (BFS), `distance_to`, `search_path_weighted` and + `field_of_movement` (up to 3 cost classes), `reachable`, `range`, `ring`, `hex_distance`, + `neighbor`, `is_walkable`; + - `u252`, an unsigned integer held in one `felt252`. + - Gas (snforge 0.61.0, sierra gas): the scenario cave + component + entrance + 10 objects + + path costs 1.14M on 17x14 (145.8M on `origami_map` 18x14, cairo-test estimate). + - Determinism: a seed gives the same map for a given version; the generators' streams are + part of the API from 1.8.0 on. +- `origami_defi`: `FixedStorePacking` (a `Fixed` stored as one `i64`). + +### Fixed + +- `origami_defi`: the fields of `DiscreteGDA` and `ContinuousGDA` are public, as those of + `LinearVRGDA` and `LogisticVRGDA`: the structs could not be built outside the crate. +- Every crate README example compiles against the current API (`tests/readme.cairo` in each + crate). + +## [1.7.0] + +Last release on git tags only (Scarb 2.12.2). diff --git a/README.md b/README.md index b153fc5..0f71a35 100644 --- a/README.md +++ b/README.md @@ -39,37 +39,57 @@ It provides a set of powerful tools and libraries that enable game developers to ### Crates -- [Defi](./crates/defi/) -- [Hexmap](./crates/hexmap) -- [Map](./crates/map) -- Physics (WIP) -- [Random](./crates/random) -- [Rating](./crates/rating) -- [Security](./crates/security) +| Crate | Content | +|---|---| +| [`origami_defi`](./crates/defi) | Gradual Dutch auctions (discrete, continuous) and VRGDAs (linear, logistic) on `fixed` Q32.32 numbers | +| [`origami_hexmap`](./crates/hexmap) | Hexagonal tile maps: generators, BFS and weighted search, ranges and rings, bit-parallel on one `felt252` | +| [`origami_map`](./crates/map) | Square tile maps: maze, cave and random walk generators, A\*, BFS, DFS, Dijkstra and greedy finders | +| [`origami_random`](./crates/random) | Dice and card decks from a seed | +| [`origami_rating`](./crates/rating) | Elo rating | +| [`origami_security`](./crates/security) | Commit-reveal commitments | -### Easy integration into your project +### Installation -Incorporate `origami` seamlessly into your projects using Scarb.toml. +Toolchain: [Scarb](https://docs.swmansion.com/scarb) 2.19.4 (Cairo 2.19.4); `origami_hexmap` is +tested with [Starknet Foundry](https://foundry-rs.github.io/starknet-foundry) 0.61.0. -From the [scarbs.xyz](https://scarbs.xyz) registry: +From the [scarbs.xyz](https://scarbs.xyz) registry, one crate at a time: ```sh +scarb add origami_defi@1.8.0 scarb add origami_hexmap@1.8.0 scarb add origami_map@1.8.0 scarb add origami_random@1.8.0 +scarb add origami_rating@1.8.0 +scarb add origami_security@1.8.0 ``` +`origami_defi` takes and returns `fixed::Fixed` values: add `scarb add fixed@0.4.0` as well. + Or from git, in your `[dependencies]`: ```toml [dependencies] -origami_random = { git = "https://github.com/dojoengine/origami", tag = "v1.8.0" } origami_hexmap = { git = "https://github.com/dojoengine/origami", tag = "v1.8.0" } -origami_map = { git = "https://github.com/dojoengine/origami", tag = "v1.8.0" } ``` For linear algebra, use the [`nalgebra`](https://scarbs.xyz/packages/nalgebra) and [`glam`](https://scarbs.xyz/packages/glam) packages on scarbs.xyz. +### What's new in 1.8.0 + +- **Toolchain.** Scarb and Cairo 2.19.4 (from 2.12.2). The crates are published on scarbs.xyz + for the first time. +- **`origami_algebra` removed**, with its `cubit` dependency: use `nalgebra` or `glam`. +- **`origami_defi` on [`fixed`](https://scarbs.xyz/packages/fixed)** 0.4.0: signed Q32.32 + numbers, range `[-2^31, 2^31)`, resolution `2^-32`; intermediate products are kept wide. +- **New `origami_hexmap`**: hexagonal maps where every operation works on the whole board at + once. Measured with snforge 0.61.0 (sierra gas, see + [`GAS.md`](./crates/hexmap/GAS.md)): a 17x14 cave, its connected component, an entrance, + 10 objects and a 15-step path cost 1.14M gas, against 145.8M for the same scenario on + `origami_map` 18x14 (cairo-test estimate); `search_path` across a 17x14 cave (24 steps) 706k, `new_cave` 142k. + +See [CHANGELOG.md](./CHANGELOG.md) for the details. + Now you will be able to use origami like any other Cairo package! ### 🏗️ Join Our Contributors From ae20dda27f6ca027cfdd4c35f106bce116f9d830 Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Sun, 27 Sep 2026 21:20:56 +0000 Subject: [PATCH 5/7] ci: release workflow with tag check, packages, GitHub release and guarded publish On a v* tag: check the tag against the six crate versions, scarb package them and create the GitHub release (gh, default GITHUB_TOKEN, generated notes). The publish job runs only when SCARB_REGISTRY_AUTH_TOKEN is set (env indirection) and skips the versions already on scarbs.xyz. .gitignore: .scratch, .p1, REPORT.md. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yaml | 82 +++++++++++++++++++++++++--------- .gitignore | 3 ++ 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cc7ed1c..11d578c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,29 +5,71 @@ on: tags: - "v*" +env: + SCARB_VERSION: 2.19.4 + CRATES: origami_defi origami_hexmap origami_map origami_random origami_rating origami_security + jobs: - create-release: + release: runs-on: ubuntu-latest - + name: Package and create the GitHub release + permissions: + contents: write steps: - - name: Extract version from tag - id: tag-version + - uses: actions/checkout@v4 + - uses: software-mansion/setup-scarb@v1 + with: + scarb-version: ${{ env.SCARB_VERSION }} + - name: Check the tag against the crate versions run: | - echo "::set-output name=version::${GITHUB_REF#refs/tags/v}" + tag="${GITHUB_REF_NAME#v}" + metadata=$(scarb metadata --format-version 1 --no-deps) + for crate in $CRATES; do + version=$(jq -r --arg name "$crate" '.packages[] | select(.name == $name) | .version' <<< "$metadata") + if [ "$version" != "$tag" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match $crate version '$version'" + exit 1 + fi + done + shell: bash + - name: Package + run: scarb package $(printf -- '-p %s ' $CRATES) + shell: bash + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME" target/package/*.tar.zst + shell: bash - - name: Create GitHub Release + publish: + needs: release + runs-on: ubuntu-latest + name: Publish to scarbs.xyz + env: + # A secret cannot be tested in `if`: expose it as an env variable and test that instead. + # Without the secret every step is skipped and the job is a no-op. + SCARB_REGISTRY_AUTH_TOKEN: ${{ secrets.SCARB_REGISTRY_AUTH_TOKEN }} + steps: + - name: Registry token missing + if: ${{ env.SCARB_REGISTRY_AUTH_TOKEN == '' }} + run: echo "::notice::SCARB_REGISTRY_AUTH_TOKEN is not set, nothing published" + - uses: actions/checkout@v4 + if: ${{ env.SCARB_REGISTRY_AUTH_TOKEN != '' }} + - uses: software-mansion/setup-scarb@v1 + if: ${{ env.SCARB_REGISTRY_AUTH_TOKEN != '' }} + with: + scarb-version: ${{ env.SCARB_VERSION }} + - name: Publish the versions not yet on the registry + if: ${{ env.SCARB_REGISTRY_AUTH_TOKEN != '' }} run: | - curl -s \ - -X POST \ - -H "Authorization: token $GIT_TOKEN" \ - -H "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/${{ github.repository }}/releases \ - -d '{ - "tag_name": "v${{ steps.tag-version.outputs.version }}", - "name": "Release v${{ steps.tag-version.outputs.version }}", - "body": "Release of version ${{ steps.tag-version.outputs.version }}", - "draft": false, - "prerelease": false - }' - env: - GIT_TOKEN: ${{ secrets.GIT_TOKEN }} + version="${GITHUB_REF_NAME#v}" + for crate in $CRATES; do + # Index path of the registry: `{first two}/{next two}/{name}.json` + index="https://scarbs.xyz/api/v1/index/${crate:0:2}/${crate:2:2}/${crate}.json" + if curl -sf "$index" | jq -e --arg v "$version" 'any(.[]; .v == $v)' > /dev/null; then + echo "$crate $version is already on scarbs.xyz, skipped" + else + scarb publish -p "$crate" + fi + done + shell: bash diff --git a/.gitignore b/.gitignore index af8a33c..8e4bc68 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ target abis manifests .snfoundry_cache +.scratch +.p1 +REPORT.md From 174e5e20813d7655bb6b9883adc72b87a832cc8f Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Sun, 27 Sep 2026 21:30:15 +0000 Subject: [PATCH 6/7] fix: gate hexmap test modules on cfg(test) so dependents' tests compile scarb also sets cfg(target: "test") when a dependent package runs its tests: with the files kept out of the package, a consumer's scarb test failed on the missing hexmap test modules and map printer. hexmap declares them under cfg(test) (this package's own tests only); origami_map keeps its printer in the package (no change to its sources). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- crates/hexmap/.scarbignore | 5 +++-- crates/hexmap/GAS.md | 6 ++++-- crates/hexmap/src/lib.cairo | 4 ++-- crates/map/.scarbignore | 5 ++--- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9cc7d..36a1aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ First release published on the [scarbs.xyz](https://scarbs.xyz) registry: `origa - Registry metadata on every crate (description, license, repository, homepage, documentation, readme, keywords); versions and license inherited from the workspace and resolved in the packages. -- Test-only files (integration tests, `origami_hexmap` benchmarks and `GAS.md`, the printers) - are kept out of the packages with `.scarbignore`. +- Test-only files (integration tests; `origami_hexmap` benchmarks, printer and `GAS.md`) are kept + out of the packages with `.scarbignore`. - Release workflow: on a `v*` tag, checks the tag against the crate versions, packages the six crates and creates the GitHub release; publishes to scarbs.xyz the versions not yet there once the `SCARB_REGISTRY_AUTH_TOKEN` secret is set. diff --git a/crates/hexmap/.scarbignore b/crates/hexmap/.scarbignore index 5492aa9..7fbae22 100644 --- a/crates/hexmap/.scarbignore +++ b/crates/hexmap/.scarbignore @@ -1,8 +1,9 @@ # Kept out of the published package (`scarb package`), still in the repository. # Integration tests (README examples). /tests/ -# Test-only modules: declared under `#[cfg(target: "test")]` in `src/lib.cairo`, so the -# verification build and dependents compile without them. +# Test-only modules: declared under `#[cfg(test)]` in `src/lib.cairo`, set for this package's own +# tests only (not `#[cfg(target: "test")]`, which scarb also sets when a dependent runs its +# tests), so the verification build and dependents compile without them. /src/tests/ /src/helpers/printer.cairo # Measurements, linked from the README on GitHub. diff --git a/crates/hexmap/GAS.md b/crates/hexmap/GAS.md index a5f171b..cd48639 100644 --- a/crates/hexmap/GAS.md +++ b/crates/hexmap/GAS.md @@ -1857,5 +1857,7 @@ Measured with scarb 2.19.4, snforge 0.61.0 (sierra gas), `snforge test -p origam | `bench_map_variant_keep_component_caver_7x7` | 95_333 | 177_000 | 101_000 | | `bench_map_variant_keep_component_caver_maze` | 1_111_093 | 1_306_000 | 1_167_000 | -* **Package.** `.scarbignore` keeps `src/tests/`, `src/helpers/printer.cairo` (test-only modules, - declared under `#[cfg(target: "test")]`) and this file out of the published package. +* **Package.** `.scarbignore` keeps `tests/`, `src/tests/`, `src/helpers/printer.cairo` and this + file out of the published package (943.98 KiB -> 400.42 KiB). The test-only modules are declared + under `#[cfg(test)]`, set for this package's own tests only: `#[cfg(target: "test")]` is also set + when a dependent runs its tests, which then looked for the missing files. diff --git a/crates/hexmap/src/lib.cairo b/crates/hexmap/src/lib.cairo index 526353e..4995b0c 100644 --- a/crates/hexmap/src/lib.cairo +++ b/crates/hexmap/src/lib.cairo @@ -27,12 +27,12 @@ pub mod helpers { pub mod geometry; pub mod layout; - #[cfg(target: "test")] + #[cfg(test)] pub mod printer; pub mod rng; } -#[cfg(target: "test")] +#[cfg(test)] pub mod tests { pub mod bench_bfs; pub mod bench_caver; diff --git a/crates/map/.scarbignore b/crates/map/.scarbignore index 36e599b..d6f2d50 100644 --- a/crates/map/.scarbignore +++ b/crates/map/.scarbignore @@ -1,6 +1,5 @@ # Kept out of the published package (`scarb package`), still in the repository. # Integration tests (README examples). /tests/ -# Test-only module: declared under `#[cfg(target: "test")]` in `src/lib.cairo`, so the -# verification build and dependents compile without it. -/src/helpers/printer.cairo +# `src/helpers/printer.cairo` stays: it is declared under `#[cfg(target: "test")]`, which scarb +# also sets when a dependent runs its tests, so the file must ship. From ed29f003c031923453d271b4237b0c6d90417115 Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Sun, 27 Sep 2026 22:00:50 +0000 Subject: [PATCH 7/7] fix(hexmap): bound-check neighbor, is_walkable and hex_distance (audit A4) neighbor returns None and is_walkable false for a position at or above W * H; hex_distance panics with 'Asserter: position not inside'. The test is a bounded_int product, difference and sign, for any u8 dimensions since HexMapTrait::new stays unchecked (now documented, with the list of validating functions). distance_to doc: walls block. Per call: neighbor 6_391 -> 6_959, is_walkable 6_550 -> 7_073, hex_distance 9_462 -> 10_393; u16, u8, row and single-product variants measured dearer and larger in Sierra (GAS.md, F1). CHANGELOG dated 2026-09-27. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- crates/hexmap/GAS.md | 30 ++- crates/hexmap/README.md | 26 ++- crates/hexmap/src/map.cairo | 133 ++++++++++- crates/hexmap/src/tests/bench_map.cairo | 292 +++++++++++++++++++++++- 5 files changed, 458 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a1aa3..999a524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the Origami crates. The six crates share one version. -## [1.8.0] - unreleased +## [1.8.0] - 2026-09-27 First release published on the [scarbs.xyz](https://scarbs.xyz) registry: `origami_defi`, `origami_hexmap`, `origami_map`, `origami_random`, `origami_rating`, `origami_security`. diff --git a/crates/hexmap/GAS.md b/crates/hexmap/GAS.md index cd48639..147ed61 100644 --- a/crates/hexmap/GAS.md +++ b/crates/hexmap/GAS.md @@ -1215,7 +1215,8 @@ inputs are folded by the compiler); per call = `(test - bench_map_loop_baseline) | `is_walkable` | 797_080 | 849_980 | -52_900 | Per call: `hex_distance` 9_461, `neighbor` 6_391, `is_walkable` 6_549 (the direct loop -re-converts the constant fixture: 7_078). +re-converts the constant fixture: 7_078). These three queries now check their positions against +`W * H` (audit A4): see the F1 section for the current figures and budgets. ### `keep_component`: `Bfs::reachable` (library) vs `Caver::keep_component` @@ -1857,6 +1858,33 @@ Measured with scarb 2.19.4, snforge 0.61.0 (sierra gas), `snforge test -p origam | `bench_map_variant_keep_component_caver_7x7` | 95_333 | 177_000 | 101_000 | | `bench_map_variant_keep_component_caver_maze` | 1_111_093 | 1_306_000 | 1_167_000 | +* **Audit A4: bound checks.** `neighbor` returns `None` and `is_walkable` `false` for a position + at or above `W * H`, and `hex_distance` panics (`Asserter: position not inside`). Formulations of + `position < W * H` for any `u8` dimensions (`HexMapTrait::new` is unchecked), same 100-call loops + as L8, per call = `(test - 142_090) / 100`: + +| Function | Formulation | Test | Measured | Budget | Per call | +|---|---|---|---:|---:|---:| +| `neighbor` | none (before) | `bench_map_direct_neighbor` | 781_230 | 821_000 | 6_391 | +| `neighbor` | **`bounded_int` product, difference and sign (library)** | `bench_map_neighbor` | 837_960 | 880_000 | 6_959 | +| `neighbor` | row test `y >= H` after the division of `LayoutTrait::neighbor` | `bench_map_variant_neighbor_row` | 847_560 | 890_000 | 7_055 | +| `neighbor` | `u16` product and comparison | `bench_map_variant_neighbor_u16` | 857_460 | 901_000 | 7_154 | +| `neighbor` | `u8` product (panics above 255 tiles) | `bench_map_variant_neighbor_u8` | 857_460 | 901_000 | 7_154 | +| `is_walkable` | none (before) | L8 `bench_map_is_walkable` | 797_080 | 837_000 | 6_550 | +| `is_walkable` | **`bounded_int` (library)** | `bench_map_is_walkable` | 849_410 | 892_000 | 7_073 | +| `is_walkable` | `u16` | `bench_map_variant_is_walkable_u16` | 863_410 | 907_000 | 7_213 | +| `is_walkable` | `u8` | `bench_map_variant_is_walkable_u8` | 863_410 | 907_000 | 7_213 | +| `hex_distance` | none (before) | `bench_map_direct_hex_distance` | 1_088_280 | 1_143_000 | 9_462 | +| `hex_distance` | **`bounded_int`, one test per position (library)** | `bench_map_hex_distance` | 1_181_340 | 1_241_000 | 10_393 | +| `hex_distance` | `u16`, one product for both | `bench_map_variant_hex_distance_u16` | 1_220_940 | 1_282_000 | 10_789 | +| `hex_distance` | `Asserter::assert_inside` of the larger position | `bench_map_variant_hex_distance_max` | 1_241_730 | 1_304_000 | 10_996 | +| `hex_distance` | `bounded_int`, one product for both | `bench_map_variant_hex_distance_bounded_once` | 1_280_340 | 1_345_000 | 11_383 | +| `hex_distance` | row tests `y < H` after the divisions of the distance | `bench_map_variant_hex_distance_rows` | 1_375_530 | 1_445_000 | 12_334 | + + The `bounded_int` form is 1.1 % to 2.3 % cheaper than the runners-up, under the 5 % rule, so the + code size decides: in a contract with the three queries it is also the smallest, 903 Sierra / + 1_704 CASM felts against 918 / 1_772 for `u16` (861 / 1_614 unchecked). The non-inlined helper + variants (`rows`, `bounded_once`) lose to the inlined forms. * **Package.** `.scarbignore` keeps `tests/`, `src/tests/`, `src/helpers/printer.cairo` and this file out of the published package (943.98 KiB -> 400.42 KiB). The test-only modules are declared under `#[cfg(test)]`, set for this package's own tests only: `#[cfg(target: "test")]` is also set diff --git a/crates/hexmap/README.md b/crates/hexmap/README.md index 583810e..dcfed62 100644 --- a/crates/hexmap/README.md +++ b/crates/hexmap/README.md @@ -64,8 +64,13 @@ This is `HexPrinter`'s layout: top row first, `x = 0` on the right, even rows in (not a corner) and dig inward. A path may start or end on an open edge tile but never crosses one; `reachable`, `range`, `ring` and `field_of_movement` include the open edge tiles on which a path can end. `compute_distribution` treats them as any walkable tile. -- `HexMapTrait::new` accepts any grid, but the finders and generators assume the border ring is - wall except for such entrances. +- `HexMapTrait::new` is the raw constructor and checks nothing, like `origami_map`'s: the caller + is responsible for valid dimensions and for a grid without bits at or above `W * H`, with the + border ring wall except for such entrances. The dimensions and positions are validated by + `open_with_corridor`, `open_with_maze`, `compute_distribution`, `search_path`, + `search_path_weighted`, `field_of_movement`, `distance_to`, `reachable`, `range`, `ring` and + `keep_component` (they panic); `hex_distance`, `neighbor` and `is_walkable` check their + positions against `W * H` only. ### Limits @@ -90,7 +95,7 @@ The names follow the Rust crate [`hexx`](https://docs.rs/hexx) where they apply ### Create a map ```rust -// From an existing grid (no check) +// From an existing grid, unchecked (see Border ring and entrances) let map = HexMapTrait::new(grid, 17, 14, seed); // Every interior tile walkable let map = HexMapTrait::new_empty(17, 14, seed); @@ -131,7 +136,7 @@ let objects: felt252 = map.compute_distribution(10, seed); ```rust // Shortest path, from the target (included) to the start (excluded), empty if unreachable let path: Span = map.search_path(8, 202); -// Number of steps only, `None` if unreachable +// Number of steps of the shortest path (walls block), `None` if unreachable let steps: Option = map.distance_to(8, 202); // Distance on an empty board, walls ignored let d: u8 = map.hex_distance(8, 202); @@ -166,7 +171,7 @@ let open: bool = map.is_walkable(113); | Message | When | |---|---| | `Asserter: invalid dimension` | `W < 3`, `H < 3` or `W * H > 251` (constructors, digger, finders, spreader); `new_hexagon` with a radius above 6 | -| `Asserter: position not inside` | a finder endpoint or a `ring` / `range` centre outside the board | +| `Asserter: position not inside` | a finder endpoint, a `ring` / `range` centre or a `hex_distance` position outside the board (`position >= W * H`) | | `Asserter: position not an edge` | `open_with_*` from a tile that is not on the edge | | `Asserter: position is a corner` | `open_with_*` from a corner | | `Mazer: order > 1 not supported` | `new_maze` or `open_with_*` with an order above 1 | @@ -177,8 +182,9 @@ let open: bool = map.is_walkable(113); | `Spreader: invalid grid` | `compute_distribution` on a grid with a walkable bit outside the board | `search_path` returns an empty path when the target is unreachable and when `from == to`; -`distance_to` returns `None` and `Some(0)` respectively. `hex_distance`, `neighbor` and -`is_walkable` do not check their positions. +`distance_to` returns `None` and `Some(0)` respectively. Outside the board +(`position >= W * H`), `neighbor` returns `None` and `is_walkable` returns `false`; `hex_distance` +panics, since a distance has no neutral value. **Endpoints on walls panic.** This differs from `origami_map`, whose `search_path` returns an empty path when the start or the target is a wall. In `origami_hexmap` an empty path only means @@ -228,9 +234,9 @@ not in the package). | `range` | 17x14 cave, radius 4 | 104k | | `ring` | 17x14 cave, radius 4 | 100k | | `ring` | 7x7 cave, radius 2 | 46k | -| `hex_distance` | per call | 9.5k | -| `neighbor` | per call | 6.4k | -| `is_walkable` | per call | 6.5k | +| `hex_distance` | per call, both positions checked | 10.4k | +| `neighbor` | per call, position checked | 7.0k | +| `is_walkable` | per call, position checked | 7.1k | Rules of thumb: a BFS layer costs ~19k on a two-limb board and ~10k on a single-limb board, a backtracking step ~8k; a weighted time step ~37k with 2 cost classes. diff --git a/crates/hexmap/src/map.cairo b/crates/hexmap/src/map.cairo index da566f1..e2ae7c7 100644 --- a/crates/hexmap/src/map.cairo +++ b/crates/hexmap/src/map.cairo @@ -3,6 +3,13 @@ //! The facade mirrors `origami_map::map::MapTrait` name for name, plus the hex additions. Every //! method forwards to one library call (see `tests/bench_map.cairo` for the facade overhead). +// Core imports + +#[feature("bounded-int-utils")] +use core::internal::bounded_int::{ + BoundedInt, ConstrainHelper, MulHelper, SubHelper, constrain, mul, sub, +}; + // Internal imports use origami_hexmap::finders::bfs::{Bfs, BfsInternal}; @@ -12,7 +19,7 @@ use origami_hexmap::generators::digger::Digger; use origami_hexmap::generators::mazer::Mazer; use origami_hexmap::generators::spreader::Spreader; use origami_hexmap::generators::walker::Walker; -use origami_hexmap::helpers::asserter::Asserter; +use origami_hexmap::helpers::asserter::{Asserter, errors}; use origami_hexmap::helpers::bits::Bits; use origami_hexmap::helpers::geometry::Geometry; use origami_hexmap::helpers::layout::{DilationTrait, LayoutTrait}; @@ -23,6 +30,35 @@ use origami_hexmap::types::direction::Direction; /// Largest board of the single-limb path, as in `finders::bfs`. const SMALL_SIZE: u8 = 128; +/// `W * H` for any `u8` dimensions. +impl SizeMul of MulHelper { + type Result = BoundedInt<0, 65025>; +} + +/// `position - W * H`. +impl PositionSub of SubHelper> { + type Result = BoundedInt<-65025, 255>; +} + +/// Sign of `position - W * H`. +impl PositionConstrain of ConstrainHelper, 0> { + type LowT = BoundedInt<-65025, -1>; + type HighT = BoundedInt<0, 255>; +} + +/// Whether a position lies in the board, `position < W * H`, for any `u8` dimensions: a +/// `bounded_int` product, difference and sign, cheaper than a `u16` product and comparison (see +/// `GAS.md`, F1). +#[feature("bounded-int-utils")] +#[inline(always)] +fn is_inside(width: u8, height: u8, position: u8) -> bool { + let size = mul::<_, _, SizeMul>(width, height); + match constrain::<_, 0, PositionConstrain>(sub::<_, _, PositionSub>(position, size)) { + Ok(_) => true, + Err(_) => false, + } +} + /// Types. #[derive(Copy, Drop, Serde)] pub struct HexMap { @@ -35,7 +71,14 @@ pub struct HexMap { /// Implementation of the `HexMapTrait` trait for the `HexMap` struct. #[generate_trait] pub impl HexMapImpl of HexMapTrait { - /// Create a map. + /// Create a map from an existing grid, unchecked (the raw constructor, as in `origami_map`). + /// The caller is responsible for valid dimensions (`W, H >= 3`, `W * H <= 251`) and for a grid + /// without bits at or above `W * H`; the border ring is expected to be wall except for + /// entrances. The dimensions are validated later by the functions that take them in charge: + /// `open_with_corridor`, `open_with_maze`, `compute_distribution`, `search_path`, + /// `search_path_weighted`, `field_of_movement`, `distance_to`, `reachable`, `range`, `ring` and + /// `keep_component` (they panic on invalid dimensions or positions); `hex_distance`, + /// `neighbor` and `is_walkable` only check their positions against `W * H`. /// # Arguments /// * `grid` - The grid of the map, `1` is walkable /// * `width` - The width of the map @@ -233,7 +276,8 @@ pub impl HexMapImpl of HexMapTrait { Dial::field_of_movement(self.grid, self.width, self.height, from, budget, costs) } - /// Length of the shortest path between two tiles, walls included. + /// Length of the shortest path between two tiles through walkable tiles: walls block, as in + /// `search_path`. /// # Arguments /// * `self` - The map /// * `from` - The starting position @@ -254,9 +298,18 @@ pub impl HexMapImpl of HexMapTrait { /// * `to` - The second position /// # Returns /// * The distance + /// # Panics + /// * If a position is outside the board (`position >= W * H`) #[inline] fn hex_distance(self: HexMap, from: u8, to: u8) -> u8 { - Geometry::distance(self.width, from, to) + // [Check] Positions, a distance has no neutral value + let (width, height) = (self.width, self.height); + assert( + is_inside(width, height, from) && is_inside(width, height, to), + errors::ASSERTER_POSITION_NOT_INSIDE, + ); + // [Return] Distance + Geometry::distance(width, from, to) } /// Every tile reachable from a position. @@ -360,27 +413,31 @@ pub impl HexMapImpl of HexMapTrait { ring + Bits::to_felt(Bits::and(near, edges)) - Bits::to_felt(Bits::and(far, edges)) } - /// Neighbour of a position, `None` outside the board. + /// Neighbour of a position, `None` if the neighbour or the position is outside the board. /// # Arguments /// * `self` - The map /// * `position` - The position /// * `direction` - The direction /// # Returns - /// * The neighbour position + /// * The neighbour position, `None` if `position >= W * H` or the neighbour is off the board #[inline] fn neighbor(self: HexMap, position: u8, direction: Direction) -> Option { - LayoutTrait::neighbor(self.width, self.height, position, direction) + let (width, height) = (self.width, self.height); + if !is_inside(width, height, position) { + return None; + } + LayoutTrait::neighbor(width, height, position, direction) } /// Whether a position is walkable. /// # Arguments /// * `self` - The map - /// * `position` - The position, at most 251 + /// * `position` - The position /// # Returns - /// * `true` if the tile is walkable + /// * `true` if the tile is walkable, `false` if it is a wall or `position >= W * H` #[inline] fn is_walkable(self: HexMap, position: u8) -> bool { - Bits::get(self.grid.into(), position) + is_inside(self.width, self.height, position) && Bits::get(self.grid.into(), position) } } @@ -589,6 +646,62 @@ mod tests { assert!(!map.is_walkable(237)); } + #[test] + fn test_map_neighbor_outside() { + // Audit A4: position 9 is outside a 3x3 board (0..=8), `LayoutTrait::neighbor` alone + // returns `Some(10)` West + let map = HexMapTrait::new_empty(3, 3, 0); + assert!(map.neighbor(9, Direction::West).is_none()); + let directions = array![ + Direction::East, Direction::NorthEast, Direction::NorthWest, Direction::West, + Direction::SouthWest, Direction::SouthEast, + ] + .span(); + for direction in directions { + assert!(map.neighbor(9, *direction).is_none()); + assert!(map.neighbor(255, *direction).is_none()); + } + // The last tile of the board still has neighbours + assert!(map.neighbor(8, Direction::SouthEast) == Some(4)); + assert!(map.neighbor(8, Direction::West).is_none()); + } + + #[test] + fn test_map_is_walkable_outside() { + // Audit A4: an unchecked grid with bit 9 set on a 3x3 board + let map = HexMapTrait::new(0x200, 3, 3, 0); + assert!(!map.is_walkable(9)); + assert!(!map.is_walkable(255)); + let map = HexMapTrait::new(0x1ff, 3, 3, 0); + assert!(map.is_walkable(8)); + // Dimensions beyond `u8` products: every `u8` position is inside, no overflow + let map = HexMapTrait::new(0x1, 255, 255, 0); + assert!(map.is_walkable(0)); + assert!(!map.is_walkable(255)); + } + + #[test] + fn test_map_distance_to_walls_block() { + // Audit A4: 5x3, walkable 6 and 8, wall 7 between them: no path, walls are not crossed + let map = HexMapTrait::new(0x140, 5, 3, 0); + assert!(map.distance_to(6, 8).is_none()); + assert!(map.search_path(6, 8).len() == 0); + // The grid distance ignores the wall + assert!(map.hex_distance(6, 8) == 2); + } + + #[test] + #[should_panic(expected: 'Asserter: position not inside')] + fn test_map_hex_distance_revert_from_outside() { + HexMapTrait::new_empty(3, 3, 0).hex_distance(9, 0); + } + + #[test] + #[should_panic(expected: 'Asserter: position not inside')] + fn test_map_hex_distance_revert_to_outside() { + HexMapTrait::new_empty(3, 3, 0).hex_distance(4, 9); + } + #[test] fn test_map_scenario_17x14() { // Cave, component of 113, corridor from 8, 10 objects, path from 8 to 202: diff --git a/crates/hexmap/src/tests/bench_map.cairo b/crates/hexmap/src/tests/bench_map.cairo index 7f05511..4c4c346 100644 --- a/crates/hexmap/src/tests/bench_map.cairo +++ b/crates/hexmap/src/tests/bench_map.cairo @@ -8,6 +8,13 @@ //! `compute_distribution(10)` + `search_path`, one test per prefix: the cost of a step is the //! difference between two consecutive prefixes. +// Core imports + +#[feature("bounded-int-utils")] +use core::internal::bounded_int::{ + BoundedInt, ConstrainHelper, MulHelper, SubHelper, constrain, mul, sub, +}; + // Internal imports use origami_hexmap::finders::bfs::Bfs; @@ -358,7 +365,7 @@ fn bench_map_loop_baseline() { } #[test] -#[available_gas(l2_gas: 1143000)] +#[available_gas(l2_gas: 1241000)] fn bench_map_hex_distance() { let map = cave(); let mut acc: felt252 = 0; @@ -383,7 +390,7 @@ fn bench_map_direct_hex_distance() { } #[test] -#[available_gas(l2_gas: 821000)] +#[available_gas(l2_gas: 880000)] fn bench_map_neighbor() { let map = cave(); let mut acc: felt252 = 0; @@ -412,7 +419,7 @@ fn bench_map_direct_neighbor() { } #[test] -#[available_gas(l2_gas: 837000)] +#[available_gas(l2_gas: 892000)] fn bench_map_is_walkable() { let map = cave(); let mut acc: felt252 = 0; @@ -525,3 +532,282 @@ fn bench_map_scenario_7x7_5_total() { let path = map.search_path(SMALL_ENTRANCE, SMALL_TARGET); assert!(path.len() != 0); } + +// Audit A4: bound checks of `neighbor`, `is_walkable` and `hex_distance`. The library uses the +// `bounded_int` form of `map.cairo` (`bench_map_neighbor`, `bench_map_is_walkable`, +// `bench_map_hex_distance`); the losers below are test-only, see `GAS.md`, F1. + +/// `W * H` as a bounded integer. +impl SizeMul of MulHelper { + type Result = BoundedInt<0, 65025>; +} + +/// `position - W * H`. +impl PositionSub of SubHelper> { + type Result = BoundedInt<-65025, 255>; +} + +/// Sign of `position - W * H`. +impl PositionConstrain of ConstrainHelper, 0> { + type LowT = BoundedInt<-65025, -1>; + type HighT = BoundedInt<0, 255>; +} + +/// Inside test: `u16` product and comparison. +#[inline(always)] +fn inside_u16(width: u8, height: u8, position: u8) -> bool { + let size: u16 = width.into() * height.into(); + position.into() < size +} + +/// Inside test: `u8` product (panics on dimensions above 255 tiles). +#[inline(always)] +fn inside_u8(width: u8, height: u8, position: u8) -> bool { + position < width * height +} + +/// `LayoutTrait::neighbor` with the row test `y >= H` after its division. +fn neighbor_row(width: u8, height: u8, position: u8, direction: Direction) -> Option { + let (y, x) = DivRem::div_rem(position, width.try_into().unwrap()); + if y >= height { + return None; + } + let odd = y % 2 == 1; + match direction { + Direction::East => if x == 0 { + None + } else { + Some(position - 1) + }, + Direction::West => if x == width - 1 { + None + } else { + Some(position + 1) + }, + Direction::NorthEast => if y == height - 1 { + None + } else if odd { + Some(position + width) + } else if x == 0 { + None + } else { + Some(position + width - 1) + }, + Direction::NorthWest => if y == height - 1 { + None + } else if !odd { + Some(position + width) + } else if x == width - 1 { + None + } else { + Some(position + width + 1) + }, + Direction::SouthEast => if y == 0 { + None + } else if odd { + Some(position - width) + } else if x == 0 { + None + } else { + Some(position - width - 1) + }, + Direction::SouthWest => if y == 0 { + None + } else if !odd { + Some(position - width) + } else if x == width - 1 { + None + } else { + Some(position + 1 - width) + }, + } +} + +#[test] +#[available_gas(l2_gas: 901000)] +fn bench_map_variant_neighbor_u16() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + let position = n + 20; + if !inside_u16(map.width, map.height, position) { + continue; + } + if let Some(next) = + LayoutTrait::neighbor(map.width, map.height, position, Direction::NorthEast) { + acc += next.into(); + } + } + assert!(acc != 0); +} + +#[test] +#[available_gas(l2_gas: 901000)] +fn bench_map_variant_neighbor_u8() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + let position = n + 20; + if !inside_u8(map.width, map.height, position) { + continue; + } + if let Some(next) = + LayoutTrait::neighbor(map.width, map.height, position, Direction::NorthEast) { + acc += next.into(); + } + } + assert!(acc != 0); +} + +#[test] +#[available_gas(l2_gas: 890000)] +fn bench_map_variant_neighbor_row() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + if let Some(next) = neighbor_row(map.width, map.height, n + 20, Direction::NorthEast) { + acc += next.into(); + } + } + assert!(acc != 0); +} + +#[test] +#[available_gas(l2_gas: 907000)] +fn bench_map_variant_is_walkable_u16() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + let position = n + n; + if inside_u16(map.width, map.height, position) && Bits::get(map.grid.into(), position) { + acc += 1; + } + } + assert!(acc != 0); +} + +#[test] +#[available_gas(l2_gas: 907000)] +fn bench_map_variant_is_walkable_u8() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + let position = n + n; + if inside_u8(map.width, map.height, position) && Bits::get(map.grid.into(), position) { + acc += 1; + } + } + assert!(acc != 0); +} + +#[test] +#[available_gas(l2_gas: 1282000)] +fn bench_map_variant_hex_distance_u16() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + let (from, to) = (n, 237 - n); + let size: u16 = map.width.into() * map.height.into(); + assert(from.into() < size && to.into() < size, 'Asserter: position not inside'); + acc += Geometry::distance(map.width, from, to).into(); + } + assert!(acc != 0); +} + +/// `Geometry::distance` with the row tests `y < H` after its divisions. +fn distance_rows(width: u8, height: u8, from: u8, to: u8) -> u8 { + let (x_from, y_from) = LayoutTrait::coords(width, from); + let (x_to, y_to) = LayoutTrait::coords(width, to); + assert(y_from < height && y_to < height, 'Asserter: position not inside'); + let lhs = x_to + y_from / 2; + let rhs = x_from + y_to / 2; + let (dq, dq_negative) = if lhs >= rhs { + (lhs - rhs, false) + } else { + (rhs - lhs, true) + }; + let (dr, dr_negative) = if y_to >= y_from { + (y_to - y_from, false) + } else { + (y_from - y_to, true) + }; + if dq_negative == dr_negative { + dq + dr + } else if dq > dr { + dq + } else { + dr + } +} + +#[test] +#[available_gas(l2_gas: 1445000)] +fn bench_map_variant_hex_distance_rows() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + acc += distance_rows(map.width, map.height, n, 237 - n).into(); + } + assert!(acc != 0); +} + +#[test] +#[available_gas(l2_gas: 1345000)] +#[feature("bounded-int-utils")] +fn bench_map_variant_hex_distance_bounded_once() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + let (from, to) = (n, 237 - n); + let size = mul::<_, _, SizeMul>(map.width, map.height); + let from_ok = + match constrain::<_, 0, PositionConstrain>(sub::<_, _, PositionSub>(from, size)) { + Ok(_) => true, + Err(_) => false, + }; + let to_ok = match constrain::<_, 0, PositionConstrain>(sub::<_, _, PositionSub>(to, size)) { + Ok(_) => true, + Err(_) => false, + }; + assert(from_ok && to_ok, 'Asserter: position not inside'); + acc += Geometry::distance(map.width, from, to).into(); + } + assert!(acc != 0); +} + +#[test] +#[available_gas(l2_gas: 1304000)] +fn bench_map_variant_hex_distance_max() { + let map = cave(); + let mut acc: felt252 = 0; + let mut n = REPS; + while n != 0 { + n -= 1; + let (from, to) = (n, 237 - n); + // One comparison: the larger endpoint + let top = if from > to { + from + } else { + to + }; + Asserter::assert_inside(map.width, map.height, top); + acc += Geometry::distance(map.width, from, to).into(); + } + assert!(acc != 0); +}