Merge pull request 'docs(core): record unlimited stock recycling as an intentional rules decision' (#122) from docs/recycle-rule-decision into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 6m32s
Web E2E / web-e2e (push) Successful in 5m1s

This commit was merged in pull request #122.
This commit is contained in:
2026-07-06 20:10:00 +00:00
3 changed files with 49 additions and 0 deletions
+12
View File
@@ -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.
+6
View File
@@ -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
+31
View File
@@ -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