From ef9d914b995c8b37604339f1d7d732c4e4401d3c Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 6 Jul 2026 13:03:22 -0700 Subject: [PATCH] docs(core): record unlimited stock recycling as an intentional rules decision Resolves the Draw-1 recycle question from the June 500-game audit: unlimited recycling with upstream score penalties is deliberate, matching mainstream digital solitaire rather than strict 3-pass tournament rules. The difficulty seed catalog and the winnable-deal solver are verified under this rule, so a hard pass limit must not be introduced casually. - ARCHITECTURE.md: new 'Rules decisions' note in the solitaire_core section - GameState::draw() doc comment points at the decision record - New lock-in test draw_one_recycling_is_unlimited_by_design asserts 10+ recycles are never rejected Closes #117 Co-Authored-By: Claude Fable 5 --- ARCHITECTURE.md | 12 ++++++++++++ CHANGELOG.md | 6 ++++++ solitaire_core/src/game_state.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8f8b3b5..22b6989 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -109,6 +109,18 @@ Owns: - Achievement unlock condition evaluation - Seeded RNG for reproducible deals +**Rules decisions:** +- **Stock recycling is unlimited in every draw mode — by design.** Extra + passes through the stock are discouraged via the upstream score penalty + (applied by the `card_game`/`klondike` session), never blocked with a + `MoveError`. This matches mainstream digital solitaire (unlimited redeals + in Draw-1) rather than strict tournament rules (3-pass cap). It is load- + bearing: the difficulty seed catalog and the winnable-deal solver are + verified under unlimited recycling, so introducing a hard pass limit would + invalidate both. Locked in by the + `draw_one_recycling_is_unlimited_by_design` test in + `solitaire_core/src/game_state.rs`. (Decision record: Gitea issue #117.) + ### `solitaire_sync` **Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 672b5b1..de35860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ project follows [Semantic Versioning](https://semver.org/). ### Added +- **Rules decision record: unlimited stock recycling.** Documented in + `ARCHITECTURE.md` that unlimited recycling with score penalties (matching + mainstream digital solitaire) is intentional, and locked it in with a core + test — the difficulty seed catalog and winnable-deal solver are verified + under this rule. Resolves the last open finding from the June 500-game + audit (issue #117). - **Analytics validation runbook.** Documented native Matomo live validation, expected event payloads, and the current web/WASM analytics split. - **Android smoke-test runbook.** Updated the Android doc with the current diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 13e39f3..99a6369 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -863,6 +863,13 @@ impl GameState { } /// Draw cards from stock to waste. When stock is empty, recycles waste back to stock. + /// + /// Recycling is deliberately unlimited in every draw mode: extra passes + /// are discouraged through the upstream score penalty, never rejected + /// with a [`MoveError`]. The difficulty seed catalog and the + /// winnable-deal solver are verified under this rule — see the + /// "Rules decisions" note in `ARCHITECTURE.md` (`solitaire_core` + /// section) before considering a hard pass limit. pub fn draw(&mut self) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); @@ -1222,6 +1229,30 @@ mod tests { ); } + #[test] + fn draw_one_recycling_is_unlimited_by_design() { + // Rules decision (Gitea #117): stock recycling is unlimited in every + // draw mode. Extra passes are discouraged via the upstream score + // penalty, never blocked with a MoveError. The difficulty seed + // catalog and the winnable-deal solver are verified under this rule, + // so a hard pass limit must not be introduced casually — see the + // "Rules decisions" note in ARCHITECTURE.md (`solitaire_core`). + let mut game = game_at_first_recycle().expect("could not reach recycle"); + // ~24 draws per Draw-1 pass; 2_000 draws is ample budget for 10 passes. + for _ in 0..2_000 { + if game.recycle_count() >= 10 { + break; + } + game.draw() + .expect("unlimited recycling: draw must never fail on pass count"); + } + assert!( + game.recycle_count() >= 10, + "expected at least 10 recycles within the draw budget, got {}", + game.recycle_count(), + ); + } + #[test] fn undo_applies_minus_15_penalty_via_upstream_score() { // A foundation move scores +10 upstream; undoing it nets the move score