Compare commits
156 Commits
534870a68a
...
v0.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2a021712 | |||
| 6037596cc0 | |||
| d7ffb16df5 | |||
| b57db017d3 | |||
| 0b3140ad6d | |||
| e41def8c89 | |||
| aad8bb9c83 | |||
| 55c235b55f | |||
| 21ec03b157 | |||
| 17e3112502 | |||
| de4751115f | |||
| 9ff48ace5b | |||
| 91b7605b9f | |||
| 42d90b199c | |||
| 3e11e9e79a | |||
| bfcd05fbb5 | |||
| c497c3193c | |||
| 9aa0dd23b1 | |||
| d065d49fe7 | |||
| c30b04ec72 | |||
| 40d6e0ab17 | |||
| 9fe650fa20 | |||
| b73d246b4c | |||
| ae40a1db7a | |||
| b7c3a4996f | |||
| d48b9489db | |||
| 08b006ff30 | |||
| 17e0737a10 | |||
| dd63261999 | |||
| 93660c2217 | |||
| 56e2e6f151 | |||
| cc635328be | |||
| a4bc063497 | |||
| 540869c851 | |||
| bdac754b26 | |||
| f863d85c35 | |||
| 3c7a0eb4fb | |||
| d489e7a31b | |||
| f2f30c8002 | |||
| a49a340a30 | |||
| 27cdf78ce0 | |||
| faa6c5efc4 | |||
| 487b99bbc9 | |||
| 53e3b816cf | |||
| 87275bf340 | |||
| 56647d7f0d | |||
| cbf2483028 | |||
| a54201e97b | |||
| 48e412177c | |||
| cd54ce1bb0 | |||
| 7a3032b74c | |||
| 89699a8a86 | |||
| 70165da103 | |||
| 8a5fa8751c | |||
| bf660df971 | |||
| 13a8a012ee | |||
| 02ababa65f | |||
| 9c36b49729 | |||
| 8e90574437 | |||
| 95fcdad5d2 | |||
| d948fa862a | |||
| 1fcd032b0a | |||
| 3081505a3d | |||
| 07b8ecd9b2 | |||
| 5bed43ef32 | |||
| 23c9704887 | |||
| 93182fa251 | |||
| 89c51ab712 | |||
| 3984231c9b | |||
| d9f36bf34a | |||
| 57d1c58fdf | |||
| 42535f5109 | |||
| d5e6f8026b | |||
| 271647265c | |||
| 3eabc149a8 | |||
| f1aeb24157 | |||
| 000143231b | |||
| 1a1047664b | |||
| ba527de351 | |||
| fe41b502ac | |||
| b37f0cbec7 | |||
| a0fc0d2605 | |||
| 7ed4f2cba9 | |||
| ddc8f27c82 | |||
| 13dd44bd1b | |||
| 17f9b518f1 | |||
| 61d891fb76 | |||
| 7dba772e67 | |||
| ca5788f714 | |||
| 9887343d8b | |||
| 525fe0fe76 | |||
| 69ce9afab9 | |||
| 13aa0fd833 | |||
| 9f095c4039 | |||
| d8c70341f4 | |||
| 063269c70e | |||
| b126df82b2 | |||
| 655dfde736 | |||
| f712b89fe4 | |||
| f6c916641a | |||
| 95df5421c9 | |||
| fdb6c2ecfe | |||
| 9a3d7f3876 | |||
| c4970b16ea | |||
| 2c72e1fc87 | |||
| efa063fb8f | |||
| 78cf30e906 | |||
| 9a9026e33a | |||
| ab1d098877 | |||
| 160637d1c8 | |||
| 43f13c615e | |||
| 924a1e2af7 | |||
| a6b8348332 | |||
| b98cb8a99f | |||
| 7b59e70192 | |||
| 7f477b4ad8 | |||
| ce38b26721 | |||
| 172d7773f0 | |||
| 205ad6f646 | |||
| 936d035750 | |||
| 13d1d013e9 | |||
| b8fb3fbd6e | |||
| e510e90b95 | |||
| 902560cd68 | |||
| 912b08c719 | |||
| 3ef4ecb747 | |||
| 4b9d008be2 | |||
| 74482252d1 | |||
| 6e7705b256 | |||
| 59316de1e9 | |||
| 1719fdada0 | |||
| 8dda9541a3 | |||
| 60a80369d4 | |||
| dbe6c60133 | |||
| 74597a8c84 | |||
| 5d57b67934 | |||
| 220e3f040c | |||
| 54d34972d4 | |||
| 0c86cac2d5 | |||
| 2e080d02ce | |||
| 73e210b243 | |||
| f866299021 | |||
| b78a493a0c | |||
| 51d3454344 | |||
| 12789529a1 | |||
| c1bde18a2c | |||
| fd7fb7b6da | |||
| 138436558f | |||
| 65d595ad12 | |||
| abeb4e5cdf | |||
| b082bd65a6 | |||
| de52c8a7b7 | |||
| dcfa976dad | |||
| 71999e1062 | |||
| 5f5aba8dff | |||
| 9bfca929cb |
@@ -1,4 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
|
/.sccache-cache
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT\n r.id AS \"id!: String\",\n u.username AS \"username!: String\",\n r.seed AS \"seed!: i64\",\n r.draw_mode AS \"draw_mode!: String\",\n r.mode AS \"mode!: String\",\n r.time_seconds AS \"time_seconds!: i64\",\n r.final_score AS \"final_score!: i64\",\n r.recorded_at AS \"recorded_at!: String\",\n r.received_at AS \"received_at!: String\"\n FROM replays r\n JOIN users u ON u.id = r.user_id\n ORDER BY r.received_at DESC\n LIMIT ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: String",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "seed!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "draw_mode!: String",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mode!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "time_seconds!: i64",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "final_score!: i64",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recorded_at!: String",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "received_at!: String",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT replay_json FROM replays WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "replay_json",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO replays (\n id, user_id, seed, draw_mode, mode, time_seconds, final_score,\n recorded_at, received_at, replay_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 10
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f"
|
||||||
|
}
|
||||||
@@ -47,11 +47,10 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
|
|||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
|
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
|
||||||
- **Pure core.** All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
|
|
||||||
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
|
|
||||||
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
|
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
|
||||||
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
|
||||||
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
|
|
||||||
|
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,8 +69,8 @@ solitaire_quest/
|
|||||||
│
|
│
|
||||||
├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
|
├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
|
||||||
│ ├── cards/
|
│ ├── cards/
|
||||||
│ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — xCards @2x artwork (LGPL-3.0)
|
│ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — rendered from hayeah/playing-cards-assets SVGs (MIT)
|
||||||
│ │ └── backs/back_0.png – back_4.png # back_0 = xCards bicycle_blue; back_1–4 are generated patterns
|
│ │ └── backs/back_0.png – back_4.png # back_0 = generated default back; back_1–4 are generated patterns
|
||||||
│ ├── backgrounds/bg_0.png – bg_4.png # generated textures
|
│ ├── backgrounds/bg_0.png – bg_4.png # generated textures
|
||||||
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
||||||
│ └── audio/
|
│ └── audio/
|
||||||
@@ -133,7 +132,7 @@ Owns:
|
|||||||
- `SyncProvider` trait — implemented by `SolitaireServerClient`
|
- `SyncProvider` trait — implemented by `SolitaireServerClient`
|
||||||
|
|
||||||
### `solitaire_engine`
|
### `solitaire_engine`
|
||||||
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
|
**Dependencies:** `bevy`, `kira`, `solitaire_core`, `solitaire_data`.
|
||||||
|
|
||||||
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
|
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
|
||||||
|
|
||||||
@@ -246,7 +245,7 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
|
|||||||
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||||
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||||
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||||
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
|
| `AudioPlugin` | — | Sound effect and music playback via kira |
|
||||||
| `InputPlugin` | — | Keyboard and mouse input routing |
|
| `InputPlugin` | — | Keyboard and mouse input routing |
|
||||||
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
||||||
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
||||||
@@ -716,11 +715,14 @@ pub struct AchievementDef {
|
|||||||
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
|
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
|
||||||
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
|
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
|
||||||
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
|
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
|
||||||
|
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
|
||||||
|
|
||||||
### Evaluation Timing
|
### Evaluation Timing
|
||||||
|
|
||||||
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
|
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
|
||||||
|
|
||||||
|
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Progression System
|
## 12. Progression System
|
||||||
@@ -754,7 +756,7 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
|
|||||||
|
|
||||||
## 13. Audio System
|
## 13. Audio System
|
||||||
|
|
||||||
Audio uses `bevy_kira_audio`. All sound files are `.wav`.
|
Audio uses `kira`. All sound files are `.wav`.
|
||||||
|
|
||||||
| File | Trigger |
|
| File | Trigger |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -765,7 +767,7 @@ Audio uses `bevy_kira_audio`. All sound files are `.wav`.
|
|||||||
| `win_fanfare.wav` | Game won |
|
| `win_fanfare.wav` | Game won |
|
||||||
| `ambient_loop.wav` | Looping background music |
|
| `ambient_loop.wav` | Looping background music |
|
||||||
|
|
||||||
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
|
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `kira` channel volumes.
|
||||||
|
|
||||||
Audio systems listen for Bevy events and never block the game thread.
|
Audio systems listen for Bevy events and never block the game thread.
|
||||||
|
|
||||||
@@ -1009,5 +1011,7 @@ Using `axum::test` and an in-memory SQLite database:
|
|||||||
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 |
|
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 |
|
||||||
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 |
|
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 |
|
||||||
| Dropped GPGS backend | Redundant with the self-hosted server; adds JNI complexity for no user-visible benefit on the target platforms | 2026-04-28 |
|
| Dropped GPGS backend | Redundant with the self-hosted server; adds JNI complexity for no user-visible benefit on the target platforms | 2026-04-28 |
|
||||||
| Card, background, and font assets loaded via `AssetServer` | Reverses the earlier embed-via-`include_bytes!()` decision: PNGs and TTFs are loaded at runtime so artwork can be swapped (e.g. xCards @2x faces, alternate card backs, themed backgrounds) without a recompile, and binary size stays small. Loaders take `Option<Res<AssetServer>>` and fall back gracefully under `MinimalPlugins`. The `assets/` directory must ship alongside the binary. | 2026-04-29 |
|
| Card, background, and font assets loaded via `AssetServer` | Reverses the earlier embed-via-`include_bytes!()` decision: PNGs and TTFs are loaded at runtime so artwork can be swapped (e.g. alternate card backs, themed backgrounds) without a recompile, and binary size stays small. Loaders take `Option<Res<AssetServer>>` and fall back gracefully under `MinimalPlugins`. The `assets/` directory must ship alongside the binary. | 2026-04-29 |
|
||||||
| Audio assets remain embedded via `include_bytes!()` | Audio files are small, change rarely, and the embedded path eliminates a class of runtime-load errors during gameplay; the asset-pipeline reversal does not extend to audio | 2026-04-29 |
|
| Audio assets remain embedded via `include_bytes!()` | Audio files are small, change rarely, and the embedded path eliminates a class of runtime-load errors during gameplay; the asset-pipeline reversal does not extend to audio | 2026-04-29 |
|
||||||
|
| Card art swapped from xCards (LGPL-3.0) to hayeah/playing-cards-assets (MIT) | Public-release readiness. The previous xCards art carried LGPL relinking obligations that complicate a single-binary distribution; hayeah's set derives from the public-domain `vector-playing-cards` line-art and is permissively MIT-licensed. CREDITS.md license summary collapsed to MIT + OFL-1.1. The default card back is original work in this project's midnight-purple palette. | 2026-05-01 |
|
||||||
|
| Runtime SVG card-theme system (`CARD_PLAN.md`) | User-supplied themes need to ship SVG sources so they can rasterise at any resolution on the player's hardware; baking PNGs at build time only would lock theme installation to the developer. The pipeline (usvg → resvg → tiny-skia) rasterises once per (theme, target size) at load time and caches the resulting `Image`, so the runtime cost is paid once, not per frame. The bundled default theme ships via `embedded://`; user themes via `themes://` rooted at `user_theme_dir()`. | 2026-05-01 |
|
||||||
|
|||||||
@@ -0,0 +1,883 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Solitaire Quest are documented here. The format is
|
||||||
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
||||||
|
project follows [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
_Nothing yet._
|
||||||
|
|
||||||
|
## [0.19.0] — 2026-05-06
|
||||||
|
|
||||||
|
Closes the v0.18.0 punch list (items B and D — async hint and
|
||||||
|
persistent replay share URLs), expands desktop platform fit
|
||||||
|
(Wayland session support + monitor-aware default window size for
|
||||||
|
HiDPI / 4K displays), polishes the win-celebration and
|
||||||
|
double-click animation paths, and clears two test-flake
|
||||||
|
contributors. A short-lived "Rusty Pixel" pixel-art card theme
|
||||||
|
was prototyped and reverted in the same window — the engine
|
||||||
|
plumbing it touched (`pixel_art` field on `ThemeMeta`, PNG
|
||||||
|
manifest face support, second `embedded://` theme channel) was
|
||||||
|
fully reverted and is not part of this release.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **H-key hint runs on `AsyncComputeTaskPool`** (`3e11e9e`). The
|
||||||
|
synchronous `try_solve_from_state` call on every H press is gone;
|
||||||
|
`handle_keyboard_hint` now spawns a task whose result the new
|
||||||
|
`pending_hint::poll_pending_hint_task` system surfaces one frame
|
||||||
|
later. New `PendingHintTask` resource carries the in-flight handle
|
||||||
|
plus `move_count_at_spawn` for staleness detection;
|
||||||
|
`drop_pending_hint_on_state_change` cancels the task whenever the
|
||||||
|
game state shifts; `PendingHintTask::spawn` implements
|
||||||
|
cancel-on-replace so two quick H presses keep at most one task in
|
||||||
|
flight. Mirrors the v0.18.0 `PendingNewGameSeed` template.
|
||||||
|
`emit_hint_visuals` and `find_heuristic_hint` are extracted as
|
||||||
|
`pub` helpers so the polling system can call them.
|
||||||
|
- **Persistent replay share URLs** (`42d90b1`). v0.18.0's
|
||||||
|
`LastSharedReplayUrl` was an in-memory resource wiped on quit —
|
||||||
|
the player had to share within the session of the win.
|
||||||
|
`solitaire_data::Replay` now carries a `share_url: Option<String>`
|
||||||
|
field with `#[serde(default)]` (no `REPLAY_SCHEMA_VERSION` bump
|
||||||
|
needed; older `replays.json` files load unchanged with `share_url
|
||||||
|
== None` on every entry). `poll_replay_upload_result` writes the
|
||||||
|
resolved URL into `replays[0].share_url` and persists the updated
|
||||||
|
history via `save_replay_history_to`. The Stats overlay's
|
||||||
|
"Copy share link" button reads from
|
||||||
|
`history.0.replays[selected.0].share_url`, so the Prev/Next
|
||||||
|
selector's currently-displayed replay drives the clipboard
|
||||||
|
contents — each historical win keeps its own URL.
|
||||||
|
`LastSharedReplayUrl` removed (its role is now subsumed by the
|
||||||
|
`share_url` field on the replay record).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Wayland session support** (`b57db01`). The workspace
|
||||||
|
`Cargo.toml` Bevy feature list now enables `wayland` alongside
|
||||||
|
`x11`. winit prefers Wayland when `WAYLAND_DISPLAY` is set on the
|
||||||
|
session, falling back to X11 when it isn't. Pre-fix, a Wayland
|
||||||
|
desktop environment fell through to XWayland, rendering the
|
||||||
|
game inside an X11 frame stitched into the Wayland compositor.
|
||||||
|
Post-fix, the game opens as a native Wayland surface. Costs a
|
||||||
|
few hundred KB of binary for the libwayland-client bindings;
|
||||||
|
cross-distro friendly because winit dlopen-probes the libraries
|
||||||
|
rather than hard-linking them.
|
||||||
|
- **Monitor-relative default window size** (`b57db01`). On launches
|
||||||
|
with no saved geometry, the new
|
||||||
|
`apply_smart_default_window_size` Update system queries
|
||||||
|
`Monitor` (with the `PrimaryMonitor` marker) and resizes the
|
||||||
|
primary window to ~70 % of the monitor's *logical* size on the
|
||||||
|
first frame. Before, every fresh launch opened at 1280×800
|
||||||
|
regardless of monitor; on a 4K monitor that's a comparatively
|
||||||
|
tiny window in one corner. Logical size already accounts for
|
||||||
|
the OS's HiDPI scale factor, so a Retina display reporting
|
||||||
|
scale_factor 2.0 yields the same physical inches as a 1080p
|
||||||
|
display reporting 1.0. Skipped entirely when saved geometry was
|
||||||
|
applied — the player's chosen size always wins.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Duplicate "You Win" toast on game-won** (`55c235b`). The
|
||||||
|
post-win UI was firing two celebration surfaces: a 4-second
|
||||||
|
toast banner ("You Win! Score: X Time: Y") on top of the
|
||||||
|
`win_summary_plugin`'s "You Won!" modal. In screenshots the
|
||||||
|
toast banner was partially clipped behind the modal card,
|
||||||
|
peeking out on either side. The toast predated the modal and is
|
||||||
|
strictly subsumed by it; removed. The cards-fly-off cascade
|
||||||
|
animation (`MotionCurve::Expressive` per-card rotation drift)
|
||||||
|
is unchanged — that's the visual celebration, distinct from
|
||||||
|
the textual celebration the modal owns. `WIN_TOAST_SECS` const
|
||||||
|
removed.
|
||||||
|
- **Double-click on a single card with no destination now plays
|
||||||
|
the reject animation** (`d7ffb16`). `handle_double_click` only
|
||||||
|
fired `MoveRejectedEvent` for multi-card stacks with no
|
||||||
|
destination; a double-click on a single card whose top didn't
|
||||||
|
fit any foundation or tableau slot produced zero feedback —
|
||||||
|
no `card_invalid.wav`, no source-pile shake. Both priorities'
|
||||||
|
failure paths now converge on a single rejection at the end of
|
||||||
|
the double-click branch, so single-card and stack misses get
|
||||||
|
the same feedback shape as drag-and-drop rejections.
|
||||||
|
- **Double-click move animation no longer plays twice**
|
||||||
|
(`6037596`). On a successful double-click, the slide-to-
|
||||||
|
destination animation rendered twice — once from the move's
|
||||||
|
`StateChangedEvent` landing, then again from the release's
|
||||||
|
`end_drag` firing a redundant `StateChangedEvent` mid-slide.
|
||||||
|
`sync_cards_on_change` saw the card mid-CardAnim (`cur ≠
|
||||||
|
target`) and replaced the in-flight tween with a fresh one
|
||||||
|
starting at the mid-position, visibly restarting the slide. The
|
||||||
|
defensive `StateChangedEvent` write in `end_drag`'s
|
||||||
|
uncommitted-drag branch is removed; `start_drag` only mutates
|
||||||
|
`DragState` (never card transforms), so an uncommitted drag
|
||||||
|
has no visual side effect to undo. The committed-drag branch
|
||||||
|
keeps its `StateChangedEvent` since real drag snap-backs do
|
||||||
|
need a resync.
|
||||||
|
- **`auto_save_writes_after_30_seconds` test flake** (`91b7605`).
|
||||||
|
The test's single-frame `app.update()` was sensitive to
|
||||||
|
first-frame `Time::delta_secs()` variance under heavy parallel
|
||||||
|
cargo-test load, and to production-disk
|
||||||
|
`~/.local/share/solitaire_quest/game_state.json` state leaking
|
||||||
|
into the test world via `GamePlugin::build`'s load path.
|
||||||
|
`test_app` now resets `PendingRestoredGame(None)` after plugin
|
||||||
|
build (preventing the dev machine's saved-game state from
|
||||||
|
tripping the auto-save guard) and the test re-arms the timer in
|
||||||
|
a small bounded loop until the file appears (robust against
|
||||||
|
first-frame Time variance). No production-code change.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1170 passing tests (was 1166 at v0.18.0 close — net +4 from
|
||||||
|
the persistent share URL backwards-compat test, the three
|
||||||
|
async-hint tests, minus the dropped synchronous hint tests).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.18.0] — 2026-05-06
|
||||||
|
|
||||||
|
The launch-experience round. The engine used to drop the player on a
|
||||||
|
silent default Classic deal whether they had unfinished work or not;
|
||||||
|
v0.18.0 replaces that with two stacked decision points — a Restore
|
||||||
|
prompt for in-progress saves, then an MSSC-style Home / mode picker
|
||||||
|
that surfaces Daily / Zen / Challenge / Time Attack as picture tiles
|
||||||
|
with live stats. The same round closes the last solver-on-main-thread
|
||||||
|
hot path (winnable-only seed selection moves to
|
||||||
|
`AsyncComputeTaskPool`), wires "Copy share link" into Stats, lights a
|
||||||
|
"Won before" HUD chip on re-deals of beaten seeds, and tidies the
|
||||||
|
unified-3.0 rule set across CLAUDE.md / CLAUDE_SPEC.md /
|
||||||
|
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Restore prompt on launch** (`3c7a0eb`). When `game_state.json`
|
||||||
|
holds an in-progress game (`move_count > 0`, not won), the engine
|
||||||
|
now seeds `GameStateResource` with a fresh deal and holds the saved
|
||||||
|
game in a new `PendingRestoredGame` resource. After the splash
|
||||||
|
clears, a "Welcome back" modal offers **Continue** (Enter / C /
|
||||||
|
click) or **New game** (N / click). Fresh-deal saves
|
||||||
|
(`move_count == 0`) skip the prompt and load directly.
|
||||||
|
- **Save preservation while the prompt is unanswered** (`f863d85`).
|
||||||
|
Both `save_game_state_on_exit` and `auto_save_game_state` consult
|
||||||
|
`PendingRestoredGame` first: if it still holds a pending saved
|
||||||
|
game, that's what gets persisted (or the auto-save is skipped),
|
||||||
|
so exiting before answering the prompt no longer overwrites the
|
||||||
|
meaningful save with the placeholder fresh deal.
|
||||||
|
- **Home / mode picker auto-shows on launch** (`dd63261`). The mode
|
||||||
|
picker was only reachable via **M** during gameplay; players who
|
||||||
|
hadn't discovered the hotkey never saw the Daily / Zen / Challenge
|
||||||
|
/ Time Attack entry points after the splash cleared. `HomePlugin`
|
||||||
|
gains an `auto_show_on_launch` flag (default true) and a
|
||||||
|
one-shot `LaunchHomeShown` gate. Skips when the Restore prompt is
|
||||||
|
on screen so Welcome-back still takes precedence.
|
||||||
|
- **MSSC-style Home picker — header / chips / score chips / draw
|
||||||
|
mode** (`ae40a1d`). Player-stats header strip (Level / XP /
|
||||||
|
Lifetime Score, compact-formatted as `1.2M` / `12.3K` / `1,234`)
|
||||||
|
acts as a clickable shortcut to Profile. Draw-mode chip row above
|
||||||
|
the mode cards lets the player flip Draw 1 / Draw 3 from the
|
||||||
|
picker itself; persists `settings.json` and respawns the modal so
|
||||||
|
the active state repaints cleanly. Per-mode best-score / streak
|
||||||
|
chips on each card; hidden on a 0 best so a fresh profile doesn't
|
||||||
|
read "Best 0" everywhere.
|
||||||
|
- **Today's Event callout on the Daily card** (`b73d246`). "Today,
|
||||||
|
May 6" date line plus the server-fetched goal (when SyncPlugin is
|
||||||
|
wired). Once today's daily is recorded as completed, the date
|
||||||
|
flips to `Today, May 6 • Done` in `ACCENT_PRIMARY` so the picker
|
||||||
|
reads as a reward state rather than a TODO.
|
||||||
|
- **Picture-tile mode cards** (`9fe650f` + glyph-picking follow-ups
|
||||||
|
`40d6e0a`, `c30b04e`, `d065d49`). Mode cards become a wrapping
|
||||||
|
2-up grid (`FlexWrap::Wrap`, tiles 48 % wide, `min_height: 180px`)
|
||||||
|
with a centred Unicode-glyph centrepiece per tile. Final glyph set
|
||||||
|
picked from FiraMono-Medium's actual coverage: ♣ Classic, ◆ Daily,
|
||||||
|
○ Zen, ▲ Challenge, → TimeAttack. `ACCENT_PRIMARY` when the mode is
|
||||||
|
unlocked, `TEXT_DISABLED` when locked. Centrepiece is a `Text` node
|
||||||
|
for now — when real per-mode artwork lands, swap to `Image` without
|
||||||
|
touching tile layout, focus order, or chip rendering.
|
||||||
|
- **Solver-vetted seed selection on `AsyncComputeTaskPool`**
|
||||||
|
(`d489e7a`). Closes the worst-case 6 s UI stall on a New Game
|
||||||
|
click with "Winnable deals only" enabled. New `PendingNewGameSeed`
|
||||||
|
resource holds the in-flight `Task<u64>` plus the original
|
||||||
|
request's `mode` / `confirmed` flags. `poll_pending_new_game_seed`
|
||||||
|
runs `.before(GameMutation)` and replays a synthetic
|
||||||
|
`NewGameRequestEvent` once the task resolves — the player sees no
|
||||||
|
extra-frame visual lag. Cancel-on-replace: a fresh
|
||||||
|
`NewGameRequestEvent` while a task is in flight drops the old
|
||||||
|
task, letting Bevy's `Task` Drop cancel cooperatively at the next
|
||||||
|
await point.
|
||||||
|
- **"Won before" HUD indicator** (`bdac754`). When the current
|
||||||
|
deal's `(seed, draw_mode, mode)` triple matches an entry in the
|
||||||
|
rolling `ReplayHistory`, the HUD's tier-2 context row shows
|
||||||
|
**✓ Won before** in `STATE_SUCCESS`. Cleared on win (the on-screen
|
||||||
|
victory cue is enough) and on first-time deals. New
|
||||||
|
`HudWonPreviously` marker driven by a separate
|
||||||
|
`update_won_previously` system; gracefully no-ops in headless
|
||||||
|
tests that don't load `StatsPlugin`.
|
||||||
|
- **"Copy share link" Stats button** (`540869c`). End-to-end replay
|
||||||
|
sharing on a server-backed sync backend:
|
||||||
|
`sync_plugin::push_replay_on_win` spawns the upload on
|
||||||
|
`AsyncComputeTaskPool` and stores the handle in
|
||||||
|
`PendingReplayUpload` (drops any in-flight predecessor — the most
|
||||||
|
recent win is what the player wants the link for);
|
||||||
|
`poll_replay_upload_result` writes `<server>/replays/<id>` to
|
||||||
|
`LastSharedReplayUrl` on success; the Stats overlay's action bar
|
||||||
|
gains a button that writes the URL to the OS clipboard via
|
||||||
|
`arboard` and surfaces a "Copied: \<url\>" toast. URL is in-memory
|
||||||
|
only — sharing must happen within the session of the win.
|
||||||
|
- **Empty-state copy + onboarding hints** (`56e2e6f`). Leaderboard
|
||||||
|
empty state: two-tier "Be the first on the leaderboard." headline
|
||||||
|
+ body invite. Achievements panel: first-launch hint above the
|
||||||
|
grid until the first unlock. Volume hotkeys (`[` / `]`) now emit
|
||||||
|
an `InfoToastEvent` with the new percentage so off-panel
|
||||||
|
adjustments give visible feedback (previously silent).
|
||||||
|
- **Enter dismisses the Win Summary and starts a fresh deal**
|
||||||
|
(`17e0737`). The post-win modal's "Play Again" was click-only;
|
||||||
|
keyboard-only players had to reach for the mouse to leave the
|
||||||
|
celebration screen. The button label gains a trailing return-key
|
||||||
|
glyph so the keyboard path is discoverable on first sight.
|
||||||
|
- **`N` opens the real Confirm/Cancel modal** (`93660c2`). The old
|
||||||
|
"Press N again" double-tap pattern was a UI-first violation (only
|
||||||
|
continuation was another keystroke). `N` now fires
|
||||||
|
`NewGameRequestEvent::default()` directly; `handle_new_game`'s
|
||||||
|
active-game check spawns the existing `ConfirmNewGameScreen`. The
|
||||||
|
HUD button already routed through the same modal — keyboard and
|
||||||
|
mouse paths are unified. `Shift+N` keeps the keyboard power-user
|
||||||
|
bypass (`confirmed: true`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Settings row layout** (`a4bc063`). All five
|
||||||
|
slider/toggle row helpers (volume × 2, tooltip delay, time-bonus
|
||||||
|
multiplier, replay-move interval, generic toggle) restructured to
|
||||||
|
a label-spacer-cluster layout (`width: 100%`, label gets
|
||||||
|
`flex-grow: 1`, controls cluster sits flush right). Stable across
|
||||||
|
varying value-text widths ("0.80" → "1.00", "Instant" vs "1.5 s")
|
||||||
|
and narrow windows.
|
||||||
|
- **Docs adopt the unified-3.0 rule set** (`f2f30c8`). `CLAUDE.md`
|
||||||
|
grows from a 114-line pointer doc to a 571-line rulebook (hard
|
||||||
|
global constraints §2, engine rules §3, asset rules §4, code
|
||||||
|
standards §5, build + verification §6, git workflow §7, the ASK
|
||||||
|
BEFORE list §8, Context Injection System §14). New companions:
|
||||||
|
`CLAUDE_SPEC.md` (formal architecture spec — crate dependency
|
||||||
|
graph, data ownership, state-machine invariants, sync merge /
|
||||||
|
server contracts, validation checklist),
|
||||||
|
`CLAUDE_WORKFLOW.md` (two-agent Builder/Guardian pipeline with
|
||||||
|
hard-fail patterns), `CLAUDE_PROMPT_PACK.md` (task-type
|
||||||
|
templates). Three duplicate rule passages removed across
|
||||||
|
`CLAUDE_SPEC.md` and `ARCHITECTURE.md`.
|
||||||
|
- **Test discipline pruning** (`a49a340`). Removed 43 low-value
|
||||||
|
tests across `solitaire_data` and `solitaire_core` (default-value
|
||||||
|
tests, serde-derive round-trips on plain structs, single-field
|
||||||
|
clamp tests, near-duplicates, constant-equals-itself tests). None
|
||||||
|
pinned a behaviour contract or a regression on a real bug. Future
|
||||||
|
agent briefs request tests for behaviour contracts or real-bug
|
||||||
|
regressions, not a count of N.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Esc on a modal no longer opens Pause underneath** (`08b006f`).
|
||||||
|
A single Esc press on Confirm New Game / Restore / Home /
|
||||||
|
Onboarding / Settings used to both close the modal and spawn the
|
||||||
|
Pause overlay on top in the same frame. `toggle_pause` now skips
|
||||||
|
when any non-Pause `ModalScrim` is in the world; the HUD-button
|
||||||
|
path is gated too. The four modal queries are bundled into a
|
||||||
|
`PauseModalQueries` `SystemParam` to stay under Bevy's
|
||||||
|
16-parameter cap.
|
||||||
|
- **Esc dismisses Home / accepts the Restore-prompt default**
|
||||||
|
(`d48b948`). Both screens previously ignored Esc, leaving the
|
||||||
|
player no keyboard-only escape after the previous fix. Home: Esc
|
||||||
|
behaves like Cancel (despawns the modal, keeps the underlying
|
||||||
|
default deal). Restore: Esc maps to Continue (preserves the saved
|
||||||
|
game, matching how the primary action already advertises Enter).
|
||||||
|
- **Esc dismisses the topmost modal when Profile stacks on Home**
|
||||||
|
(`9aa0dd2`). Clicking the Home header chip opens Profile on top
|
||||||
|
of Home; Esc used to close Home (because
|
||||||
|
`handle_home_cancel_button` fired with no awareness of layered
|
||||||
|
modals) and leave Profile orphaned over the game.
|
||||||
|
`profile_plugin` now splits P/button (toggle) from Esc
|
||||||
|
(close-only); `handle_home_cancel_button` skips its Esc branch
|
||||||
|
when any other `ModalScrim` exists.
|
||||||
|
- **Restore-prompt resolution suppresses Home auto-show**
|
||||||
|
(`b7c3a49`). Resolving the Welcome-back prompt cleared
|
||||||
|
`PendingRestoredGame` and despawned the modal, but the
|
||||||
|
launch-time Home auto-show then fired the next frame and stacked
|
||||||
|
itself over the player's chosen path. `LaunchHomeShown` becomes
|
||||||
|
`pub` so `handle_restore_prompt` flips it to `true` after either
|
||||||
|
resolution; **M** still re-opens the picker on demand.
|
||||||
|
- **Game timers freeze while the Home picker is up** (`c497c31`).
|
||||||
|
The HUD's elapsed-time counter ticked from the moment the default
|
||||||
|
Classic deal landed at startup, even though the auto-show Home
|
||||||
|
picker was still up — the player saw "0:11" before they had
|
||||||
|
chosen a mode. `tick_elapsed_time` and `advance_time_attack` now
|
||||||
|
also gate on the absence of `HomeScreen`, mirroring their
|
||||||
|
existing `PausedResource` check.
|
||||||
|
- **Popover rows stay visible regardless of action-bar fade**
|
||||||
|
(`cc63532`). Opening Modes / Menu showed a solid dark-purple
|
||||||
|
block in the top-right with no readable content — the action-bar
|
||||||
|
auto-fade was matching the popover rows by their shared
|
||||||
|
`ActionButton` marker and dropping their alpha to the
|
||||||
|
cursor-position-based fade value (typically 0). New `PopoverRow`
|
||||||
|
marker on rows in `spawn_modes_popover` / `spawn_menu_popover`;
|
||||||
|
`apply_action_fade` excludes them via `Without<PopoverRow>`.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1166 passing tests (was 1208 at v0.17.0 close — 43 net removals
|
||||||
|
from the test-discipline prune plus 1 net-new test from the
|
||||||
|
async-seed work, no behaviour regressions).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.17.0] — 2026-05-06
|
||||||
|
|
||||||
|
A short follow-up round on top of v0.16.0: the H-key hint is no
|
||||||
|
longer a heuristic guess but the actual best first move suggested by
|
||||||
|
the v0.15.0 solver, and the in-engine replay player now has a
|
||||||
|
player-tunable playback rate.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Replay-rate slider** in Settings → Gameplay. Tunes
|
||||||
|
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
|
||||||
|
default 0.45 s. `tick_replay_playback` reads the value from
|
||||||
|
`SettingsResource` per frame so the slider takes effect on the
|
||||||
|
next playback tick — no restart required.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Solver-driven hints.** Pressing **H** used to surface a
|
||||||
|
heuristic-best move (foundation moves preferred, then
|
||||||
|
tableau-to-tableau by depth-of-flip-revealed). It now asks the
|
||||||
|
v0.15.0 solver for the actual provably-best first move via the
|
||||||
|
new `solitaire_core::solver::try_solve_with_first_move` /
|
||||||
|
`try_solve_from_state` APIs. When the solver returns inconclusive
|
||||||
|
(rare deals where the bound runs out before a result), the old
|
||||||
|
heuristic remains the fallback. Median 2 ms per H press.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1208 passing tests (was 1196 at v0.16.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.16.0] — 2026-05-06
|
||||||
|
|
||||||
|
A modal-feel polish round. Every overlay screen now scrolls when its
|
||||||
|
content overflows the 800×600 minimum window, every clickable button
|
||||||
|
shows a hand cursor on hover, keyboard focus lands on the primary
|
||||||
|
button on the same frame the modal opens, and read-only modals
|
||||||
|
dismiss when the player clicks the scrim outside the card.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Pointer cursor on hover** for every interactive `Button` entity
|
||||||
|
(modal buttons, HUD action bar, mode-launcher cards, settings
|
||||||
|
toggles, Stats selectors). `update_cursor_icon` gains a fourth
|
||||||
|
branch sitting between Grabbing (active drag) and Grab
|
||||||
|
(draggable card hover): when no drag is active and any
|
||||||
|
`Interaction::Hovered`/`Pressed` button is detected, the window
|
||||||
|
cursor swaps to `SystemCursorIcon::Pointer`. A pure
|
||||||
|
`pick_cursor_icon` helper makes the priority logic
|
||||||
|
unit-testable.
|
||||||
|
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
|
||||||
|
Achievements, Help, Profile, Leaderboard, Home. New
|
||||||
|
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
|
||||||
|
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
|
||||||
|
topmost dismissible scrim on a left-mouse press whose cursor
|
||||||
|
lands on the scrim and outside every `ModalCard`. Bevy's
|
||||||
|
hierarchy despawn cascades to the card and children.
|
||||||
|
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
|
||||||
|
Game intentionally don't opt in — they carry unsaved or
|
||||||
|
destructive state.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Modal content scrolls when it overflows** (Achievements, Help,
|
||||||
|
Stats, Profile, Leaderboard). Each modal's body Node now
|
||||||
|
carries `Overflow::scroll_y()` plus a `max_height` constraint
|
||||||
|
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
|
||||||
|
leaderboard's variable-length ranking section) and a marker
|
||||||
|
component (`AchievementsScrollable`, `HelpScrollable`,
|
||||||
|
`StatsScrollable`, `ProfileScrollable`,
|
||||||
|
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
|
||||||
|
per modal routes `MouseWheel` events into the body's
|
||||||
|
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
|
||||||
|
pattern. Home modal intentionally not scrolled — its five
|
||||||
|
mode cards + Cancel are sized to fit at 800×600 by design.
|
||||||
|
- **Modal focus arrives on the same frame the modal opens.**
|
||||||
|
Previously `attach_focusable_to_modal_buttons` and
|
||||||
|
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
|
||||||
|
click-handlers that spawn modals; with no ordering edge,
|
||||||
|
Bevy's deferred `Commands` queued the new entities but the
|
||||||
|
attach system couldn't see them on the same tick. Both systems
|
||||||
|
moved to `PostUpdate` so the schedule boundary itself supplies
|
||||||
|
the sync point — `FocusedButton` is always populated before
|
||||||
|
`app.update()` returns. The very next Tab/Enter press lands on
|
||||||
|
a populated resource instead of wasting itself moving focus
|
||||||
|
from None to the primary.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1196 passing tests (was 1178 at v0.15.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.15.0] — 2026-05-02
|
||||||
|
|
||||||
|
In-engine replay playback, the Klondike solver + "Winnable deals
|
||||||
|
only" toggle, a 19th achievement, rolling replay history, and a
|
||||||
|
significant build-time / binary-size win from disabling Bevy's
|
||||||
|
default audio stack.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **In-engine replay playback** for the Stats overlay's Watch Replay
|
||||||
|
button. New `ReplayPlaybackPlugin` runs a state machine
|
||||||
|
(Inactive / Playing / Completed) that resets the live game to the
|
||||||
|
recorded deal and ticks through `replay.moves` at
|
||||||
|
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
|
||||||
|
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
|
||||||
|
Recording is suppressed during playback so replays don't re-record
|
||||||
|
themselves.
|
||||||
|
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
|
||||||
|
top of the window during playback. Shows "Replay" label, "Move N
|
||||||
|
of M" progress, and a Stop button. Z-order leaves modals
|
||||||
|
(Settings, Pause, Help) free to render on top so the player can
|
||||||
|
adjust audio mid-replay.
|
||||||
|
- **Rolling replay history** at `<data_dir>/replays.json` capped at
|
||||||
|
8 entries. Replaces the single-slot `latest_replay.json` (legacy
|
||||||
|
file is migrated forward on first launch via
|
||||||
|
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
|
||||||
|
selector and a "Replay N / M" caption so the player can revisit
|
||||||
|
older wins.
|
||||||
|
- **"Cinephile" achievement** (#19). Unlocks the first time
|
||||||
|
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
|
||||||
|
replay played out to its end without the player pressing Stop).
|
||||||
|
Stop transitions Playing → Inactive directly so it doesn't count.
|
||||||
|
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
|
||||||
|
with memoisation on a 64-bit canonical state hash, two budget
|
||||||
|
knobs (move_budget + state_budget) for pathological cases, and a
|
||||||
|
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
|
||||||
|
Median solve time 2 ms; pathological inconclusives cap near
|
||||||
|
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
|
||||||
|
- **"Winnable deals only" toggle** in Settings → Gameplay (default
|
||||||
|
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
|
||||||
|
through `try_solve` until it finds Winnable or Inconclusive,
|
||||||
|
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
|
||||||
|
challenges, replays, and explicit-seed requests bypass the
|
||||||
|
solver — only random Classic deals are gated.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Bevy default-feature trim** (`bevy = { default-features = false,
|
||||||
|
features = [...] }` in workspace Cargo.toml) drops 51 transitive
|
||||||
|
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
|
||||||
|
chain that the project doesn't use (kira handles audio directly).
|
||||||
|
The retained feature list is curated to exactly what the engine
|
||||||
|
uses; `solitaire_wasm` is unaffected because it doesn't depend on
|
||||||
|
bevy.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1178 passing tests (was 1134 at v0.14.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.14.0] — 2026-05-02
|
||||||
|
|
||||||
|
Two threads land in v0.14.0: the second half of the post-v0.12.0 UX
|
||||||
|
candidate list (theme thumbnails, daily-challenge calendar, Time Attack
|
||||||
|
auto-save, per-mode bests, time-bonus multiplier) plus a **major new
|
||||||
|
feature** — the replay pipeline (record → upload → web viewer). Three
|
||||||
|
Quat-reported bugs from a smoke-test round shipped alongside.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Theme-picker thumbnails** in Settings → Cosmetic. Each theme chip
|
||||||
|
renders a small Ace-of-Spades + back preview pair via the existing
|
||||||
|
`rasterize_svg` path. Cached per theme in a new
|
||||||
|
`ThemeThumbnailCache`. Themes that lack a preview SVG fall back to
|
||||||
|
a transparent placeholder rather than crashing.
|
||||||
|
- **14-day daily-challenge calendar** in the Profile modal. Horizontal
|
||||||
|
row of dots showing the trailing two weeks; today's dot is ringed
|
||||||
|
in `ACCENT_PRIMARY`, completed days fill `STATE_SUCCESS`, missed
|
||||||
|
days fill `BG_ELEVATED`. Caption above the row reads "Current
|
||||||
|
streak: N · Longest: M".
|
||||||
|
- **Time Attack session auto-save** to `<data_dir>/time_attack_session.json`,
|
||||||
|
atomic .tmp + rename. 30-second auto-save while a session is active,
|
||||||
|
plus on `AppExit`. Sessions whose 10-minute window expired in real
|
||||||
|
time while the app was closed are discarded on load. Classic, Zen,
|
||||||
|
and Challenge already auto-saved correctly via `game_state.json` —
|
||||||
|
Time Attack was the only mode missing session-level persistence.
|
||||||
|
- **Per-mode best-score and fastest-win readouts** in the Stats screen.
|
||||||
|
`StatsSnapshot` gains six `#[serde(default)]` fields (Classic / Zen
|
||||||
|
/ Challenge × best_score + fastest_win_seconds). Stats screen renders
|
||||||
|
a "Per-mode bests" section between the primary cell grid and
|
||||||
|
progression. Lifetime totals continue to roll all modes together.
|
||||||
|
- **Time-bonus multiplier slider** in Settings → Gameplay (0.0–2.0,
|
||||||
|
0.1 steps, default 1.0, "Off" label at zero). Cosmetic only —
|
||||||
|
multiplies the time-bonus shown in the win modal but does NOT
|
||||||
|
affect achievement unlock thresholds (those still use the raw
|
||||||
|
unmultiplied score).
|
||||||
|
- **Win-replay recording + storage.** Every move during a successful
|
||||||
|
game appends to a `RecordingReplay` resource; on `GameWonEvent`
|
||||||
|
the recording freezes into a `Replay` (seed + draw_mode + mode +
|
||||||
|
score + time + ordered move list) and persists to
|
||||||
|
`<data_dir>/latest_replay.json` atomically. Single-slot — overwrites
|
||||||
|
on every win.
|
||||||
|
- **"Watch replay" button** in the Stats overlay. Shows the latest
|
||||||
|
win's caption and surfaces a button that loads the replay (button
|
||||||
|
fires an `InfoToastEvent` describing the replay; full in-engine
|
||||||
|
playback is deferred to a future build).
|
||||||
|
- **Replay upload + fetch endpoints** on the server. `POST /api/replays`
|
||||||
|
accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated
|
||||||
|
with the existing auth middleware. Engine uploads winning replays
|
||||||
|
automatically when the player has cloud sync configured.
|
||||||
|
- **`solitaire_wasm` crate** — new workspace member compiling
|
||||||
|
replay-relevant `solitaire_core` types to WebAssembly so a
|
||||||
|
browser can re-execute a replay client-side. No-std-friendly
|
||||||
|
surface; `wasm-bindgen` glue.
|
||||||
|
- **Web replay viewer** served from the Solitaire server.
|
||||||
|
`GET /replays/:id` returns HTML + CSS + the wasm bundle that
|
||||||
|
fetches the replay JSON, rasterises a deal from the seed, and
|
||||||
|
animates the recorded moves.
|
||||||
|
- **Card flight animations on the web side** so the browser viewer
|
||||||
|
reads as a real game replay rather than a static dump.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Multi-card lift validation.** `solitaire_core::rules::is_valid_tableau_sequence`
|
||||||
|
rejects a moved stack whose adjacent cards don't form a descending
|
||||||
|
alternating-colour run. Previously a player could lift any
|
||||||
|
multi-card selection and drop it as long as the bottom landed
|
||||||
|
legally. Wired into `move_cards`'s tableau-destination branch.
|
||||||
|
- **Softlock detection.** `has_legal_moves` rewritten to walk every
|
||||||
|
potential move source (every stock card, every waste card, the
|
||||||
|
face-up top of every tableau column) and check it against every
|
||||||
|
foundation and every tableau. Previously the heuristic
|
||||||
|
early-returned `true` whenever stock had cards — players got
|
||||||
|
stuck in unwinnable end-states with no end-game screen.
|
||||||
|
`GameOverScreen` now actually fires for true softlocks. Quat's
|
||||||
|
exact reproduction case is pinned by a new test.
|
||||||
|
- **Deal-tween information leak.** New-game now snaps every card
|
||||||
|
sprite to the stock pile position before writing
|
||||||
|
`StateChangedEvent`, so all 52 cards animate from a single point
|
||||||
|
during the deal. Previously the sprites started from their
|
||||||
|
previous-game positions, briefly revealing the prior deal.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `SESSION_HANDOFF.md` refreshed for the Quat smoke-test round
|
||||||
|
including investigation findings on solver decisions and
|
||||||
|
dependency duplicates.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1134 passing tests (was 1053 at v0.13.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.13.0] — 2026-05-02
|
||||||
|
|
||||||
|
Third UX iteration round on top of v0.12.0. Six handoff candidates
|
||||||
|
shipped — three small polish items, three larger interaction
|
||||||
|
features (theme-aware backs, full keyboard play, right-click power
|
||||||
|
shortcut). Plus two code-review fixes (font handling unified,
|
||||||
|
sccache wiring removed).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tooltip-delay slider** in Settings → Gameplay. `tooltip_delay_secs`
|
||||||
|
ranges [0.0, 1.5] in 0.1 s steps; "Instant" label when zero.
|
||||||
|
`Settings.tooltip_delay_secs` round-trips through serialise/deserialise
|
||||||
|
with `#[serde(default)]`. The hover-delay comparison in
|
||||||
|
`ui_tooltip` reads from `SettingsResource` with the existing
|
||||||
|
`MOTION_TOOLTIP_DELAY_SECS` as the test-fixture fallback.
|
||||||
|
- **Win-streak fire animation.** New `WinStreakMilestoneEvent` fires
|
||||||
|
from `stats_plugin` when `win_streak_current` crosses any of
|
||||||
|
[3, 5, 10] (only the threshold crossing — not every subsequent
|
||||||
|
win). The HUD streak readout scale-pulses 1.0 → 1.20 → 1.0 over
|
||||||
|
`MOTION_STREAK_FLOURISH_SECS` (0.6 s).
|
||||||
|
- **Score-breakdown reveal on the win modal.** Replaces the single
|
||||||
|
"Score: N" line with a per-component reveal (Base / Time bonus /
|
||||||
|
No-undo bonus / Mode multiplier / Total). Rows fade in over
|
||||||
|
`MOTION_SCORE_BREAKDOWN_FADE_SECS` (0.12 s) staggered by
|
||||||
|
`MOTION_SCORE_BREAKDOWN_STAGGER_SECS` (0.15 s). Honours
|
||||||
|
`AnimSpeed::Instant` by spawning all rows fully visible.
|
||||||
|
- **Card backs follow the active theme.** `theme.ron`'s `back` slot
|
||||||
|
now actually drives the face-down sprite. Active-theme back
|
||||||
|
rasterises alongside the faces and supersedes the legacy
|
||||||
|
`back_N.png` picker. The picker remains as a fallback for themes
|
||||||
|
that don't ship a back, and the Settings UI surfaces a caption
|
||||||
|
("Active theme provides its own back") + dimmed swatches when
|
||||||
|
the override is in effect.
|
||||||
|
- **Keyboard-only drag-and-drop.** Tab cycles draggable card stacks,
|
||||||
|
Enter "lifts" the focused stack, arrow keys (or Tab) cycle the
|
||||||
|
legal-destination targets only, Enter confirms, Esc cancels. A
|
||||||
|
new `KeyboardDragState` resource models the two-mode flow without
|
||||||
|
changing the existing `SelectionState` contract. Mutual exclusion
|
||||||
|
with mouse drag uses a sentinel `DragState.active_touch_id =
|
||||||
|
KEYBOARD_DRAG_TOUCH_ID` (u64::MAX) so neither pipeline can
|
||||||
|
trample the other.
|
||||||
|
- **Right-click radial menu.** Hold right-click on a face-up card →
|
||||||
|
a small ring of icons appears at the cursor with one entry per
|
||||||
|
legal destination. Release over an icon → fires
|
||||||
|
`MoveRequestEvent`; release in dead space, Esc, or left-click
|
||||||
|
cancels. Skips the drag motion entirely. New `RadialMenuPlugin`
|
||||||
|
owns the flow; co-exists with the existing `RightClickHighlight`
|
||||||
|
pile-marker tint.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Font handling consolidated to bundled-only.** Code-review
|
||||||
|
feedback: the SVG rasteriser previously mixed
|
||||||
|
`load_system_fonts` + bundled FiraMono + a lenient resolver,
|
||||||
|
which made card text rendering depend on host fontconfig. Picked
|
||||||
|
option (a) and applied it across both layers — `font_plugin` now
|
||||||
|
embeds `assets/fonts/main.ttf` via `include_bytes!()` and
|
||||||
|
registers it with `Assets<Font>`; `svg_loader::shared_fontdb`
|
||||||
|
loads only the bundled bytes; the new `bundled_font_resolver`
|
||||||
|
ignores the SVG's `font-family` request and always returns the
|
||||||
|
single bundled face. A parse failure aborts with a clear error
|
||||||
|
("bundled FiraMono failed to parse — binary is corrupt").
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Project-level sccache wiring.** Code-review feedback: sccache
|
||||||
|
shouldn't be a per-project build dependency. Cargo's incremental
|
||||||
|
cache already covers the single-project case, and forcing
|
||||||
|
`rustc-wrapper = "sccache"` workspace-wide meant every contributor
|
||||||
|
had to install it. `.cargo/config.toml` deleted entirely; plain
|
||||||
|
`cargo build` now works without setup.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `help_plugin` controls reference gains a "Mouse" section covering
|
||||||
|
double-click auto-move, right-click highlight, and the new
|
||||||
|
hold-RMB radial.
|
||||||
|
- `help_plugin` also gains a "Keyboard drag" section for the new
|
||||||
|
Tab/Enter/Arrows/Esc flow.
|
||||||
|
- Onboarding slide 3 picks up a `Tab → Enter` row referencing the
|
||||||
|
full keyboard drag path.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1053 passing tests (was 1031 at v0.12.0 close).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.12.0] — 2026-05-02
|
||||||
|
|
||||||
|
UX feel polish round on top of v0.11.0. Six small-but-tangible
|
||||||
|
improvements that make the play surface feel more responsive,
|
||||||
|
forgiving, and discoverable, plus the doc refresh that should have
|
||||||
|
ridden along with v0.11.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Foundation completion flourish.** When a King lands on a
|
||||||
|
foundation (Ace-through-King for that suit), a brief celebration
|
||||||
|
fires: King card scale-pulses 1.0 → 1.15 → 1.0 over 0.4 s, the
|
||||||
|
foundation marker tints `STATE_SUCCESS` for the first half then
|
||||||
|
fades, and a synthesised C6→E6→G6 bell ping plays (~240 ms,
|
||||||
|
octave above `win_fanfare`'s root so the fourth completion + win
|
||||||
|
cascade layer cleanly). New `FoundationCompletedEvent { slot,
|
||||||
|
suit }` carries the trigger so future systems can hook in.
|
||||||
|
- **Drag-cancel return tween.** Illegal drops glide each dragged
|
||||||
|
card back to its origin slot over 150 ms with a quintic ease-out
|
||||||
|
curve (`MotionCurve::Responsive`, zero overshoot — reads forgiving
|
||||||
|
rather than jittery). The audio cue (`card_invalid.wav`) still
|
||||||
|
fires for negative feedback. Right-click and double-click invalid
|
||||||
|
paths still use `ShakeAnim` since there's no motion to interpolate.
|
||||||
|
- **Focus ring breathing.** The keyboard focus ring's alpha modulates
|
||||||
|
with a 1.4 s sin curve over [0.65, 1.0] of its native value so the
|
||||||
|
indicator catches the eye on focus changes without competing with
|
||||||
|
gameplay. Honours `AnimSpeed::Instant` by reverting to the static
|
||||||
|
outline for reduced-motion users.
|
||||||
|
- **First-win achievement onboarding toast.** After the player's
|
||||||
|
very first win, a one-shot info toast surfaces "First win! Press
|
||||||
|
A to see your achievements." `Settings.shown_achievement_onboarding`
|
||||||
|
persists the seen state so the cue never re-fires (legacy
|
||||||
|
`settings.json` files load to `false` via `#[serde(default)]`).
|
||||||
|
- **Mode Launcher digit shortcuts.** Pressing M opens the Home modal
|
||||||
|
(the Mode Launcher); inside it, pressing 1–5 launches each mode
|
||||||
|
directly without needing Tab + Enter. Locked modes (Zen, Challenge,
|
||||||
|
Time Attack at level < 5) are silent no-ops. Modal-scoped — digit
|
||||||
|
keys outside the launcher fire nothing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Card aspect ratio matches hayeah SVGs.** `CARD_ASPECT` 1.4 →
|
||||||
|
1.4523 to match the bundled artwork's natural 167.087 × 242.667
|
||||||
|
dimensions. Cards previously rendered ~3.6 % vertically squashed.
|
||||||
|
The vertical-budget math in `compute_layout` uses `CARD_ASPECT`
|
||||||
|
algebraically so the worst-case-tableau-fits-on-screen guarantee
|
||||||
|
adapts automatically.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **README refresh** with v0.11.0+ features (card themes, HUD
|
||||||
|
overhaul, drag feel, unlocked foundations) and a corrected controls
|
||||||
|
table — the previous table inverted Z/U for undo and listed H for
|
||||||
|
help when F1 is the binding.
|
||||||
|
- **CHANGELOG.md** added (this file), covering v0.9.0–v0.12.0 with
|
||||||
|
Keep a Changelog 1.1.0 conventions.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 1007 passing tests (was 982 at v0.11.0).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.11.0] — 2026-05-02
|
||||||
|
|
||||||
|
The biggest release since 0.10.0. Headline threads: a runtime card-theme
|
||||||
|
system, an HUD restructure that reclaims the play surface, and a round of
|
||||||
|
UX feel polish surfaced by smoke testing.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Runtime card-theme system** (CARD_PLAN phases 1–7).
|
||||||
|
- Bundled default theme ships in the binary via `embedded://` — 52
|
||||||
|
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||||
|
SVGs (MIT) plus a midnight-purple `back.svg` as original work.
|
||||||
|
- User themes live under `themes://` rooted at `user_theme_dir()`. Drop
|
||||||
|
a directory containing `theme.ron` + 53 SVGs and the registry picks
|
||||||
|
it up on next launch.
|
||||||
|
- Importer at `solitaire_engine::theme::import_theme(zip)` validates
|
||||||
|
archives (20 MB cap, zip-slip rejection, manifest validation, every
|
||||||
|
SVG round-tripped through the rasteriser) and atomically unpacks.
|
||||||
|
- Picker UI in **Settings → Cosmetic**; selection persists as
|
||||||
|
`selected_theme_id` and propagates to live sprites.
|
||||||
|
- **Reserved HUD top band** (64 px) so cards no longer crowd the score
|
||||||
|
readout or action buttons; layout's `top_y` shifts down accordingly.
|
||||||
|
- **Action-bar auto-fade** — buttons fade out when the cursor leaves the
|
||||||
|
band, fade back in when it returns. Lerp at ~167 ms.
|
||||||
|
- **Visible drop-target overlay during drag** — a soft fill plus 3 px
|
||||||
|
outline drawn ABOVE stacked cards for every legal target (full fanned
|
||||||
|
column for tableaux, card-sized for foundations and empty tableaux).
|
||||||
|
Replaces the previously invisible pile-marker tint.
|
||||||
|
- **Card drop shadows** — every card casts a neutral 25 % black shadow
|
||||||
|
with a 4 px halo; cards in the active drag set switch to a lifted
|
||||||
|
shadow (40 % alpha, larger offset, bigger halo).
|
||||||
|
- **Stock remaining-count badge** — small `·N` chip at the top-right of
|
||||||
|
the stock pile so the player can see how close they are to a recycle.
|
||||||
|
Hides when the stock empties.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Foundations are unlocked.** `PileType::Foundation(Suit)` →
|
||||||
|
`Foundation(u8)` (slot 0..3). The claimed suit is derived from the
|
||||||
|
bottom card via `Pile::claimed_suit()` — no separate field, no
|
||||||
|
claim-stuck-after-undo bugs. Any Ace lands in any empty slot, and the
|
||||||
|
slot then claims that suit. `next_auto_complete_move` prefers a
|
||||||
|
claim-matched slot before falling back to the first empty slot for
|
||||||
|
Aces. Empty foundation markers render as plain placeholders (no
|
||||||
|
"C/D/H/S").
|
||||||
|
- **HUD selection label** and **hint toast** read `claimed_suit()` and
|
||||||
|
fall through to "Foundation N" / "move to foundation" only when the
|
||||||
|
slot is empty.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`shared_fontdb` now bundles FiraMono.** The hayeah SVGs reference
|
||||||
|
`Bitstream Vera Sans` and `Arial` by name. On minimal Linux installs
|
||||||
|
/ fresh Wayland sessions / chroots where neither is installed AND the
|
||||||
|
CSS-generic aliases don't resolve, card rank/suit text vanished. The
|
||||||
|
bundled font is loaded into fontdb and pinned as every CSS generic's
|
||||||
|
target so the resolver always lands on something real. Surfaced when
|
||||||
|
a second-machine pull rendered cards without glyphs.
|
||||||
|
- **Theme asset path resolution** — `AssetPath::resolve` (concatenates)
|
||||||
|
→ `resolve_embed` (RFC 1808 sibling resolution). Was producing paths
|
||||||
|
like `…/theme.ron/hearts_4.svg` and failing to load every face SVG.
|
||||||
|
- **Sync exit log spam** — `push_on_exit` silently no-ops on
|
||||||
|
`LocalOnlyProvider`'s `UnsupportedPlatform` instead of warn-spamming
|
||||||
|
every shutdown.
|
||||||
|
- **usvg font-substitution warn spam** — custom `FontResolver.select_font`
|
||||||
|
appends `Family::SansSerif` and `Family::Serif` to every query so
|
||||||
|
unmatched named families silently fall through.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- **In-progress saves invalidated.** `GameState.schema_version` bumped
|
||||||
|
1 → 2; pre-v2 `game_state.json` files silently fall through to "fresh
|
||||||
|
game on launch." Stats, progress, achievements, and settings live in
|
||||||
|
separate files and are unaffected.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 982 passing tests (was 819 at v0.10.0).
|
||||||
|
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
## [0.10.0] — 2026-04-29
|
||||||
|
|
||||||
|
PNG art pipeline plus a major dependency pass. The first release where
|
||||||
|
the binary shipped with bundled artwork.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **52 individual card face PNGs** generated via `solitaire_assetgen`.
|
||||||
|
- **Custom font** (FiraMono-Medium) loaded via `AssetServer` at startup
|
||||||
|
through the new `FontPlugin`.
|
||||||
|
- **Card backs and backgrounds** upgraded to 120×168 with richer
|
||||||
|
patterns.
|
||||||
|
- **Ambient audio loop** wired through the kira mixer.
|
||||||
|
- **Arch Linux PKGBUILDs** for the game client and sync server (under
|
||||||
|
the separate `solitaire-quest-pkgbuild` directory).
|
||||||
|
- **Workspace README, CI workflow, migration guide.**
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Bevy 0.15 → 0.18** workspace migration.
|
||||||
|
- **kira 0.9 → 0.12** audio backend migration.
|
||||||
|
- **Edition 2024**, MSRV pinned to **Rust 1.95**.
|
||||||
|
- **rand 0.9** upgrade.
|
||||||
|
- **Card rendering** moved from `Text2d` overlay to PNG-backed
|
||||||
|
`Sprite` with face/back atlases; `Text2d` retained as a headless
|
||||||
|
fallback when `CardImageSet` is absent (tests under MinimalPlugins).
|
||||||
|
- **Asset pipeline** switched from `include_bytes!()` for PNGs/TTFs to
|
||||||
|
runtime `AssetServer::load()` so artwork can be swapped without a
|
||||||
|
recompile. Audio remains embedded.
|
||||||
|
- **Removed Google Play Games Services sync backend** — redundant with
|
||||||
|
the self-hosted server.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Server JWT secret** loaded at startup (was lazy, surfaced as
|
||||||
|
intermittent 500s).
|
||||||
|
- **Daily-challenge race** in the server's seed-generation path.
|
||||||
|
- **Rate limiter** switched to `SmartIpKeyExtractor` so the limit
|
||||||
|
applies per real client IP rather than per upstream proxy.
|
||||||
|
- **Touch input** uses `MessageReader<TouchInput>` (Bevy 0.18 rename).
|
||||||
|
- **Sync push/pull races** in async task scheduling.
|
||||||
|
- **Hot-path allocations** reduced in card-rendering systems.
|
||||||
|
- **Conflict report coverage** added for sync merge edge cases.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 819 passing tests at tag time.
|
||||||
|
|
||||||
|
## [0.9.0] — 2026-04-28
|
||||||
|
|
||||||
|
Initial public-tagged release. Established the workspace structure
|
||||||
|
(`solitaire_core` / `_sync` / `_data` / `_engine` / `_server` / `_app` /
|
||||||
|
`_assetgen`), the modal scaffold via `ui_modal`, the design-token system
|
||||||
|
in `ui_theme`, and the four-tier HUD layout. Foundations were
|
||||||
|
suit-locked at this point; cards rendered as `Text2d` rank/suit overlays
|
||||||
|
with no PNG artwork yet.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Klondike core (Draw One / Draw Three modes).
|
||||||
|
- Progression system (XP, levels, 18 achievements, daily challenge,
|
||||||
|
weekly goals, special modes at level 5).
|
||||||
|
- Self-hosted sync server (Axum + SQLite + JWT auth).
|
||||||
|
- All 12 overlay screens migrated to the `ui_modal` scaffold with real
|
||||||
|
Primary/Secondary/Tertiary buttons.
|
||||||
|
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce,
|
||||||
|
deal jitter, win-cascade rotation.
|
||||||
|
- Splash screen, focus rings (Phases 1–3), tooltips infrastructure +
|
||||||
|
HUD/Settings/popover applications, achievement integration tests,
|
||||||
|
destructive-confirm verb unification, leaderboard error/idle states,
|
||||||
|
first-launch empty-state polish, hit-target accessibility fix,
|
||||||
|
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
|
||||||
|
client-side sync round-trip integration tests.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
|
||||||
|
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
|
||||||
|
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
|
||||||
|
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
|
||||||
|
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
|
||||||
|
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
|
||||||
|
[0.11.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.10.0...v0.11.0
|
||||||
|
[0.10.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.9.0...v0.10.0
|
||||||
|
[0.9.0]: https://github.com/funman300/Rusty_Solitaire/releases/tag/v0.9.0
|
||||||
@@ -1,114 +1,571 @@
|
|||||||
# Solitaire Quest — Claude Code Instructions
|
# CLAUDE.md
|
||||||
|
|
||||||
See @ARCHITECTURE.md for full project design, crate responsibilities, data models, and API reference.
|
version: unified-3.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Layout
|
# 0. Role of This File
|
||||||
|
|
||||||
```text
|
This document defines:
|
||||||
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
|
|
||||||
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
|
* **Execution rules (what Claude must do)**
|
||||||
solitaire_data/ # Persistence + SyncProvider trait + server client
|
* **System constraints (what Claude must never violate)**
|
||||||
solitaire_engine/ # Bevy ECS systems, components, plugins
|
* **Operational architecture (how code is structured)**
|
||||||
solitaire_server/ # Axum sync server binary
|
|
||||||
solitaire_app/ # Thin binary entry point
|
For full system design details:
|
||||||
assets/ # Source assets — embedded at compile time via include_bytes!()
|
→ `ARCHITECTURE.md` (authoritative source of truth)
|
||||||
|
|
||||||
|
This file overrides all conversational assumptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. System Architecture (Authoritative Mapping)
|
||||||
|
|
||||||
|
## 1.1 Crates
|
||||||
|
|
||||||
|
```text id="crate_map"
|
||||||
|
solitaire_core/ # PURE logic (no IO, no Bevy, deterministic)
|
||||||
|
solitaire_sync/ # Shared API + merge logic
|
||||||
|
solitaire_data/ # Persistence + sync client
|
||||||
|
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||||
|
solitaire_server/ # Axum backend (optional sync layer)
|
||||||
|
solitaire_app/ # Entry binary
|
||||||
|
assets/ # Runtime assets (except audio)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build & Test Commands
|
## 1.2 Architecture Source of Truth
|
||||||
|
|
||||||
```bash
|
* Full system design: `ARCHITECTURE.md`
|
||||||
# Dev run (fast compile via dynamic linking)
|
* This file NEVER redefines system design
|
||||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
* This file ONLY enforces behavior
|
||||||
|
|
||||||
# Release build
|
---
|
||||||
cargo build --workspace --release
|
|
||||||
|
|
||||||
# All tests — MUST pass before any commit
|
# 2. Hard Global Constraints (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
These override all other instructions.
|
||||||
|
|
||||||
|
## 2.1 Core Determinism
|
||||||
|
|
||||||
|
* `solitaire_core` MUST:
|
||||||
|
|
||||||
|
* be deterministic
|
||||||
|
* be side-effect free
|
||||||
|
* never depend on Bevy / IO / async
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.2 Sync Isolation
|
||||||
|
|
||||||
|
* `solitaire_sync`:
|
||||||
|
|
||||||
|
* no Bevy
|
||||||
|
* no IO
|
||||||
|
* no engine dependencies
|
||||||
|
* merge logic must be pure functions only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.3 Error Policy
|
||||||
|
|
||||||
|
* NO `unwrap()`
|
||||||
|
* NO `panic!()` in runtime/game logic
|
||||||
|
* All state transitions:
|
||||||
|
|
||||||
|
```rust id="err_model"
|
||||||
|
Result<T, MoveError>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.4 Threading Rules
|
||||||
|
|
||||||
|
* Sync must run on `AsyncComputeTaskPool`
|
||||||
|
* NEVER block Bevy main thread
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.5 Persistence Rules
|
||||||
|
|
||||||
|
* atomic writes only:
|
||||||
|
|
||||||
|
* write `.tmp`
|
||||||
|
* rename atomically
|
||||||
|
* no partial state writes allowed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.6 Security Rules
|
||||||
|
|
||||||
|
* credentials ONLY via `keyring`
|
||||||
|
* NEVER store secrets in:
|
||||||
|
|
||||||
|
* files
|
||||||
|
* logs
|
||||||
|
* source code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.7 Sync System Rules
|
||||||
|
|
||||||
|
* All sync backends implement:
|
||||||
|
|
||||||
|
```rust id="sync_trait"
|
||||||
|
trait SyncProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
* `SyncPlugin` MUST be backend-agnostic
|
||||||
|
* NEVER match on backend inside ECS systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Engine Rules (Bevy Layer)
|
||||||
|
|
||||||
|
## 3.1 ECS Design
|
||||||
|
|
||||||
|
* systems = single responsibility
|
||||||
|
* communication = Events only
|
||||||
|
* shared state = Resources only
|
||||||
|
* per-entity state = Components only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.2 Game State Authority
|
||||||
|
|
||||||
|
* ONLY `GameStateResource` can mutate game state
|
||||||
|
* UI systems MUST NOT directly modify core logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.3 UI-First Constraint (CRITICAL)
|
||||||
|
|
||||||
|
Every player action MUST:
|
||||||
|
|
||||||
|
* have a visible UI control
|
||||||
|
* NOT rely solely on keyboard shortcuts
|
||||||
|
|
||||||
|
Keyboard shortcuts are:
|
||||||
|
→ optional accelerators only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.4 Layout System
|
||||||
|
|
||||||
|
* recompute on `WindowResized`
|
||||||
|
* no fixed resolution assumptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Asset System Rules
|
||||||
|
|
||||||
|
## 4.1 Runtime Assets (AssetServer)
|
||||||
|
|
||||||
|
Loaded via:
|
||||||
|
|
||||||
|
* `CardImageSet`
|
||||||
|
* `BackgroundImageSet`
|
||||||
|
* `FontResource`
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
|
||||||
|
* cards
|
||||||
|
* backgrounds
|
||||||
|
* fonts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2 Embedded Assets
|
||||||
|
|
||||||
|
Only audio:
|
||||||
|
|
||||||
|
```text id="audio_rule"
|
||||||
|
include_bytes!()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3 Test Compatibility Rule
|
||||||
|
|
||||||
|
All asset loaders MUST accept:
|
||||||
|
|
||||||
|
```rust id="asset_fallback"
|
||||||
|
Option<Res<AssetServer>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Must degrade gracefully under `MinimalPlugins`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Code Standards
|
||||||
|
|
||||||
|
## 5.1 Error Handling
|
||||||
|
|
||||||
|
* use `thiserror`
|
||||||
|
* no `Box<dyn Error>` in libraries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2 Public API Rules
|
||||||
|
|
||||||
|
* prefer `Into<T>` over concrete types
|
||||||
|
* all public items require doc comments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.3 Derive Order
|
||||||
|
|
||||||
|
```rust id="derive_order"
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.4 Performance Rules
|
||||||
|
|
||||||
|
* NO `clone()` in hot paths
|
||||||
|
* profile before optimizing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.5 SQL Rules
|
||||||
|
|
||||||
|
* ONLY `sqlx::query!`
|
||||||
|
* NO raw SQL strings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Build & Verification Rules
|
||||||
|
|
||||||
|
These are mandatory before ANY commit.
|
||||||
|
|
||||||
|
```bash id="build_rules"
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
|
|
||||||
# Lint — MUST pass clean (zero warnings)
|
|
||||||
cargo clippy --workspace -- -D warnings
|
cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
# Run sync server locally
|
|
||||||
cargo run -p solitaire_server
|
|
||||||
|
|
||||||
# Check a single crate
|
|
||||||
cargo test -p solitaire_core
|
|
||||||
cargo clippy -p solitaire_core -- -D warnings
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hard Rules
|
# 7. Git Workflow Rules
|
||||||
|
|
||||||
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
|
## Commit format
|
||||||
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
|
|
||||||
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
|
```text id="commit_fmt"
|
||||||
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
|
type(scope): description
|
||||||
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
|
```
|
||||||
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
|
|
||||||
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
|
Examples:
|
||||||
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
|
|
||||||
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
|
* feat(core): add draw-three rules
|
||||||
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
|
* fix(engine): correct drag z-order
|
||||||
- `cargo test --workspace` must pass after every change.
|
* test(core): undo boundary cases
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Code Style
|
## Commit conditions
|
||||||
|
|
||||||
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
|
* tests must pass
|
||||||
- Prefer `Into<T>` over concrete types in public API function parameters.
|
* clippy must be clean
|
||||||
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
|
|
||||||
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
|
NEVER commit otherwise
|
||||||
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
|
|
||||||
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
|
|
||||||
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bevy Conventions
|
# 8. Change Control (ASK BEFORE DOING)
|
||||||
|
|
||||||
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
|
Claude must request confirmation before:
|
||||||
- Resources own shared state. Events communicate between systems. Components own per-entity data.
|
|
||||||
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
|
* adding dependencies
|
||||||
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
|
* modifying `solitaire_sync`
|
||||||
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
|
* changing DB schema
|
||||||
|
* introducing `unsafe`
|
||||||
|
* changing merge strategy
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Git Workflow
|
# 9. System Mental Model (IMPORTANT)
|
||||||
|
|
||||||
- Commit after each passing phase, not after every file change.
|
```text id="mental_model"
|
||||||
- Commit message format: `type(scope): description`
|
Core (rules + deterministic logic)
|
||||||
- `feat(core): add draw-three mode validation`
|
↓
|
||||||
- `fix(engine): card z-order during drag`
|
Engine (Bevy orchestration)
|
||||||
- `test(core): undo stack boundary conditions`
|
↓
|
||||||
- `chore(server): add sqlx migration 002`
|
Data layer (persistence + sync)
|
||||||
- Never commit with failing tests or clippy warnings.
|
↓
|
||||||
- Never commit secrets, `.env` files, or `*.db` files.
|
Server (optional external system)
|
||||||
|
```
|
||||||
|
|
||||||
|
Core is always the source of truth.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ask Before Doing
|
# 10. Known Platform Pitfalls
|
||||||
|
|
||||||
- Adding a new crate dependency (discuss alternatives first).
|
Must always be handled explicitly:
|
||||||
- Changing a type in `solitaire_sync` (breaking change on both client and server).
|
|
||||||
- Altering the database schema (requires a new sqlx migration).
|
* Bevy `Time` uses `f32`
|
||||||
- Introducing `unsafe` code anywhere.
|
* `sqlx::migrate!()` path is crate-relative
|
||||||
- Changing the merge strategy in `solitaire_sync::merge()`.
|
* `dirs::data_dir()` may return `None`
|
||||||
|
* Linux may lack keyring backend
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lessons Learned
|
# 11. Forbidden Patterns
|
||||||
|
|
||||||
> Add entries here when Claude makes a mistake so it isn't repeated.
|
* game logic inside Bevy systems
|
||||||
|
* duplication across crates
|
||||||
|
* blocking async calls in ECS
|
||||||
|
* insecure credential storage
|
||||||
|
* bypassing core logic layer
|
||||||
|
|
||||||
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
|
---
|
||||||
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
|
|
||||||
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
|
# 12. Execution Rules for Claude
|
||||||
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
|
|
||||||
|
When generating code:
|
||||||
|
|
||||||
|
1. respect crate boundaries
|
||||||
|
2. minimize diff size
|
||||||
|
3. do not expand scope
|
||||||
|
4. follow existing patterns
|
||||||
|
5. preserve invariants
|
||||||
|
|
||||||
|
If unclear:
|
||||||
|
→ ask before acting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Relationship to ARCHITECTURE.md
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
| --------------- | ------------------------- |
|
||||||
|
| CLAUDE.md | execution + constraints |
|
||||||
|
| ARCHITECTURE.md | system design truth |
|
||||||
|
| Both combined | full system understanding |
|
||||||
|
|
||||||
|
---
|
||||||
|
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||||
|
|
||||||
|
## 14.1 Purpose
|
||||||
|
|
||||||
|
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
||||||
|
|
||||||
|
This prevents:
|
||||||
|
|
||||||
|
* architectural drift
|
||||||
|
* irrelevant spec loading
|
||||||
|
* over-engineering
|
||||||
|
* cross-crate confusion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.2 Input Classification Step (MANDATORY)
|
||||||
|
|
||||||
|
Every request MUST be classified into exactly one task type:
|
||||||
|
|
||||||
|
```text id="task_types"
|
||||||
|
feature
|
||||||
|
bugfix
|
||||||
|
refactor
|
||||||
|
system_design
|
||||||
|
bevy_system
|
||||||
|
core_logic
|
||||||
|
sync
|
||||||
|
optimization
|
||||||
|
test
|
||||||
|
debug
|
||||||
|
```
|
||||||
|
|
||||||
|
If uncertain → ask clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.3 Context Selection Engine
|
||||||
|
|
||||||
|
After classification, Claude MUST include ONLY the relevant sections below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.4 Context Map (CORE RULESET)
|
||||||
|
|
||||||
|
### feature
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §2 Hard Global Constraints
|
||||||
|
* §3 Engine Rules
|
||||||
|
* ARCHITECTURE.md (crate of target feature only)
|
||||||
|
* relevant data models (GameState, SyncPayload if needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bugfix
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §2 Hard Global Constraints
|
||||||
|
* §5 Code Standards
|
||||||
|
* affected crate boundaries
|
||||||
|
* relevant system (engine/core/sync only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### refactor
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §3 Engine Rules
|
||||||
|
* §5 Code Standards
|
||||||
|
* §11 Forbidden Patterns
|
||||||
|
* target crate boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### system_design
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* ARCHITECTURE.md (FULL)
|
||||||
|
* §9 Mental Model
|
||||||
|
* §1 System Architecture Mapping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### core_logic
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* solitaire_core rules only
|
||||||
|
* GameState model
|
||||||
|
* MoveError model
|
||||||
|
* §2.1–2.3 constraints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bevy_system
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §3 Engine Rules
|
||||||
|
* ECS rules (Events/Resources/Components)
|
||||||
|
* UI-first constraint
|
||||||
|
* relevant plugin system only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### sync
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* SyncProvider trait
|
||||||
|
* merge strategy rules
|
||||||
|
* solitaire_sync models
|
||||||
|
* §2.6 Sync Rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### optimization
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* target crate only
|
||||||
|
* §5.4 Performance Rules
|
||||||
|
* hot path constraints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### test
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* §6 Build Rules
|
||||||
|
* relevant module
|
||||||
|
* expected invariants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### debug
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
* target file/module only
|
||||||
|
* §2.3 Error Policy
|
||||||
|
* runtime assumptions relevant to failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.5 Context Compression Rules
|
||||||
|
|
||||||
|
Claude MUST obey:
|
||||||
|
|
||||||
|
* never include full ARCHITECTURE.md unless system_design
|
||||||
|
* max 2 crates per response unless explicitly required
|
||||||
|
* prefer function-level context over file-level context
|
||||||
|
* exclude unrelated plugins/systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.6 Context Priority Order
|
||||||
|
|
||||||
|
When space is limited:
|
||||||
|
|
||||||
|
1. Hard Constraints (§2)
|
||||||
|
2. Target crate rules
|
||||||
|
3. Data models
|
||||||
|
4. Only then: architecture snippets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.7 “No Context Pollution” Rule
|
||||||
|
|
||||||
|
Claude must NOT include:
|
||||||
|
|
||||||
|
* unrelated crates
|
||||||
|
* unrelated plugins
|
||||||
|
* unused data models
|
||||||
|
* full architecture dumps
|
||||||
|
* speculative systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.8 Self-Check Before Execution
|
||||||
|
|
||||||
|
Before writing code, Claude MUST verify:
|
||||||
|
|
||||||
|
* [ ] Is only relevant context included?
|
||||||
|
* [ ] Is at least one hard constraint present?
|
||||||
|
* [ ] Am I touching more than one crate unnecessarily?
|
||||||
|
* [ ] Am I duplicating ARCHITECTURE.md content?
|
||||||
|
|
||||||
|
If any fail → revise context selection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.9 Injection Output Format (Internal Model)
|
||||||
|
|
||||||
|
Claude should behave as if it constructed:
|
||||||
|
|
||||||
|
```text id="ctx_format"
|
||||||
|
[SELECTED TASK TYPE]
|
||||||
|
|
||||||
|
[MINIMAL REQUIRED RULES]
|
||||||
|
|
||||||
|
[MINIMAL ARCHITECTURE SLICES]
|
||||||
|
|
||||||
|
[RELEVANT MODELS]
|
||||||
|
|
||||||
|
[REQUEST]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.10 Relationship to ARCHITECTURE.md
|
||||||
|
|
||||||
|
* ARCHITECTURE.md = source of truth
|
||||||
|
* CLAUDE.md = execution constraints
|
||||||
|
* THIS SECTION = filtering layer between them
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# END CONTEXT INJECTION SYSTEM
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
# CLAUDE_PROMPT_PACK.md
|
||||||
|
|
||||||
|
version: 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0. GLOBAL INSTRUCTION (prepend to every prompt)
|
||||||
|
|
||||||
|
```
|
||||||
|
You must follow CLAUDE_SPEC.md strictly.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do not expand scope beyond what is defined
|
||||||
|
- Do not refactor unrelated code
|
||||||
|
- Do not introduce new dependencies
|
||||||
|
- Prefer minimal, surgical changes
|
||||||
|
- Use existing patterns in the codebase
|
||||||
|
- Return minimal diffs or changed functions only
|
||||||
|
|
||||||
|
Before writing code:
|
||||||
|
1. List relevant constraints from CLAUDE_SPEC.md
|
||||||
|
2. Identify risks
|
||||||
|
3. Then implement
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. FEATURE IMPLEMENTATION
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Feature Implementation
|
||||||
|
|
||||||
|
feature: "<name>"
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<clear outcome>"
|
||||||
|
|
||||||
|
scope:
|
||||||
|
crates: []
|
||||||
|
systems: []
|
||||||
|
files: []
|
||||||
|
|
||||||
|
non_goals:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must follow CLAUDE_SPEC.md
|
||||||
|
- event-driven architecture required
|
||||||
|
- no blocking operations
|
||||||
|
- no cross-crate leakage
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- ""
|
||||||
|
- ""
|
||||||
|
|
||||||
|
edge_cases:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Patterns
|
||||||
|
|
||||||
|
Use this pattern for systems:
|
||||||
|
<PASTE EXISTING SYSTEM SNIPPET HERE>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
intent:
|
||||||
|
plan:
|
||||||
|
constraints_used:
|
||||||
|
risks:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
(minimal diffs only)
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. BUGFIX
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Bug Fix
|
||||||
|
|
||||||
|
bug_description:
|
||||||
|
"<what is broken>"
|
||||||
|
|
||||||
|
expected_behavior:
|
||||||
|
"<correct behavior>"
|
||||||
|
|
||||||
|
root_cause_hint (optional):
|
||||||
|
""
|
||||||
|
|
||||||
|
scope:
|
||||||
|
crates: []
|
||||||
|
files: []
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- minimal fix only
|
||||||
|
- no refactors unless required
|
||||||
|
- must add regression protection if applicable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. Identify root cause
|
||||||
|
2. Fix it minimally
|
||||||
|
3. Preserve all invariants
|
||||||
|
4. Do not change unrelated logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
root_cause:
|
||||||
|
fix_strategy:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
(minimal diff)
|
||||||
|
|
||||||
|
regression_test (only if high-value):
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. REFACTOR
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Refactor
|
||||||
|
|
||||||
|
target:
|
||||||
|
"<what is being improved>"
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<what improves>"
|
||||||
|
|
||||||
|
scope:
|
||||||
|
crates: []
|
||||||
|
files: []
|
||||||
|
|
||||||
|
non_goals:
|
||||||
|
- no behavior changes
|
||||||
|
- no new features
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must preserve behavior exactly
|
||||||
|
- must respect crate boundaries
|
||||||
|
- must not duplicate logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactor Type
|
||||||
|
|
||||||
|
- [ ] simplify logic
|
||||||
|
- [ ] reduce duplication
|
||||||
|
- [ ] improve readability
|
||||||
|
- [ ] performance (non-invasive)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
issues_found:
|
||||||
|
|
||||||
|
refactor_plan:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
(diff only)
|
||||||
|
|
||||||
|
verification:
|
||||||
|
- behavior unchanged: yes/no
|
||||||
|
- invariants preserved: yes/no
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. SYSTEM DESIGN (NEW FEATURE)
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: System Design
|
||||||
|
|
||||||
|
feature:
|
||||||
|
"<name>"
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<what problem it solves>"
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must fit existing architecture
|
||||||
|
- must follow plugin + event model
|
||||||
|
- must not violate crate boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Output
|
||||||
|
|
||||||
|
design:
|
||||||
|
|
||||||
|
components:
|
||||||
|
- plugins:
|
||||||
|
- systems:
|
||||||
|
- events:
|
||||||
|
- resources:
|
||||||
|
|
||||||
|
data_flow:
|
||||||
|
(step-by-step)
|
||||||
|
|
||||||
|
integration_points:
|
||||||
|
- where it connects to existing systems
|
||||||
|
|
||||||
|
risks:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
tradeoffs:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DO NOT
|
||||||
|
|
||||||
|
- write full implementation
|
||||||
|
- modify unrelated systems
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. NEW BEVY SYSTEM
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Add Bevy System
|
||||||
|
|
||||||
|
system_name:
|
||||||
|
""
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
(event or condition)
|
||||||
|
|
||||||
|
reads:
|
||||||
|
[Resources]
|
||||||
|
|
||||||
|
writes:
|
||||||
|
[Resources]
|
||||||
|
|
||||||
|
emits:
|
||||||
|
[Events]
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must be event-driven
|
||||||
|
- must not directly mutate unrelated state
|
||||||
|
- must be single responsibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
system_signature:
|
||||||
|
|
||||||
|
implementation:
|
||||||
|
(code only)
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. CORE LOGIC FUNCTION (solitaire_core)
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Core Logic Implementation
|
||||||
|
|
||||||
|
function:
|
||||||
|
"<name>"
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<what it does>"
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- no IO
|
||||||
|
- no async
|
||||||
|
- no Bevy
|
||||||
|
- deterministic
|
||||||
|
|
||||||
|
invariants:
|
||||||
|
- ""
|
||||||
|
- ""
|
||||||
|
|
||||||
|
errors:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
constraints_checked:
|
||||||
|
|
||||||
|
implementation:
|
||||||
|
(code only)
|
||||||
|
|
||||||
|
edge_case_handling:
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. SYNC / MERGE LOGIC
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Sync Logic
|
||||||
|
|
||||||
|
goal:
|
||||||
|
"<what is being merged or synced>"
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- must be deterministic
|
||||||
|
- must be idempotent
|
||||||
|
- must be lossless
|
||||||
|
- must not delete data
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- counters → max
|
||||||
|
- times → min
|
||||||
|
- collections → union
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
|
||||||
|
merge_logic:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
|
||||||
|
invariants_verified:
|
||||||
|
- deterministic
|
||||||
|
- idempotent
|
||||||
|
- lossless
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. PERFORMANCE OPTIMIZATION
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Optimization
|
||||||
|
|
||||||
|
target:
|
||||||
|
"<what is slow>"
|
||||||
|
|
||||||
|
constraints:CLAUDE_WORKFLOW.md
|
||||||
|
- no behavior change
|
||||||
|
- no architecture change
|
||||||
|
- minimal code changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
bottleneck:
|
||||||
|
|
||||||
|
optimization_strategy:
|
||||||
|
|
||||||
|
code_changes:
|
||||||
|
|
||||||
|
impact_estimate:
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. TEST GENERATION (STRICT MODE)
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Test Generation
|
||||||
|
|
||||||
|
target:
|
||||||
|
"<function/system>"
|
||||||
|
|
||||||
|
reason:
|
||||||
|
- bugfix | complex logic | invariant protection
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
- no redundant tests
|
||||||
|
- must test real behavior
|
||||||
|
- must fail if logic breaks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
- ""
|
||||||
|
|
||||||
|
test_code:
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. DEBUGGING / INVESTIGATION
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Debug
|
||||||
|
|
||||||
|
problem:
|
||||||
|
"<symptom>"
|
||||||
|
|
||||||
|
context:
|
||||||
|
"<relevant code or system>"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Steps
|
||||||
|
|
||||||
|
1. List possible causes
|
||||||
|
2. Narrow down most likely
|
||||||
|
3. Suggest verification steps
|
||||||
|
4. Provide minimal fix
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
hypotheses:
|
||||||
|
|
||||||
|
most_likely:
|
||||||
|
|
||||||
|
verification_steps:
|
||||||
|
|
||||||
|
fix:
|
||||||
|
|
||||||
|
notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. HARD CONSTRAINT OVERRIDE (RARE)
|
||||||
|
|
||||||
|
```
|
||||||
|
# TASK: Exception Handling
|
||||||
|
|
||||||
|
reason:
|
||||||
|
"<why constraints must be bent>"
|
||||||
|
|
||||||
|
requested_exception:
|
||||||
|
"<rule being broken>"
|
||||||
|
|
||||||
|
justification:
|
||||||
|
"<why unavoidable>"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
|
||||||
|
alternatives_considered:
|
||||||
|
|
||||||
|
final_decision:
|
||||||
|
|
||||||
|
risk:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. STOP CONDITIONS (always append)
|
||||||
|
|
||||||
|
```
|
||||||
|
Stop when:
|
||||||
|
- acceptance criteria are met
|
||||||
|
- code is minimal and correct
|
||||||
|
|
||||||
|
Do NOT:
|
||||||
|
- expand scope
|
||||||
|
- refactor unrelated code
|
||||||
|
- optimize prematurely
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# END
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# CLAUDE_SPEC.md
|
||||||
|
|
||||||
|
version: 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Global Rules
|
||||||
|
|
||||||
|
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
|
||||||
|
|
||||||
|
rules:
|
||||||
|
|
||||||
|
* id: single_source_of_truth
|
||||||
|
description: "GameStateResource is the only mutable game state in runtime"
|
||||||
|
|
||||||
|
* id: sync_is_additive
|
||||||
|
description: "Remote data must never destructively overwrite local data"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Crate Graph
|
||||||
|
|
||||||
|
crates:
|
||||||
|
solitaire_core:
|
||||||
|
depends_on: [rand, serde, chrono]
|
||||||
|
forbidden_deps: [bevy, reqwest, tokio, std::fs]
|
||||||
|
|
||||||
|
solitaire_sync:
|
||||||
|
depends_on: [serde, serde_json, uuid, chrono]
|
||||||
|
role: "shared_types"
|
||||||
|
|
||||||
|
solitaire_data:
|
||||||
|
depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring]
|
||||||
|
role: "persistence_and_sync"
|
||||||
|
|
||||||
|
solitaire_engine:
|
||||||
|
depends_on: [bevy, kira, solitaire_core, solitaire_data]
|
||||||
|
role: "runtime_engine"
|
||||||
|
|
||||||
|
solitaire_server:
|
||||||
|
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
||||||
|
role: "backend"
|
||||||
|
|
||||||
|
solitaire_app:
|
||||||
|
depends_on: [solitaire_engine]
|
||||||
|
role: "entrypoint"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Data Ownership
|
||||||
|
|
||||||
|
ownership:
|
||||||
|
GameState:
|
||||||
|
owner: solitaire_core
|
||||||
|
mutable_in: solitaire_engine
|
||||||
|
access_pattern: "via GameStateResource only"
|
||||||
|
|
||||||
|
StatsSnapshot:
|
||||||
|
owner: solitaire_data
|
||||||
|
|
||||||
|
PlayerProgress:
|
||||||
|
owner: solitaire_data
|
||||||
|
|
||||||
|
AchievementRecord:
|
||||||
|
owner: solitaire_data
|
||||||
|
|
||||||
|
SyncPayload:
|
||||||
|
owner: solitaire_sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. State Transitions
|
||||||
|
|
||||||
|
state_machine:
|
||||||
|
GameState:
|
||||||
|
transitions:
|
||||||
|
- action: move_cards
|
||||||
|
returns: Result<GameState, MoveError>
|
||||||
|
|
||||||
|
```
|
||||||
|
- action: draw
|
||||||
|
returns: Result<GameState, MoveError>
|
||||||
|
|
||||||
|
- action: undo
|
||||||
|
returns: Result<GameState, MoveError>
|
||||||
|
|
||||||
|
invariants:
|
||||||
|
- "52 cards always exist"
|
||||||
|
- "no duplicate card IDs"
|
||||||
|
- "all cards belong to exactly one pile"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Event System
|
||||||
|
|
||||||
|
events:
|
||||||
|
|
||||||
|
input:
|
||||||
|
- MoveRequestEvent
|
||||||
|
- DrawRequestEvent
|
||||||
|
- UndoRequestEvent
|
||||||
|
- NewGameRequestEvent
|
||||||
|
|
||||||
|
state:
|
||||||
|
- StateChangedEvent
|
||||||
|
- GameWonEvent
|
||||||
|
|
||||||
|
meta:
|
||||||
|
- AchievementUnlockedEvent
|
||||||
|
- SyncCompleteEvent
|
||||||
|
|
||||||
|
rules:
|
||||||
|
|
||||||
|
* "Input events trigger core logic"
|
||||||
|
* "Core logic emits state events"
|
||||||
|
* "UI reacts to state events only"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sync Contract
|
||||||
|
|
||||||
|
sync:
|
||||||
|
|
||||||
|
provider_trait:
|
||||||
|
methods:
|
||||||
|
- pull() -> SyncPayload
|
||||||
|
- push(payload) -> SyncResponse
|
||||||
|
|
||||||
|
guarantees:
|
||||||
|
- "non-blocking during gameplay"
|
||||||
|
- "blocking allowed on exit only"
|
||||||
|
|
||||||
|
merge:
|
||||||
|
rules:
|
||||||
|
counters: "max"
|
||||||
|
best_times: "min"
|
||||||
|
collections: "union"
|
||||||
|
achievements: "never removed"
|
||||||
|
|
||||||
|
```
|
||||||
|
properties:
|
||||||
|
- deterministic
|
||||||
|
- idempotent
|
||||||
|
- lossless
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Persistence
|
||||||
|
|
||||||
|
storage:
|
||||||
|
|
||||||
|
format: json
|
||||||
|
|
||||||
|
files:
|
||||||
|
- stats.json
|
||||||
|
- progress.json
|
||||||
|
- achievements.json
|
||||||
|
- settings.json
|
||||||
|
- game_state.json
|
||||||
|
|
||||||
|
guarantees:
|
||||||
|
- atomic_write: true
|
||||||
|
- crash_safe: true
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Engine Rules
|
||||||
|
|
||||||
|
engine:
|
||||||
|
|
||||||
|
mutation_rules:
|
||||||
|
- "Only GameLogicSystem mutates GameState"
|
||||||
|
- "UI systems are read-only"
|
||||||
|
|
||||||
|
threading:
|
||||||
|
- "sync runs on AsyncComputeTaskPool"
|
||||||
|
- "main thread must never block"
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
pattern: "feature_isolation"
|
||||||
|
communication: "events"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Server Contract
|
||||||
|
|
||||||
|
server:
|
||||||
|
|
||||||
|
auth:
|
||||||
|
method: jwt
|
||||||
|
access_expiry: 24h
|
||||||
|
refresh_expiry: 30d
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- POST /api/auth/register
|
||||||
|
- POST /api/auth/login
|
||||||
|
- GET /api/sync/pull
|
||||||
|
- POST /api/sync/push
|
||||||
|
|
||||||
|
limits:
|
||||||
|
payload_max: 1MB
|
||||||
|
rate_limit: "10 req/min auth routes"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Achievement System
|
||||||
|
|
||||||
|
achievements:
|
||||||
|
|
||||||
|
definition_location: solitaire_core
|
||||||
|
state_location: solitaire_data
|
||||||
|
|
||||||
|
types:
|
||||||
|
- condition_based
|
||||||
|
- event_driven
|
||||||
|
|
||||||
|
rule:
|
||||||
|
- "achievements cannot be revoked"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing Rules
|
||||||
|
|
||||||
|
testing:
|
||||||
|
|
||||||
|
philosophy:
|
||||||
|
- "test real failures"
|
||||||
|
- "avoid redundant tests"
|
||||||
|
|
||||||
|
required_coverage:
|
||||||
|
solitaire_core:
|
||||||
|
- move_validation
|
||||||
|
- undo_integrity
|
||||||
|
- win_detection
|
||||||
|
|
||||||
|
```
|
||||||
|
solitaire_sync:
|
||||||
|
- merge_correctness
|
||||||
|
- idempotency
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Prohibited Patterns
|
||||||
|
|
||||||
|
(See CLAUDE.md §11 for the canonical forbidden-patterns list.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Extension Points
|
||||||
|
|
||||||
|
extensibility:
|
||||||
|
|
||||||
|
sync_backends:
|
||||||
|
pattern: "implement SyncProvider"
|
||||||
|
|
||||||
|
game_modes:
|
||||||
|
location: solitaire_core::GameMode
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
rule: "new feature = new plugin"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Validation Checklist (for Claude)
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
* check: "crate dependency rules respected"
|
||||||
|
* check: "no panics in core"
|
||||||
|
* check: "events used for cross-system communication"
|
||||||
|
* check: "GameState mutations centralized"
|
||||||
|
* check: "merge function properties preserved"
|
||||||
|
* check: "no blocking operations in main loop"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Mental Model
|
||||||
|
|
||||||
|
model:
|
||||||
|
|
||||||
|
layers:
|
||||||
|
- core
|
||||||
|
- engine
|
||||||
|
- data
|
||||||
|
- server
|
||||||
|
|
||||||
|
flow:
|
||||||
|
- input -> engine -> core -> engine -> ui
|
||||||
|
- data <-> sync <-> server
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# CLAUDE_WORKFLOW.md
|
||||||
|
|
||||||
|
version: 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Overview
|
||||||
|
|
||||||
|
This workflow defines a **two-agent system**:
|
||||||
|
|
||||||
|
* **Builder Agent** → writes and modifies code
|
||||||
|
* **Guardian Agent** → enforces architecture + rejects invalid changes
|
||||||
|
|
||||||
|
No code is considered valid unless it passes Guardian validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Agent Roles
|
||||||
|
|
||||||
|
### 1.1 Builder Agent
|
||||||
|
|
||||||
|
role: "code_generation"
|
||||||
|
|
||||||
|
responsibilities:
|
||||||
|
|
||||||
|
* implement features
|
||||||
|
* refactor code
|
||||||
|
* generate tests (only when justified)
|
||||||
|
* follow CLAUDE_SPEC.md
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
|
||||||
|
* cannot bypass validation
|
||||||
|
* must declare intent before writing code
|
||||||
|
|
||||||
|
output_contract:
|
||||||
|
must_produce:
|
||||||
|
- change_summary
|
||||||
|
- files_modified
|
||||||
|
- reasoning (short)
|
||||||
|
- code_diff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Guardian Agent
|
||||||
|
|
||||||
|
role: "architecture_enforcement"
|
||||||
|
|
||||||
|
responsibilities:
|
||||||
|
|
||||||
|
* validate against CLAUDE_SPEC.md
|
||||||
|
* detect violations
|
||||||
|
* reject or approve changes
|
||||||
|
* suggest minimal fixes (not full rewrites)
|
||||||
|
|
||||||
|
constraints:
|
||||||
|
|
||||||
|
* no feature implementation
|
||||||
|
* no large rewrites
|
||||||
|
* must be deterministic
|
||||||
|
|
||||||
|
output_contract:
|
||||||
|
must_produce:
|
||||||
|
- status: APPROVED | REJECTED
|
||||||
|
- violations[]
|
||||||
|
- required_fixes[]
|
||||||
|
- optional_improvements[]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Workflow Pipeline
|
||||||
|
|
||||||
|
```text
|
||||||
|
User Request
|
||||||
|
↓
|
||||||
|
Builder Agent (proposal + code)
|
||||||
|
↓
|
||||||
|
Guardian Agent (validation)
|
||||||
|
↓
|
||||||
|
IF approved → commit
|
||||||
|
IF rejected → feedback → Builder retry
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Builder Protocol
|
||||||
|
|
||||||
|
### Step 1 — Intent Declaration
|
||||||
|
|
||||||
|
Builder MUST start with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
intent:
|
||||||
|
feature: "<name>"
|
||||||
|
crates_touched: []
|
||||||
|
systems_affected: []
|
||||||
|
risk_level: low|medium|high
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Plan
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
plan:
|
||||||
|
- step: "..."
|
||||||
|
- step: "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Implementation
|
||||||
|
|
||||||
|
* Only modify declared crates
|
||||||
|
* Follow ownership rules
|
||||||
|
* Use events for cross-system communication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Output
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
change_summary: "..."
|
||||||
|
|
||||||
|
files_modified:
|
||||||
|
- path: ...
|
||||||
|
change: "..."
|
||||||
|
|
||||||
|
violations_self_check:
|
||||||
|
- none | list
|
||||||
|
|
||||||
|
notes: "short reasoning"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Guardian Protocol
|
||||||
|
|
||||||
|
### Step 1 — Spec Validation
|
||||||
|
|
||||||
|
Check against:
|
||||||
|
|
||||||
|
* crate boundaries
|
||||||
|
* mutation rules
|
||||||
|
* event system usage
|
||||||
|
* sync guarantees
|
||||||
|
* forbidden patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Invariant Validation
|
||||||
|
|
||||||
|
Must verify:
|
||||||
|
|
||||||
|
* GameState invariants preserved
|
||||||
|
* no new panic paths
|
||||||
|
* no blocking calls in engine
|
||||||
|
* merge properties unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Output Decision
|
||||||
|
|
||||||
|
#### APPROVED
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: APPROVED
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- "no violations"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### REJECTED
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: REJECTED
|
||||||
|
|
||||||
|
violations:
|
||||||
|
- id: core_purity_violation
|
||||||
|
file: "solitaire_core/src/..."
|
||||||
|
reason: "uses std::fs"
|
||||||
|
|
||||||
|
required_fixes:
|
||||||
|
- "move IO to solitaire_data"
|
||||||
|
|
||||||
|
optional_improvements:
|
||||||
|
- "simplify event naming"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Enforcement Rules
|
||||||
|
|
||||||
|
### Hard Fail (automatic rejection)
|
||||||
|
|
||||||
|
* core crate uses IO / Bevy / network
|
||||||
|
* GameState mutated outside GameLogicSystem
|
||||||
|
* blocking async on main thread
|
||||||
|
* duplicate logic across crates
|
||||||
|
* merge function altered incorrectly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Soft Fail (allowed but flagged)
|
||||||
|
|
||||||
|
* unnecessary complexity
|
||||||
|
* redundant tests
|
||||||
|
* minor architectural drift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Iteration Loop
|
||||||
|
|
||||||
|
Max attempts per task: **3**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Attempt 1 → Reject → Fix
|
||||||
|
Attempt 2 → Reject → Fix
|
||||||
|
Attempt 3 → Final decision
|
||||||
|
```
|
||||||
|
|
||||||
|
If still failing:
|
||||||
|
→ escalate to user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Diff Strategy
|
||||||
|
|
||||||
|
Builder MUST produce:
|
||||||
|
|
||||||
|
* minimal diffs
|
||||||
|
* no unrelated refactors
|
||||||
|
* no formatting-only changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Test Strategy Integration
|
||||||
|
|
||||||
|
Builder rules:
|
||||||
|
|
||||||
|
* only add tests if:
|
||||||
|
|
||||||
|
* fixing a bug
|
||||||
|
* protecting complex logic
|
||||||
|
* validating invariants
|
||||||
|
|
||||||
|
Guardian rejects:
|
||||||
|
|
||||||
|
* redundant tests
|
||||||
|
* no-op tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Optional Extensions
|
||||||
|
|
||||||
|
### 9.1 Third Agent (Optimizer)
|
||||||
|
|
||||||
|
role: performance + cleanup
|
||||||
|
|
||||||
|
runs AFTER approval:
|
||||||
|
|
||||||
|
* reduce allocations
|
||||||
|
* simplify logic
|
||||||
|
* improve ECS scheduling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 CI Integration
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Builder → Guardian → cargo check → clippy → tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Guardian runs BEFORE compilation to catch structural issues early.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Example Interaction
|
||||||
|
|
||||||
|
### Builder
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
intent:
|
||||||
|
feature: "undo stack limit fix"
|
||||||
|
crates_touched: [solitaire_core]
|
||||||
|
risk_level: low
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
change_summary: "limit undo stack to 64 entries"
|
||||||
|
|
||||||
|
files_modified:
|
||||||
|
- solitaire_core/src/game_state.rs
|
||||||
|
|
||||||
|
notes: "prevents unbounded memory growth"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Guardian
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: APPROVED
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- "respects core constraints"
|
||||||
|
- "no invariant violations"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Mental Model
|
||||||
|
|
||||||
|
* Builder = **creative**
|
||||||
|
* Guardian = **strict**
|
||||||
|
|
||||||
|
Builder explores
|
||||||
|
Guardian enforces
|
||||||
|
|
||||||
|
Neither replaces the other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Success Criteria
|
||||||
|
|
||||||
|
System is working if:
|
||||||
|
|
||||||
|
* architectural violations go to ~0
|
||||||
|
* code stays consistent across features
|
||||||
|
* refactors become safe
|
||||||
|
* complexity grows sub-linearly
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Credits
|
||||||
|
|
||||||
|
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
|
||||||
|
the work of many open-source projects and a small handful of third-party
|
||||||
|
assets. This file lists every component that ships in the binary or in the
|
||||||
|
`assets/` directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code & Framework
|
||||||
|
|
||||||
|
| Component | License | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| [Bevy 0.18](https://bevyengine.org/) | MIT OR Apache-2.0 | Game engine, ECS, rendering, UI |
|
||||||
|
| [kira 0.12](https://crates.io/crates/kira) | MIT OR Apache-2.0 | Audio playback (mixer, sub-tracks, looping ambient) |
|
||||||
|
| [serde](https://crates.io/crates/serde) / [serde_json](https://crates.io/crates/serde_json) | MIT OR Apache-2.0 | Serialization for save files and the sync API |
|
||||||
|
| [tokio](https://crates.io/crates/tokio) | MIT | Async runtime for the sync client and server |
|
||||||
|
| [axum 0.8](https://crates.io/crates/axum) | MIT | HTTP framework for the self-hosted sync server |
|
||||||
|
| [sqlx 0.8](https://crates.io/crates/sqlx) | MIT OR Apache-2.0 | Compile-time-checked SQLite access on the server |
|
||||||
|
| [reqwest 0.13](https://crates.io/crates/reqwest) | MIT OR Apache-2.0 | HTTP client for the sync provider |
|
||||||
|
| [jsonwebtoken 10](https://crates.io/crates/jsonwebtoken) | MIT | JWT issuance and validation |
|
||||||
|
| [bcrypt 0.19](https://crates.io/crates/bcrypt) | MIT | Password hashing on the server |
|
||||||
|
| [keyring 4](https://crates.io/crates/keyring) | MIT OR Apache-2.0 | OS keychain integration for credential storage |
|
||||||
|
| [tower-governor 0.8](https://crates.io/crates/tower-governor) | MIT | Rate limiting on `/api/auth/*` |
|
||||||
|
| [chrono](https://crates.io/crates/chrono) | MIT OR Apache-2.0 | Date / time handling |
|
||||||
|
| [uuid](https://crates.io/crates/uuid) | MIT OR Apache-2.0 | User and session identifiers |
|
||||||
|
| [thiserror](https://crates.io/crates/thiserror) | MIT OR Apache-2.0 | Error type derive |
|
||||||
|
| [rand 0.9](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Seeded shuffler in `solitaire_core` |
|
||||||
|
| [png 0.17](https://crates.io/crates/png) | MIT OR Apache-2.0 | PNG encoder used by `solitaire_assetgen` |
|
||||||
|
| [ab_glyph 0.2](https://crates.io/crates/ab_glyph) | Apache-2.0 | Glyph rasterization for generated card art |
|
||||||
|
|
||||||
|
The full transitive dependency tree (several hundred crates) is captured in
|
||||||
|
`Cargo.lock` and reachable via `cargo tree`. Every crate brought in is
|
||||||
|
MIT, Apache-2.0, BSD-style, or a dual-licensed combination thereof — no
|
||||||
|
copyleft code is statically linked into the game binary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
### Card artwork
|
||||||
|
|
||||||
|
| File(s) | Source | License |
|
||||||
|
|---|---|---|
|
||||||
|
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
|
||||||
|
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) |
|
||||||
|
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
|
||||||
|
| `assets/cards/backs/back_0.png` – `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||||
|
|
||||||
|
The face SVGs come from Howard Yeh's `playing-cards-assets` repository, which
|
||||||
|
is itself derived from the public-domain `vector-playing-cards` Google Code
|
||||||
|
project. The art is redistributed under the MIT license — see the upstream
|
||||||
|
repository for the full notice. The files ship unmodified in the bundled
|
||||||
|
default theme; user-supplied themes can override them per-installation
|
||||||
|
through the runtime SVG theming system documented in `CARD_PLAN.md`.
|
||||||
|
|
||||||
|
The default card back is original work by this project, midnight-purple
|
||||||
|
themed to match the rest of the UI palette.
|
||||||
|
|
||||||
|
### Backgrounds
|
||||||
|
|
||||||
|
| File(s) | Source | License |
|
||||||
|
|---|---|---|
|
||||||
|
| `assets/backgrounds/bg_0.png` – `bg_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
| File | Source | License |
|
||||||
|
|---|---|---|
|
||||||
|
| `assets/fonts/main.ttf` (FiraMono-Medium) | [mozilla/Fira](https://github.com/mozilla/Fira) | SIL Open Font License 1.1 |
|
||||||
|
|
||||||
|
The OFL permits redistribution and embedding in software so long as the font
|
||||||
|
file itself is not sold standalone. The file ships unmodified.
|
||||||
|
|
||||||
|
### Audio
|
||||||
|
|
||||||
|
All six WAV files in `assets/audio/` are **original work** — there are no
|
||||||
|
third-party audio samples in this project. They are synthesized
|
||||||
|
programmatically by `solitaire_assetgen/src/bin/gen_sfx.rs`, which writes
|
||||||
|
44.1 kHz mono 16-bit PCM WAVs using a hand-rolled WAV writer (no `hound` or
|
||||||
|
`dasp` dependency). The synthesis stack is entirely additive: sine /
|
||||||
|
square waves, layered harmonics, deterministic LCG noise, AR envelopes,
|
||||||
|
and a slow LFO for the ambient track.
|
||||||
|
|
||||||
|
| File | Synthesis approach |
|
||||||
|
|---|---|
|
||||||
|
| `card_deal.wav` | Filtered LCG noise with a sweeping low-pass cutoff for a "whoosh" |
|
||||||
|
| `card_flip.wav` | High-passed LCG noise under a fast AR envelope |
|
||||||
|
| `card_place.wav` | 120 Hz sine body + filtered noise click |
|
||||||
|
| `card_invalid.wav` | Two dissonant square tones (196 Hz + 207.65 Hz) beating against each other |
|
||||||
|
| `win_fanfare.wav` | C-major arpeggio (C5 / E5 / G5 / C6) with sine + 2nd harmonic |
|
||||||
|
| `ambient_loop.wav` | 55 Hz fundamental with 2nd and 3rd harmonics, modulated by a 0.2 Hz LFO; loop length is chosen so the tone and LFO both complete an integer number of cycles for seamless looping |
|
||||||
|
|
||||||
|
Audio files are MIT-licensed alongside the rest of this project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License Summary
|
||||||
|
|
||||||
|
- **Project code:** MIT — see [LICENSE](LICENSE).
|
||||||
|
- **Card face artwork (52 SVGs from hayeah/playing-cards-assets, plus the
|
||||||
|
pre-rendered PNGs in `assets/cards/faces/`):** MIT, redistributed
|
||||||
|
unmodified. The original `vector-playing-cards` line art is itself
|
||||||
|
public domain.
|
||||||
|
- **FiraMono-Medium font:** SIL Open Font License 1.1, redistributed unmodified.
|
||||||
|
- **All other assets** (backgrounds, the default `back.svg`, generated card
|
||||||
|
backs, every audio file) are original work covered by this project's MIT
|
||||||
|
license.
|
||||||
|
|
||||||
|
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
|
||||||
|
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
|
||||||
|
and OFL (FiraMono) notices remain visible to end users.
|
||||||
@@ -7,6 +7,7 @@ members = [
|
|||||||
"solitaire_server",
|
"solitaire_server",
|
||||||
"solitaire_app",
|
"solitaire_app",
|
||||||
"solitaire_assetgen",
|
"solitaire_assetgen",
|
||||||
|
"solitaire_wasm",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -29,15 +30,84 @@ dirs = "6"
|
|||||||
keyring = "4"
|
keyring = "4"
|
||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
|
arboard = { version = "3", default-features = false }
|
||||||
|
|
||||||
solitaire_core = { path = "solitaire_core" }
|
solitaire_core = { path = "solitaire_core" }
|
||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
solitaire_data = { path = "solitaire_data" }
|
solitaire_data = { path = "solitaire_data" }
|
||||||
solitaire_engine = { path = "solitaire_engine" }
|
solitaire_engine = { path = "solitaire_engine" }
|
||||||
|
|
||||||
bevy = "0.18"
|
# Bevy with `default-features = false` to avoid the unused
|
||||||
|
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
|
||||||
|
# Audio is handled directly by `kira` in `audio_plugin.rs`, so the
|
||||||
|
# `bevy_audio` feature is intentionally omitted. The features below
|
||||||
|
# enumerate every leaf of the standard `2d` + `ui` meta-features that
|
||||||
|
# we actually use; new features should only be added with a
|
||||||
|
# corresponding use site.
|
||||||
|
bevy = { version = "0.18", default-features = false, features = [
|
||||||
|
# default_app
|
||||||
|
"async_executor",
|
||||||
|
"bevy_asset",
|
||||||
|
"bevy_input_focus",
|
||||||
|
"bevy_log",
|
||||||
|
"bevy_state",
|
||||||
|
"bevy_window",
|
||||||
|
"custom_cursor",
|
||||||
|
"reflect_auto_register",
|
||||||
|
# default_platform (desktop subset; no android/webgl/gilrs/sysinfo)
|
||||||
|
"std",
|
||||||
|
"bevy_winit",
|
||||||
|
"default_font",
|
||||||
|
"multi_threaded",
|
||||||
|
# winit prefers Wayland when WAYLAND_DISPLAY is set on the
|
||||||
|
# session and falls through to X11 otherwise. Without `wayland`,
|
||||||
|
# winit-on-Wayland-session falls back to XWayland which renders
|
||||||
|
# the game in an X11 frame inside the Wayland compositor.
|
||||||
|
"wayland",
|
||||||
|
"x11",
|
||||||
|
# common_api
|
||||||
|
"bevy_color",
|
||||||
|
"bevy_image",
|
||||||
|
"bevy_mesh",
|
||||||
|
"bevy_shader",
|
||||||
|
"bevy_text",
|
||||||
|
"png",
|
||||||
|
# 2d rendering
|
||||||
|
"bevy_camera",
|
||||||
|
"bevy_render",
|
||||||
|
"bevy_core_pipeline",
|
||||||
|
"bevy_sprite",
|
||||||
|
"bevy_sprite_render",
|
||||||
|
# UI rendering
|
||||||
|
"bevy_ui",
|
||||||
|
"bevy_ui_render",
|
||||||
|
] }
|
||||||
kira = "0.12"
|
kira = "0.12"
|
||||||
|
|
||||||
|
# SVG rasterisation pipeline for the runtime card-theme system.
|
||||||
|
# usvg parses + simplifies; resvg renders to a tiny-skia Pixmap;
|
||||||
|
# tiny-skia provides the CPU rasteriser. All three are maintained
|
||||||
|
# together by the resvg-rs project and version in lockstep.
|
||||||
|
usvg = "0.47"
|
||||||
|
resvg = "0.47"
|
||||||
|
tiny-skia = "0.12"
|
||||||
|
|
||||||
|
# Theme manifest format. RON keeps the file human-editable while
|
||||||
|
# preserving Rust-style structures the importer can validate.
|
||||||
|
ron = "0.12"
|
||||||
|
|
||||||
|
# Importer-only: reads user-supplied theme zip archives, validates
|
||||||
|
# their contents, and unpacks them into the user themes directory.
|
||||||
|
# Default features are disabled to keep the dependency footprint small;
|
||||||
|
# only `deflate` is needed because the importer rejects other
|
||||||
|
# compression methods anyway (see Phase 7 spec).
|
||||||
|
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
|
# Importer-only test dependency: tests build zip archives in a
|
||||||
|
# scratch directory so they don't pollute the real user themes path
|
||||||
|
# on the developer's machine.
|
||||||
|
tempfile = "3.27"
|
||||||
|
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||||
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
# Solitaire Quest
|
# Solitaire Quest
|
||||||
|
|
||||||
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
|
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
|
||||||
|
system, full progression (XP / levels / achievements / daily challenges), and
|
||||||
|
optional self-hosted sync so your stats follow you across machines.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Klondike Solitaire** — Draw One and Draw Three modes
|
- **Klondike Solitaire** — Draw One and Draw Three modes; foundations are
|
||||||
|
unlocked (any Ace lands in any empty slot, the slot then claims that suit)
|
||||||
|
- **Card themes** — bundled hayeah/playing-cards-assets default plus
|
||||||
|
user-installable themes (drop a directory under the data dir or import a
|
||||||
|
zip from Settings → Cosmetic)
|
||||||
|
- **Modern HUD** — reserved top band keeps cards from crowding the score
|
||||||
|
readout; the action bar auto-fades when the cursor leaves it so it can't
|
||||||
|
compete with the play surface
|
||||||
|
- **Drag feel** — every legal drop target is highlighted in green during
|
||||||
|
drag; cards cast a soft drop shadow that lifts when picked up; the stock
|
||||||
|
pile shows a remaining-count chip so you can see how close you are to a
|
||||||
|
recycle
|
||||||
|
- **Keyboard navigation** — Tab cycles focus through buttons, arrow keys
|
||||||
|
move within picker rows, Enter activates; works across every modal and
|
||||||
|
the HUD action bar
|
||||||
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
- **Progression** — XP, levels, unlockable card backs and backgrounds
|
||||||
- **18 Achievements** — including secret ones
|
- **19 Achievements** — including secret ones
|
||||||
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
|
- **Daily Challenge** — server-seeded so every player worldwide gets the
|
||||||
|
same deal
|
||||||
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
||||||
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
||||||
- **Sync** — pull/push stats across devices via a self-hosted server
|
- **Sync** — pull/push stats across devices via a self-hosted server
|
||||||
- **Color-blind mode** — blue tint on red-suit cards
|
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
|
||||||
|
glyph
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -32,42 +50,73 @@ cargo build -p solitaire_app --release
|
|||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
|
Every action also has a visible UI button — keyboard shortcuts are optional
|
||||||
|
accelerators.
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Left click / drag | Move cards |
|
| Left click / drag | Move cards |
|
||||||
|
| Double click | Auto-move card to its best legal destination |
|
||||||
| Right click | Highlight legal moves for a card |
|
| Right click | Highlight legal moves for a card |
|
||||||
| Space / D | Draw from stock |
|
| Space / D | Draw from stock |
|
||||||
| Z / Ctrl+Z | Undo |
|
| U | Undo |
|
||||||
|
| H | Hint (highlight a legal move) |
|
||||||
| N | New game |
|
| N | New game |
|
||||||
| S | Stats overlay |
|
| Z | Zen mode |
|
||||||
| A | Achievements overlay |
|
| G | Forfeit (during pause) |
|
||||||
| P | Profile overlay |
|
| Tab / Shift+Tab | Cycle keyboard focus |
|
||||||
| O | Settings |
|
| Enter | Activate focused button / auto-complete (when badge is lit) |
|
||||||
| L | Leaderboard |
|
| Esc | Pause / dismiss modal |
|
||||||
| H | Help / controls |
|
| F1 | Help / controls |
|
||||||
| Enter | Auto-complete (when badge is lit) |
|
| F11 | Toggle fullscreen |
|
||||||
| Escape | Pause / clear selection |
|
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
|
||||||
| Arrow keys | Navigate card selection |
|
|
||||||
|
## Card themes
|
||||||
|
|
||||||
|
The default theme ships embedded in the binary, so the game runs
|
||||||
|
self-contained with no external assets. To install another theme, drop a
|
||||||
|
directory containing a `theme.ron` manifest plus 53 SVG files (52 faces +
|
||||||
|
1 back) under the platform data dir's `themes/` folder, or import a zip
|
||||||
|
from **Settings → Cosmetic**. The picker chip lights up the moment a new
|
||||||
|
theme is registered. Themes are SVG-based, so they rasterise cleanly at
|
||||||
|
whatever resolution the window happens to be.
|
||||||
|
|
||||||
## Sync Server (optional)
|
## Sync Server (optional)
|
||||||
|
|
||||||
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
|
To sync stats across machines, run the self-hosted server. See
|
||||||
|
[README_SERVER.md](README_SERVER.md) for setup instructions.
|
||||||
|
|
||||||
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
|
Once the server is running, open **Settings → Sync Backend**, enter the
|
||||||
|
server URL and your username, and register an account from within the
|
||||||
|
game.
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All tests
|
# All tests (982 passing as of v0.11.0)
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
|
|
||||||
# Just game logic (no display required)
|
# Just game logic (no display required)
|
||||||
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
cargo clippy --workspace -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||||
|
(Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
|
||||||
|
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
|
||||||
|
(MIT, derived from the public-domain `vector-playing-cards` library); the
|
||||||
|
default card back is original work; the UI font is FiraMono-Medium (OFL).
|
||||||
|
All audio is synthesized programmatically by this project. See
|
||||||
|
[CREDITS.md](CREDITS.md) for the full list and license details.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -1,212 +1,180 @@
|
|||||||
# Solitaire Quest — UX Overhaul Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-04-30 — Phase 3 complete. All 10 steps landed; ready for full smoke-test.
|
**Last updated:** 2026-05-06 (post-v0.19.0) — Tagged + pushed at
|
||||||
|
`6037596`. v0.19.0 closes the v0.18.0 punch list (async H-key hint,
|
||||||
|
persistent replay share URLs), expands desktop platform fit (Wayland
|
||||||
|
session support + monitor-aware default window size), polishes the
|
||||||
|
win-celebration and double-click animation paths, and clears two
|
||||||
|
test-flake contributors. A short-lived "Rusty Pixel" pixel-art card
|
||||||
|
theme was prototyped and reverted in the same window.
|
||||||
|
|
||||||
|
## Status at pause
|
||||||
|
|
||||||
|
- **HEAD on origin:** `6037596` (post-tag commit; the tag itself
|
||||||
|
points at this commit).
|
||||||
|
- **Working tree:** modified — `CHANGELOG.md` and
|
||||||
|
`SESSION_HANDOFF.md` carry the v0.19.0 promotion + this refresh,
|
||||||
|
ready to commit.
|
||||||
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
|
clean (verified this session).
|
||||||
|
- **Tests:** **1170 passing / 0 failing** across the workspace
|
||||||
|
(verified this session). One known flake remains:
|
||||||
|
`solitaire_engine::sync_plugin::tests::pull_failure_sets_error_status`
|
||||||
|
occasionally fails when cargo-test parallelism starves the
|
||||||
|
`AsyncComputeTaskPool` within the test's 5-update budget. Same
|
||||||
|
shape as the auto-save flake before v0.19.0's hardening; could be
|
||||||
|
fixed similarly with a wall-clock-bounded loop.
|
||||||
|
- **Tags on origin:** `v0.9.0` through `v0.18.0` (v0.19.0 ready to
|
||||||
|
push once committed).
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
Phase 3 of the UX overhaul brief is **done**. The whole engine has been migrated to the `ui_theme` design-token system + `ui_modal` scaffold. Animation system upgraded. Final literal sweep landed. The work spans 17 commits this session, from the foundation (`e14852c`) through to the final sweep (`54e024c`).
|
v0.18.0's resume-prompt menu (A–D) is closed:
|
||||||
|
|
||||||
### Design direction (already saved as project memory)
|
- ~~**A — Tag v0.18.0:**~~ shipped at `bfcd05f`.
|
||||||
|
- ~~**B — Solver-on-`AsyncComputeTaskPool` for the H-key hint:**~~
|
||||||
|
shipped at `3e11e9e`.
|
||||||
|
- **C — Desktop packaging:** still gated on artwork + signing
|
||||||
|
certs. Icon export PNGs (11 sizes, 16–1024 px) sit in
|
||||||
|
`artwork/` from the v0.18-era export; not yet wired into the
|
||||||
|
Bevy window or assembled into `.icns` / `.ico`. App icon is
|
||||||
|
the first natural step.
|
||||||
|
- ~~**D — Persistent share link:**~~ shipped at `42d90b1`.
|
||||||
|
|
||||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
The Rusty Pixel theme arc is documented as a sub-history but
|
||||||
- **Palette:** Midnight Purple base (`BG_BASE` `#1A0F2E` → `BG_ELEVATED` `#2D1B69` → `BG_ELEVATED_HI` `#3A2580` → `BG_ELEVATED_TOP` `#482F97`) + Balatro yellow primary accent (`ACCENT_PRIMARY` `#FFD23F`) + warm magenta secondary (`ACCENT_SECONDARY` `#FF6B9D`).
|
not part of v0.19.0's content:
|
||||||
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for the full direction.
|
|
||||||
|
|
||||||
### Top complaints from the original smoke test — all closed
|
| Commit | Status |
|
||||||
|
|---|---|
|
||||||
|
| `de47511` PNG-format thumbnail support | reverted |
|
||||||
|
| `17e3112` `pixel_art: bool` field + nearest-sampling opt-in | reverted |
|
||||||
|
| `21ec03b` bundle Rusty Pixel as `embedded://` theme | reverted |
|
||||||
|
| `aad8bb9` / `e41def8` / `0b3140a` reverts | landed |
|
||||||
|
|
||||||
1. **HUD too cluttered.** ✅ Closed by `73cad7e` — readouts now sit in a 4-tier vertical stack with progressive disclosure of penalty/bonus tiers.
|
The arc remains in commit history for archaeology but the
|
||||||
2. **Y/N keyboard prompts feel like debug panels.** ✅ Closed across Confirm, GameOver, Pause, Forfeit, and Settings modals — every prompt now has real Primary/Secondary/Tertiary buttons with hover/press feedback.
|
codebase reaches v0.19.0's HEAD identical to where it would be if
|
||||||
|
the arc had never landed.
|
||||||
|
|
||||||
## Foundation (done)
|
### Design direction (unchanged)
|
||||||
|
|
||||||
- **`solitaire_engine/src/ui_theme.rs`** — every design token: colours, 5-rung typography scale, 4-multiple spacing scale, three radius rungs, monotonically-ordered z-index hierarchy, motion durations with `scaled_duration(speed)` helper.
|
- **Tone:** Balatro — chunky readable type, theatrical hierarchy,
|
||||||
- **`solitaire_engine/src/ui_modal.rs`** — `spawn_modal` scaffold + `spawn_modal_header` / `spawn_modal_body_text` / `spawn_modal_actions` / `spawn_modal_button` helpers + `ButtonVariant` enum (Primary / Secondary / Tertiary) + `paint_modal_buttons` system. `UiModalPlugin` registered in `solitaire_app/src/main.rs`.
|
satisfying micro-interactions.
|
||||||
|
- **Palette:** Midnight Purple base + Balatro yellow primary + warm
|
||||||
|
magenta secondary.
|
||||||
|
|
||||||
## Commits this session (Phase 3, latest first)
|
### Canonical remote
|
||||||
|
|
||||||
|
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
||||||
|
Always push there.
|
||||||
|
|
||||||
|
## v0.19.0 (2026-05-06)
|
||||||
|
|
||||||
|
| Area | Commits | What landed |
|
||||||
|
|---|---|---|
|
||||||
|
| Async H-key hint | `3e11e9e` | New `pending_hint.rs` module: `PendingHintTask` resource, `poll_pending_hint_task` + `drop_pending_hint_on_state_change` systems, cancel-on-replace, stale-state guard via `move_count_at_spawn`. Removes the last synchronous solver hot path. |
|
||||||
|
| Persistent share URLs | `42d90b1` | `Replay.share_url: Option<String>` with `#[serde(default)]`. `poll_replay_upload_result` writes into `replays[0].share_url` + persists. Stats Copy button reads from selected replay. `LastSharedReplayUrl` deleted. |
|
||||||
|
| Auto-save flake fix | `91b7605` | `test_app` clears `PendingRestoredGame(None)` after plugin build; test re-arms the timer in a bounded loop. No production-code change. |
|
||||||
|
| Wayland support | `b57db01` | Adds `wayland` to Bevy features. winit prefers Wayland when `WAYLAND_DISPLAY` is set, falls back to X11. Native Wayland surface instead of XWayland frame. |
|
||||||
|
| Smart default window size | `b57db01` | New `apply_smart_default_window_size` Update system queries `PrimaryMonitor` and resizes the window to ~70 % of monitor's logical size on the first frame. Skipped when saved geometry was applied. |
|
||||||
|
| Win-celebration cleanup | `55c235b` | Drops the duplicate "You Win" toast that rendered behind the WinSummary modal. Cards-fly-off cascade kept; toast removed. |
|
||||||
|
| Double-click reject animation | `d7ffb16` | Single-card double-clicks with no destination now play the same shake + sound as multi-card stack misses. Both priorities' failure paths converge on one `MoveRejectedEvent` write. |
|
||||||
|
| Double-click animation dedup | `6037596` | Drops the redundant `StateChangedEvent` write in `end_drag`'s uncommitted-drag branch; previously raced an in-flight CardAnim and restarted the slide visibly. |
|
||||||
|
|
||||||
|
## Open punch list
|
||||||
|
|
||||||
|
### Carried forward
|
||||||
|
|
||||||
|
- **Desktop packaging** per `ARCHITECTURE.md §17`. Eleven icon
|
||||||
|
PNG sizes (16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 1024)
|
||||||
|
exported via `artwork/Icon Export.html` sit in `artwork/`
|
||||||
|
pending wiring. Pending: actual Bevy window-icon hookup,
|
||||||
|
macOS `.icns` assembly via `iconutil`, Windows `.ico` via
|
||||||
|
`magick convert`, Linux hicolor PNG hierarchy install,
|
||||||
|
AppImage recipe, macOS notarisation cert, Windows
|
||||||
|
Authenticode cert.
|
||||||
|
|
||||||
|
### Possible next-round candidates
|
||||||
|
|
||||||
|
- **App icon round** — wire the icon into the Bevy window via
|
||||||
|
`Window::icon`, generate `.icns` and `.ico` from the existing
|
||||||
|
PNGs. Half-day task; doesn't depend on signing certs.
|
||||||
|
- **`pull_failure_sets_error_status` flake fix** — same pattern
|
||||||
|
as the auto-save flake. Wall-clock-bounded loop instead of
|
||||||
|
fixed 5-update budget. ~10 lines.
|
||||||
|
- **Settings UI for "open at this size on launch"** — once the
|
||||||
|
smart-default-size system is shipping, expose a checkbox to
|
||||||
|
*disable* it (player who specifically wants 1280×800 every
|
||||||
|
time). Trivial.
|
||||||
|
- **Persistent share link URL on selector caption** — surface
|
||||||
|
whether the currently-selected replay has a `share_url`
|
||||||
|
populated (e.g. "Replay 3 / 8 \u{2022} Shareable") so players
|
||||||
|
know which entries the Copy button can copy.
|
||||||
|
|
||||||
|
### Process notes (from this round)
|
||||||
|
|
||||||
|
- **Async port template (worked again):** the H-key port
|
||||||
|
followed `d489e7a`'s `PendingNewGameSeed` shape one-to-one
|
||||||
|
and the second async port required no new infrastructure.
|
||||||
|
Future async ports (e.g. moving `try_solve_with_first_move`'s
|
||||||
|
full-search variant, if it ever surfaces in the picker UI)
|
||||||
|
should follow the same shape.
|
||||||
|
- **Rusty Pixel reverted cleanly:** `git revert` of three
|
||||||
|
contiguous feature commits produced a clean three-revert
|
||||||
|
sequence with no manual conflict resolution. Bisect remains
|
||||||
|
fast over the full v0.19.0 history because the reverts are
|
||||||
|
individual commits, not a squash.
|
||||||
|
- **Defensive event writes pattern:** the
|
||||||
|
`auto_save_writes_after_30_seconds` flake AND the
|
||||||
|
`end_drag` double-animation bug shared a root cause:
|
||||||
|
defensive `MessageWriter` writes that originally covered an
|
||||||
|
edge case which no longer holds, but became load-bearing
|
||||||
|
once another system started paying attention to the event.
|
||||||
|
Worth a periodic pass: any event write that doesn't
|
||||||
|
correspond to a real state change is a candidate for
|
||||||
|
removal.
|
||||||
|
|
||||||
|
## Resume prompt
|
||||||
|
|
||||||
```
|
```
|
||||||
54e024c chore(engine): final literal-to-token sweep
|
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||||
3a01318 feat(engine): upgrade animations — curves, scoped settle, deal jitter, cascade rotation
|
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||||
79d3917 chore(data): derive Copy on AnimSpeed
|
Branch: master. v0.19.0 just shipped. The next natural item is
|
||||||
ba019c0 feat(engine): convert SettingsPanel to modal scaffold + Done button
|
desktop-packaging follow-through, starting with the app icon.
|
||||||
18d7c12 feat(engine): convert OnboardingPlugin to 3-slide modal flow
|
|
||||||
cb93bd9 fix(engine): pin modals via GlobalZIndex and surface forfeit-no-op toast
|
|
||||||
6723416 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
|
|
||||||
afb0879 docs: add SESSION_HANDOFF.md mid-overhaul checkpoint
|
|
||||||
3b619b8 feat(engine): convert HomeScreen to modal scaffold + Done button
|
|
||||||
37681cf feat(engine): convert LeaderboardScreen to modal scaffold + Done button
|
|
||||||
99064ce feat(engine): convert ProfileScreen to modal scaffold + Done button
|
|
||||||
de4dba6 feat(engine): convert AchievementsScreen to modal scaffold + Done button
|
|
||||||
75fc3aa feat(engine): convert StatsScreen to modal scaffold + Done button
|
|
||||||
deb034c feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
|
|
||||||
242b5fe feat(engine): convert GameOverScreen to real-button modal
|
|
||||||
3f922ed feat(engine): convert ConfirmNewGameScreen to real-button modal
|
|
||||||
8da62bd feat(engine): add ui_modal primitive (scaffold + button variants)
|
|
||||||
73cad7e feat(engine): restructure HUD into 4-tier layout, adopt design tokens
|
|
||||||
e14852c feat(engine): add ui_theme.rs design-token module
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test status:** `cargo build --workspace` clean, `cargo clippy --workspace -- -D warnings` clean, **819 tests pass / 0 failed / 8 ignored**.
|
State: HEAD at 6037596 + the v0.19.0 docs commit on top (this
|
||||||
|
session). Tag v0.19.0 points at the docs commit.
|
||||||
## Smoke-test checklist
|
|
||||||
|
|
||||||
The whole overhaul is on disk. Worth running through once end-to-end:
|
|
||||||
|
|
||||||
1. **Run the game.** `cargo run -p solitaire_app --features bevy/dynamic_linking`.
|
|
||||||
2. **HUD layout** reads as 4 stacked tiers (Score / Mode / Penalty / Selection) with the new midnight-purple palette.
|
|
||||||
3. **Open every overlay** — `S` (Stats), `A` (Achievements), `P` (Profile), `O` (Settings), `L` (Leaderboard), `M` (Home), `F1` (Help). Each is a centred card on a uniform scrim with a yellow `Done` / `Close` primary button. Hover/press states on every button.
|
|
||||||
4. **Settings.** Four sections (Audio / Gameplay / Cosmetic / Sync). Body scrolls within the modal on small windows; `Done` button stays fixed at the bottom regardless of scroll. Card-back / Background pickers tint the selected swatch with `STATE_SUCCESS`.
|
|
||||||
5. **Confirm flow.** Click `New Game` while a game is in progress — the abandon-current-game modal has real Cancel/Confirm buttons. `Y/Enter` and the yellow primary button start a new game; `N/Esc` and the secondary button cancel.
|
|
||||||
6. **Pause + Forfeit.** Press `Esc` — pause modal shows real Resume / Forfeit buttons. Forfeit button opens a Cancel/Forfeit confirmation modal stacked above the pause modal (z-index ordered correctly via `GlobalZIndex`).
|
|
||||||
7. **First-run onboarding.** Delete `settings.json` (or set `first_run_complete = false`) — three-slide flow shows: Welcome → How to play → Keyboard shortcuts. Navigate with `Next` / `Back` buttons or `→` / `←` accelerators. `Esc` skips on slide 0.
|
|
||||||
8. **Animations.**
|
|
||||||
- Slide a card to a pile — motion curves through `SmoothSnap` (slight overshoot + settle), not linear lerp.
|
|
||||||
- Drop a card on a valid destination — only the moved cards bounce; the rest of the table stays still.
|
|
||||||
- Start a new game — deal stagger is no longer mechanically uniform; cards land with subtle ±10% timing variation.
|
|
||||||
- Win a game — cascade now uses `Expressive` curve with per-card ±15° Z-rotation, screen shake driven by the new `MOTION_WIN_SHAKE_*` tokens.
|
|
||||||
9. **Resize the window** — cards still snap, no "snap-back-and-forth" jitter.
|
|
||||||
10. **Win modal** — restyled with the design tokens: midnight-purple card, yellow `Play Again` button.
|
|
||||||
|
|
||||||
## Open follow-ups (not blockers)
|
|
||||||
|
|
||||||
- **Home / Help redundancy.** Home is still a kbd-reference modal that mostly duplicates Help. Three options: (1) keep as-is, (2) convert into a true mode launcher (Classic / Daily / Zen / Challenge / Time Attack cards, locked options visibly disabled below level 5), (3) drop entirely now that the action bar covers everything Home does. Worth asking the user which direction they want.
|
|
||||||
- **Forfeit countdown toast** is now superseded by the Forfeit modal (`6723416`). Confirm the toast path is no longer reachable when smoke-testing.
|
|
||||||
- **Sub-rung pixel sizes** (1 px borders, 64/80/110/150/160 px fixed widths, 28/36/50 px specific spacings) were intentionally left as literals during the step-10 sweep — they're below the smallest `SPACE_*` rung. If the design system grows a "fine" spacing tier in the future, those become candidates for migration.
|
|
||||||
|
|
||||||
## Resume prompt for the next session
|
|
||||||
|
|
||||||
```
|
|
||||||
You are a senior Rust + Bevy developer working toward a public release
|
|
||||||
of Solitaire Quest. Working directory: /home/manage/Rusty_Solitare.
|
|
||||||
Branch: master. Apply that lens to every decision: prefer shipping
|
|
||||||
quality (polish, packaging, defaults, credits, crash safety) over
|
|
||||||
greenfield features. If something is half-done, the question is
|
|
||||||
"finish for v1 or cut for v1?" not "what else can we add?".
|
|
||||||
|
|
||||||
State: HEAD=0066ca6. Phase 3 of the UX overhaul is shipped. cargo
|
|
||||||
build / clippy --workspace -- -D warnings / test --workspace all
|
|
||||||
green — 819 tests pass / 0 fail / 8 ignored.
|
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
READ FIRST (in order, before doing anything):
|
||||||
1. SESSION_HANDOFF.md — full state, smoke-test checklist, follow-ups
|
1. SESSION_HANDOFF.md — this file
|
||||||
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
2. CHANGELOG.md — [Unreleased] is empty; [0.19.0] just landed
|
||||||
3. ARCHITECTURE.md §1, §15, §17 — design principles, platform
|
3. CLAUDE.md — unified-3.0 rule set
|
||||||
targets, deployment guide
|
4. CLAUDE_SPEC.md — formal architecture spec
|
||||||
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md
|
5. ARCHITECTURE.md — crate responsibilities + data flow
|
||||||
|
6. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
— saved feedback / project context
|
— saved feedback / project context
|
||||||
|
(machine-local; may be missing on a
|
||||||
|
fresh machine)
|
||||||
|
|
||||||
GATING SIGNAL — ASK FIRST, DON'T ASSUME:
|
DECISION TO ASK THE PLAYER FIRST:
|
||||||
Before proposing new work, ask: "Did the smoke-test (items 1-10 in
|
A. App icon — wire artwork/icon-{size}.png into Bevy's
|
||||||
SESSION_HANDOFF.md) pass, or did anything regress?" If a regression
|
Window::icon, generate .icns + .ico, drop into Linux
|
||||||
exists, fix it before opening any new thread.
|
hicolor hierarchy. Half-day task. No cert dependency.
|
||||||
|
B. Desktop packaging continued — AppImage recipe, .desktop
|
||||||
LIKELY NEXT DIRECTIONS — surface for the user to choose, don't pick
|
file, install scripts. Larger task; unlocks distro
|
||||||
unilaterally. All framed through "what does v1 release need?":
|
packaging. No cert dependency.
|
||||||
|
C. macOS / Windows signing cert acquisition — needs user
|
||||||
A. Home modal decision (open in SESSION_HANDOFF.md).
|
action; agent can't drive.
|
||||||
- keep as kbd-reference (duplicates Help — release-blocking
|
D. `pull_failure_sets_error_status` flake fix — small, well-
|
||||||
confusion?)
|
scoped. Same pattern as the v0.19.0 auto-save flake fix.
|
||||||
- repurpose as mode launcher (Classic / Daily / Zen / Challenge /
|
|
||||||
Time Attack cards, locked options below level 5)
|
|
||||||
- drop (action bar already covers every action)
|
|
||||||
|
|
||||||
B. Window + release polish — `solitaire_app/src/main.rs:34-48`
|
|
||||||
currently sets only title + resolution + min size. For public
|
|
||||||
release the window needs:
|
|
||||||
- app icon (taskbar / dock / alt-tab) — Bevy `Window::window_icon`
|
|
||||||
or platform `set_window_icon`; ship a .png/.ico asset.
|
|
||||||
- window class / app id (`Window::name`) so X11/Wayland and
|
|
||||||
Windows group taskbar entries correctly.
|
|
||||||
- persist size + position across launches (Settings already
|
|
||||||
saves to JSON; add `window_geometry` field).
|
|
||||||
- F11 (or a Settings toggle) wired to real fullscreen mode.
|
|
||||||
- centered default position on first launch (Bevy supports
|
|
||||||
`WindowPosition::Centered`).
|
|
||||||
- present_mode + vsync verification — make sure Linux/macOS
|
|
||||||
don't ship at uncapped 4000 fps.
|
|
||||||
- panic hook (`std::panic::set_hook`) that writes a crash
|
|
||||||
report next to the save files instead of silently exiting.
|
|
||||||
- macOS Info.plist / Windows .ico bundling — ARCHITECTURE.md
|
|
||||||
§17 currently only covers server deploy.
|
|
||||||
|
|
||||||
C. Sound-design audit. The scoped settle bounce (3a01318) means
|
|
||||||
audio_plugin.rs trigger sites may fire less often than before;
|
|
||||||
verify card_place / card_flip / card_invalid still feel right.
|
|
||||||
|
|
||||||
D. Sync flow end-to-end on a real second machine. Server
|
|
||||||
scaffolding exists but the register → push → pull → restore-on-
|
|
||||||
other-device round trip hasn't been exercised against the new
|
|
||||||
Settings sync section.
|
|
||||||
|
|
||||||
E. Achievement unlock completeness. ARCHITECTURE.md §11 lists 18.
|
|
||||||
The three hidden ones (speed_and_skill, comeback, zen_winner)
|
|
||||||
are most likely to be untested. For release, every advertised
|
|
||||||
achievement needs to actually fire.
|
|
||||||
|
|
||||||
F. Release-readiness backlog:
|
|
||||||
- README / store-page copy / screenshots
|
|
||||||
- LICENSE + third-party credits (xCards art, FiraMono, Bevy)
|
|
||||||
- SemVer + a v0.1.0 git tag
|
|
||||||
- itch.io / Steam packaging per platform (ARCHITECTURE.md §15)
|
|
||||||
- App signing — macOS notarization, Windows Authenticode,
|
|
||||||
Linux AppImage
|
|
||||||
- Telemetry / crash reporting — opt-in, off by default; or
|
|
||||||
confirm we ship without and rely on player reports
|
|
||||||
|
|
||||||
G. UI/UX professional polish — Phase 3 shipped the design system;
|
|
||||||
v1 wants the difference between "consistent" and "feels
|
|
||||||
intentional":
|
|
||||||
- Microcopy pass: every button label, empty state, error
|
|
||||||
message, and onboarding line reviewed for voice + clarity.
|
|
||||||
Pick one verb per concept ("Done" vs "Close" vs "OK") and
|
|
||||||
apply it everywhere.
|
|
||||||
- Empty / loading / error states: Leaderboard before any
|
|
||||||
scores, Stats before any games, Sync UI before login.
|
|
||||||
Today these are likely blank panels.
|
|
||||||
- Modal open/close animation: `MOTION_MODAL_SECS` token exists
|
|
||||||
in `ui_theme.rs:255` but isn't wired up — modals
|
|
||||||
appear/disappear instantly. Add scale-from-0.96 + scrim fade
|
|
||||||
per the token's doc comment.
|
|
||||||
- Tooltips on HUD readouts and settings labels. Bevy has no
|
|
||||||
built-in tooltip; build a small one. Hover a number to learn
|
|
||||||
what it counts.
|
|
||||||
- Accessibility: verify the AAA-contrast claim on
|
|
||||||
`ACCENT_PRIMARY` over `BG_BASE` (ui_theme.rs:65). Confirm
|
|
||||||
`AnimSpeed::Instant` disables every new animation (slide
|
|
||||||
curve, scoped settle, deal jitter, cascade rotation). Add
|
|
||||||
focus rings on `Button` entities for keyboard navigation.
|
|
||||||
- Typography choice: FiraMono is one weight, monospace for
|
|
||||||
everything. Consider shipping a second proportional face for
|
|
||||||
body + headings, keep mono for numerics (HUD score, timer).
|
|
||||||
Or commit to mono and lean into the "calm coder" feel — pick
|
|
||||||
deliberately and document the decision.
|
|
||||||
- Onboarding artwork: the 3 slides are text + buttons. For
|
|
||||||
release, stylised illustrations (or simple animated card
|
|
||||||
props on each slide) elevate the first-launch feel.
|
|
||||||
- Score-change feedback: floating "+N" numbers when score
|
|
||||||
jumps; pulse on the readout when value crosses a milestone.
|
|
||||||
`MOTION_SCORE_PULSE_SECS` is already a token.
|
|
||||||
- Splash / loading screen: today the window goes straight to
|
|
||||||
gameplay. A 1-2 second branded splash signals "real game"
|
|
||||||
vs "rust prototype".
|
|
||||||
- Hit-target audit: every interactive element ≥ 32 px on
|
|
||||||
desktop. Settings has 28 px icon buttons (`ICON_BUTTON_PX`
|
|
||||||
in settings_plugin.rs); revisit.
|
|
||||||
- Win-moment design: the cascade is good; consider a score-
|
|
||||||
breakdown reveal, streak callout, "share your time"
|
|
||||||
affordance for v1.
|
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
WORKFLOW NOTES:
|
||||||
- Commits use:
|
- Use the system git config (already correct).
|
||||||
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..."
|
- When attributing playtester feedback in commits/docs, use
|
||||||
- Sub-agents can Edit/Write but CANNOT `git commit`. Brief them to
|
"Quat" not "Rhys" (saved feedback memory).
|
||||||
stage + verify only; orchestrator commits on their behalf.
|
- Sub-agents stage + verify only; orchestrator commits.
|
||||||
See memory/feedback_agent_commit_limit.md.
|
- Every commit must pass build / clippy / test before pushing.
|
||||||
- Remote push needs interactive credentials on git.aleshym.co; the
|
- Push to GitHub (origin) — gh auth setup-git is already
|
||||||
user runs `git push origin master` themselves.
|
wired on this machine.
|
||||||
- Every commit must pass build / clippy / test. Pause-and-verify
|
|
||||||
is the user's preferred cadence — one feature per commit.
|
|
||||||
|
|
||||||
OPEN AT THE START: ask (1) did smoke-test pass, (2) which of A–G to
|
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
||||||
pursue first. Do not assume.
|
|
||||||
```
|
```
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 196 KiB |
@@ -1,7 +1,7 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
> Last updated: 2026-04-25
|
> Last updated: 2026-04-25
|
||||||
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
|
||||||
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
|
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::{
|
||||||
|
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
|
||||||
|
};
|
||||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||||
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||||
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiModalPlugin, WeeklyGoalsPlugin,
|
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||||
WinSummaryPlugin,
|
SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||||
|
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||||
|
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Install a panic hook that writes a crash log next to the save files
|
||||||
|
// before re-running the default hook (so stderr still gets the message
|
||||||
|
// and any debugger attached still sees the panic).
|
||||||
|
install_crash_log_hook();
|
||||||
|
|
||||||
// Initialise the platform keyring store before any token operations.
|
// Initialise the platform keyring store before any token operations.
|
||||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||||
@@ -29,13 +43,51 @@ fn main() {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let sync_provider = provider_for_backend(&settings.sync_backend);
|
let sync_provider = provider_for_backend(&settings.sync_backend);
|
||||||
|
|
||||||
App::new()
|
// Restore the previous window geometry if the player has one saved.
|
||||||
|
// Otherwise open at the platform default (1280×800, centred on the
|
||||||
|
// primary monitor) — `apply_smart_default_window_size` will resize
|
||||||
|
// up to a monitor-relative target on the first frame so HiDPI / 4K
|
||||||
|
// sessions don't end up with a comparatively tiny window.
|
||||||
|
let had_saved_geometry = settings.window_geometry.is_some();
|
||||||
|
let (window_resolution, window_position) = match settings.window_geometry {
|
||||||
|
Some(geom) => (
|
||||||
|
(geom.width, geom.height).into(),
|
||||||
|
WindowPosition::At(IVec2::new(geom.x, geom.y)),
|
||||||
|
),
|
||||||
|
None => (
|
||||||
|
(1280u32, 800u32).into(),
|
||||||
|
WindowPosition::Centered(MonitorSelection::Primary),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
|
||||||
|
// The card-theme system's `themes://` asset source must be
|
||||||
|
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
|
||||||
|
// because that plugin freezes the asset-source list at build
|
||||||
|
// time. The matching `AssetSourcesPlugin` (added below) finishes
|
||||||
|
// the wiring after `DefaultPlugins` by populating the embedded
|
||||||
|
// default theme into Bevy's `EmbeddedAssetRegistry`.
|
||||||
|
register_theme_asset_sources(&mut app);
|
||||||
|
|
||||||
|
app
|
||||||
.add_plugins(
|
.add_plugins(
|
||||||
DefaultPlugins
|
DefaultPlugins
|
||||||
.set(WindowPlugin {
|
.set(WindowPlugin {
|
||||||
primary_window: Some(Window {
|
primary_window: Some(Window {
|
||||||
title: "Solitaire Quest".into(),
|
title: "Solitaire Quest".into(),
|
||||||
resolution: (1280u32, 800u32).into(),
|
// X11/Wayland WM_CLASS so taskbar managers group
|
||||||
|
// multiple windows of this app correctly.
|
||||||
|
name: Some("solitaire-quest".into()),
|
||||||
|
resolution: window_resolution,
|
||||||
|
position: window_position,
|
||||||
|
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
||||||
|
// falls back to Immediate, eliminating the vsync stall
|
||||||
|
// that AutoVsync produces during continuous window
|
||||||
|
// resize on X11 / Wayland. The game's frame budget is
|
||||||
|
// small enough that a few stray dropped frames from
|
||||||
|
// disabling vsync are imperceptible.
|
||||||
|
present_mode: PresentMode::AutoNoVsync,
|
||||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||||
min_width: 800.0,
|
min_width: 800.0,
|
||||||
min_height: 600.0,
|
min_height: 600.0,
|
||||||
@@ -55,17 +107,23 @@ fn main() {
|
|||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.add_plugins(AssetSourcesPlugin)
|
||||||
|
.add_plugins(ThemePlugin)
|
||||||
|
.add_plugins(ThemeRegistryPlugin)
|
||||||
.add_plugins(FontPlugin)
|
.add_plugins(FontPlugin)
|
||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
.add_plugins(CursorPlugin)
|
.add_plugins(CursorPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
|
.add_plugins(RadialMenuPlugin)
|
||||||
.add_plugins(SelectionPlugin)
|
.add_plugins(SelectionPlugin)
|
||||||
.add_plugins(AnimationPlugin)
|
.add_plugins(AnimationPlugin)
|
||||||
.add_plugins(FeedbackAnimPlugin)
|
.add_plugins(FeedbackAnimPlugin)
|
||||||
.add_plugins(CardAnimationPlugin)
|
.add_plugins(CardAnimationPlugin)
|
||||||
.add_plugins(AutoCompletePlugin)
|
.add_plugins(AutoCompletePlugin)
|
||||||
|
.add_plugins(ReplayPlaybackPlugin)
|
||||||
|
.add_plugins(ReplayOverlayPlugin)
|
||||||
.add_plugins(StatsPlugin::default())
|
.add_plugins(StatsPlugin::default())
|
||||||
.add_plugins(ProgressPlugin::default())
|
.add_plugins(ProgressPlugin::default())
|
||||||
.add_plugins(AchievementPlugin::default())
|
.add_plugins(AchievementPlugin::default())
|
||||||
@@ -75,7 +133,7 @@ fn main() {
|
|||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(HomePlugin)
|
.add_plugins(HomePlugin::default())
|
||||||
.add_plugins(ProfilePlugin)
|
.add_plugins(ProfilePlugin)
|
||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
@@ -85,5 +143,107 @@ fn main() {
|
|||||||
.add_plugins(LeaderboardPlugin)
|
.add_plugins(LeaderboardPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.run();
|
.add_plugins(UiFocusPlugin)
|
||||||
|
.add_plugins(UiTooltipPlugin)
|
||||||
|
.add_plugins(SplashPlugin);
|
||||||
|
|
||||||
|
// Smart default window sizing: when no saved geometry was loaded,
|
||||||
|
// resize the freshly-opened 1280×800 window to ~70 % of the primary
|
||||||
|
// monitor's logical size on the first frame. Without this, a 4K
|
||||||
|
// monitor opens the same 1280×800 window that a 1080p monitor
|
||||||
|
// does — visually tiny relative to screen. Skipped entirely when
|
||||||
|
// saved geometry was applied; the player's preference always wins.
|
||||||
|
if !had_saved_geometry {
|
||||||
|
app.add_systems(Update, apply_smart_default_window_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot Update system that runs only on launches without saved
|
||||||
|
/// window geometry. Resizes the primary window to a fraction of the
|
||||||
|
/// primary monitor's *logical* size — bigger monitors get bigger
|
||||||
|
/// windows automatically. Logical size already accounts for the OS's
|
||||||
|
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
|
||||||
|
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
|
||||||
|
/// target window — same physical inches as a 1920×1080 monitor with
|
||||||
|
/// scale_factor 1.0 yielding 1344×756.
|
||||||
|
///
|
||||||
|
/// Uses `Local<bool>` to make itself one-shot rather than introducing
|
||||||
|
/// a dedicated resource. The Update tick is necessary because Bevy
|
||||||
|
/// populates the `Monitor` entities asynchronously after winit's
|
||||||
|
/// Resumed event fires; they may not exist on the first Startup pass.
|
||||||
|
fn apply_smart_default_window_size(
|
||||||
|
mut applied: Local<bool>,
|
||||||
|
monitors: Query<&Monitor, With<PrimaryMonitor>>,
|
||||||
|
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||||
|
) {
|
||||||
|
if *applied {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(monitor) = monitors.single() else {
|
||||||
|
// Primary monitor not yet spawned by bevy_winit. Try again
|
||||||
|
// next frame; the cost is one early-exit per tick until
|
||||||
|
// monitors arrive (typically frame 1 or 2).
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut window) = windows.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let scale = monitor.scale_factor as f32;
|
||||||
|
if scale <= 0.0 {
|
||||||
|
// Defensive: a zero or negative scale factor would NaN the
|
||||||
|
// arithmetic below. Bail and accept the default size.
|
||||||
|
*applied = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let logical_w = monitor.physical_width as f32 / scale;
|
||||||
|
let logical_h = monitor.physical_height as f32 / scale;
|
||||||
|
|
||||||
|
// Target 70 % of monitor in each dimension, clamped to the
|
||||||
|
// existing 800×600 minimum and the monitor's own logical size
|
||||||
|
// (so we never request a window larger than the screen).
|
||||||
|
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
|
||||||
|
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
|
||||||
|
|
||||||
|
// Resize only when the change is meaningful — at exactly 1280×800
|
||||||
|
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
|
||||||
|
// wider), worth the resize; at the same default on an 800×600
|
||||||
|
// monitor the clamp pins us at 800×600 and we shouldn't resize.
|
||||||
|
let curr_w = window.resolution.width();
|
||||||
|
let curr_h = window.resolution.height();
|
||||||
|
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
|
||||||
|
window.resolution.set(target_w, target_h);
|
||||||
|
}
|
||||||
|
*applied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps the default panic hook with one that also appends a crash log
|
||||||
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||||
|
/// still runs afterwards, so stderr output and debugger integration are
|
||||||
|
/// unchanged. If the data directory is unavailable, the wrapper silently
|
||||||
|
/// falls through — the default hook handles output either way.
|
||||||
|
fn install_crash_log_hook() {
|
||||||
|
let crash_log_path = settings_file_path().and_then(|p| {
|
||||||
|
p.parent()
|
||||||
|
.map(|parent| parent.join("crash.log"))
|
||||||
|
});
|
||||||
|
let default_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
if let Some(path) = crash_log_path.as_ref()
|
||||||
|
&& let Ok(mut file) = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)
|
||||||
|
{
|
||||||
|
// Plain unix-seconds timestamp keeps the format trivially
|
||||||
|
// parseable and avoids pulling in chrono just for this.
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_secs());
|
||||||
|
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
||||||
|
}
|
||||||
|
default_hook(info);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,14 @@ fn main() -> io::Result<()> {
|
|||||||
let out_dir = workspace_root().join("assets").join("audio");
|
let out_dir = workspace_root().join("assets").join("audio");
|
||||||
fs::create_dir_all(&out_dir)?;
|
fs::create_dir_all(&out_dir)?;
|
||||||
|
|
||||||
let effects: [(&str, Generator); 6] = [
|
let effects: [(&str, Generator); 7] = [
|
||||||
("card_flip.wav", card_flip),
|
("card_flip.wav", card_flip),
|
||||||
("card_place.wav", card_place),
|
("card_place.wav", card_place),
|
||||||
("card_deal.wav", card_deal),
|
("card_deal.wav", card_deal),
|
||||||
("card_invalid.wav", card_invalid),
|
("card_invalid.wav", card_invalid),
|
||||||
("win_fanfare.wav", win_fanfare),
|
("win_fanfare.wav", win_fanfare),
|
||||||
("ambient_loop.wav", ambient_loop),
|
("ambient_loop.wav", ambient_loop),
|
||||||
|
("foundation_complete.wav", foundation_complete),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, make) in &effects {
|
for (name, make) in &effects {
|
||||||
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-suit foundation-completion ping (~240 ms): a rising three-note
|
||||||
|
/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each
|
||||||
|
/// note. Shorter and brighter than `win_fanfare` so it can fire up to
|
||||||
|
/// four times per game (once per suit) without drowning out subsequent
|
||||||
|
/// move sounds. The fourth firing co-occurs with the win cascade and
|
||||||
|
/// `win_fanfare`; the C-major triad sits an octave above the
|
||||||
|
/// fanfare's root so the two layer cleanly instead of fighting for the
|
||||||
|
/// same frequency band.
|
||||||
|
fn foundation_complete() -> Vec<i16> {
|
||||||
|
// C major triad, one octave up from win_fanfare's root.
|
||||||
|
let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6
|
||||||
|
let note_dur = 0.07_f32; // brisk, ascending
|
||||||
|
let total = note_dur * notes.len() as f32 + 0.05;
|
||||||
|
let n = duration_samples(total);
|
||||||
|
let mut out = Vec::with_capacity(n);
|
||||||
|
for i in 0..n {
|
||||||
|
let t = i as f32 / SAMPLE_RATE as f32;
|
||||||
|
let mut sample = 0.0f32;
|
||||||
|
for (idx, freq) in notes.iter().enumerate() {
|
||||||
|
let start = idx as f32 * note_dur;
|
||||||
|
let local = t - start;
|
||||||
|
// Each note rings out for 0.18 s — overlapping notes form a
|
||||||
|
// brief chord at the tail.
|
||||||
|
if !(0.0..=0.18).contains(&local) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Sine + soft 2nd harmonic for warmth, ar_envelope decays
|
||||||
|
// sharply so each note is bell-like rather than sustained.
|
||||||
|
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
|
||||||
|
+ 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
|
||||||
|
let env = ar_envelope(local, 0.005, 0.18, 14.0);
|
||||||
|
sample += s * env;
|
||||||
|
}
|
||||||
|
out.push(quantize(sample * 0.20));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
|
||||||
/// mono 16-bit PCM).
|
/// mono 16-bit PCM).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -6,6 +6,5 @@ edition.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
chrono = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
|
|||||||
@@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
|
|||||||
fn zen_winner(c: &AchievementContext) -> bool {
|
fn zen_winner(c: &AchievementContext) -> bool {
|
||||||
c.last_win_is_zen
|
c.last_win_is_zen
|
||||||
}
|
}
|
||||||
|
/// Cinephile is event-driven: it unlocks when the engine observes a
|
||||||
|
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
|
||||||
|
/// any field of [`AchievementContext`]. The condition predicate therefore
|
||||||
|
/// always returns false so [`check_achievements`] never unlocks it from a
|
||||||
|
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
|
||||||
|
/// `AchievementUnlockedEvent` written directly from the engine's
|
||||||
|
/// replay-playback observer.
|
||||||
|
fn cinephile_never(_c: &AchievementContext) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// All currently-evaluable achievements. Order is stable so persistence files
|
/// All currently-evaluable achievements. Order is stable so persistence files
|
||||||
/// remain readable across versions (new achievements append).
|
/// remain readable across versions (new achievements append).
|
||||||
@@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
reward: Some(Reward::Badge),
|
reward: Some(Reward::Badge),
|
||||||
condition: zen_winner,
|
condition: zen_winner,
|
||||||
},
|
},
|
||||||
|
AchievementDef {
|
||||||
|
id: "cinephile",
|
||||||
|
name: "Cinephile",
|
||||||
|
description: "Watch a saved replay all the way through",
|
||||||
|
secret: false,
|
||||||
|
reward: None,
|
||||||
|
// Event-driven unlock: the engine's replay-playback observer fires
|
||||||
|
// `AchievementUnlockedEvent("cinephile")` directly on a Playing →
|
||||||
|
// Completed transition. `cinephile_never` keeps the condition path
|
||||||
|
// a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
|
||||||
|
condition: cinephile_never,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
||||||
@@ -721,6 +743,31 @@ mod tests {
|
|||||||
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
|
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cinephile_achievement_in_canonical_list() {
|
||||||
|
let def = achievement_by_id("cinephile").expect("cinephile must be registered");
|
||||||
|
assert_eq!(def.id, "cinephile");
|
||||||
|
assert_eq!(def.name, "Cinephile");
|
||||||
|
assert!(!def.secret, "cinephile is not a secret achievement");
|
||||||
|
// Event-driven: the predicate is a sentinel that always returns
|
||||||
|
// false. `check_achievements` must never unlock cinephile from a
|
||||||
|
// GameWonEvent context, even one that satisfies every other gate.
|
||||||
|
let mut c = ctx();
|
||||||
|
c.games_won = 1;
|
||||||
|
c.win_streak_current = 999;
|
||||||
|
c.last_win_time_seconds = 1;
|
||||||
|
c.last_win_used_undo = false;
|
||||||
|
c.best_single_score = 99_999;
|
||||||
|
c.lifetime_score = u64::MAX;
|
||||||
|
c.last_win_is_zen = true;
|
||||||
|
c.last_win_recycle_count = 99;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(
|
||||||
|
!ids.contains(&"cinephile"),
|
||||||
|
"cinephile must never unlock via condition evaluation; got {ids:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn perfectionist_score_well_above_threshold_still_passes() {
|
fn perfectionist_score_well_above_threshold_still_passes() {
|
||||||
let mut c = ctx();
|
let mut c = ctx();
|
||||||
|
|||||||
@@ -77,16 +77,6 @@ pub struct Card {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_value_ace_is_one() {
|
|
||||||
assert_eq!(Rank::Ace.value(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rank_value_king_is_thirteen() {
|
|
||||||
assert_eq!(Rank::King.value(), 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rank_values_are_sequential() {
|
fn rank_values_are_sequential() {
|
||||||
let ranks = [
|
let ranks = [
|
||||||
@@ -100,26 +90,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_red_is_diamonds_and_hearts() {
|
fn suit_red_and_black_are_complementary() {
|
||||||
assert!(Suit::Diamonds.is_red());
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
assert!(Suit::Hearts.is_red());
|
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
|
||||||
assert!(!Suit::Clubs.is_red());
|
|
||||||
assert!(!Suit::Spades.is_red());
|
|
||||||
}
|
}
|
||||||
|
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||||
#[test]
|
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||||
fn suit_black_is_clubs_and_spades() {
|
|
||||||
assert!(Suit::Clubs.is_black());
|
|
||||||
assert!(Suit::Spades.is_black());
|
|
||||||
assert!(!Suit::Diamonds.is_black());
|
|
||||||
assert!(!Suit::Hearts.is_black());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn card_face_up_field_reflects_construction() {
|
|
||||||
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false };
|
|
||||||
assert!(!card.face_up);
|
|
||||||
let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true };
|
|
||||||
assert!(card2.face_up);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::card::{Card, Suit};
|
use crate::card::Card;
|
||||||
use crate::deck::{deal_klondike, Deck};
|
use crate::deck::{deal_klondike, Deck};
|
||||||
use crate::error::MoveError;
|
use crate::error::MoveError;
|
||||||
use crate::pile::{Pile, PileType};
|
use crate::pile::{Pile, PileType};
|
||||||
use crate::rules::{can_place_on_foundation, can_place_on_tableau};
|
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
||||||
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
|
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
|
||||||
|
|
||||||
const MAX_UNDO_STACK: usize = 64;
|
const MAX_UNDO_STACK: usize = 64;
|
||||||
|
|
||||||
|
/// Save-file schema version for `GameState`. Increment when the on-disk
|
||||||
|
/// representation changes incompatibly so `load_game_state_from` can refuse
|
||||||
|
/// older formats and start the player on a fresh game.
|
||||||
|
///
|
||||||
|
/// History:
|
||||||
|
/// - v1: `Foundation(Suit)` keys.
|
||||||
|
/// - v2 (current): `Foundation(u8)` slot keys; claimed suit derived from the
|
||||||
|
/// bottom card of the pile.
|
||||||
|
pub const GAME_STATE_SCHEMA_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
/// Default value for `GameState::schema_version` when deserialising older
|
||||||
|
/// save files that pre-date the field.
|
||||||
|
fn schema_v1() -> u32 { 1 }
|
||||||
|
|
||||||
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
|
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
|
||||||
/// that JSON (which requires string map keys) round-trips correctly.
|
/// that JSON (which requires string map keys) round-trips correctly.
|
||||||
mod pile_map_serde {
|
mod pile_map_serde {
|
||||||
@@ -98,6 +112,11 @@ pub struct GameState {
|
|||||||
/// Used by the `comeback` achievement condition.
|
/// Used by the `comeback` achievement condition.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recycle_count: u32,
|
pub recycle_count: u32,
|
||||||
|
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
||||||
|
/// the field. The loader refuses any value other than
|
||||||
|
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||||
|
#[serde(default = "schema_v1")]
|
||||||
|
pub schema_version: u32,
|
||||||
undo_stack: VecDeque<StateSnapshot>,
|
undo_stack: VecDeque<StateSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +135,8 @@ impl GameState {
|
|||||||
let mut piles: HashMap<PileType, Pile> = HashMap::new();
|
let mut piles: HashMap<PileType, Pile> = HashMap::new();
|
||||||
piles.insert(PileType::Stock, stock);
|
piles.insert(PileType::Stock, stock);
|
||||||
piles.insert(PileType::Waste, Pile::new(PileType::Waste));
|
piles.insert(PileType::Waste, Pile::new(PileType::Waste));
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
for slot in 0..4_u8 {
|
||||||
piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit)));
|
piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
|
||||||
}
|
}
|
||||||
for (i, pile) in tableau.into_iter().enumerate() {
|
for (i, pile) in tableau.into_iter().enumerate() {
|
||||||
piles.insert(PileType::Tableau(i), pile);
|
piles.insert(PileType::Tableau(i), pile);
|
||||||
@@ -135,6 +154,7 @@ impl GameState {
|
|||||||
is_auto_completable: false,
|
is_auto_completable: false,
|
||||||
undo_count: 0,
|
undo_count: 0,
|
||||||
recycle_count: 0,
|
recycle_count: 0,
|
||||||
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
undo_stack: VecDeque::new(),
|
undo_stack: VecDeque::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,14 +267,14 @@ impl GameState {
|
|||||||
let bottom_card = from_pile.cards[start].clone();
|
let bottom_card = from_pile.cards[start].clone();
|
||||||
|
|
||||||
match &to {
|
match &to {
|
||||||
PileType::Foundation(suit) => {
|
PileType::Foundation(_) => {
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
return Err(MoveError::RuleViolation(
|
return Err(MoveError::RuleViolation(
|
||||||
"only one card can move to foundation at a time".into(),
|
"only one card can move to foundation at a time".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||||||
if !can_place_on_foundation(&bottom_card, dest, *suit) {
|
if !can_place_on_foundation(&bottom_card, dest) {
|
||||||
return Err(MoveError::RuleViolation("invalid foundation placement".into()));
|
return Err(MoveError::RuleViolation("invalid foundation placement".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +283,18 @@ impl GameState {
|
|||||||
if !can_place_on_tableau(&bottom_card, dest) {
|
if !can_place_on_tableau(&bottom_card, dest) {
|
||||||
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
|
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
|
||||||
}
|
}
|
||||||
|
// The previous check only validates that the *bottom* of the
|
||||||
|
// moved stack lands on the destination's top card. Without
|
||||||
|
// this guard, a player could lift an arbitrary multi-card
|
||||||
|
// selection from one column and drop it onto another whenever
|
||||||
|
// the bottom card happens to match — even if the cards
|
||||||
|
// above the bottom don't form a legal descending
|
||||||
|
// alternating-colour run.
|
||||||
|
if !is_valid_tableau_sequence(&from_pile.cards[start..]) {
|
||||||
|
return Err(MoveError::RuleViolation(
|
||||||
|
"moved cards must form a valid tableau run".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => return Err(MoveError::InvalidDestination),
|
_ => return Err(MoveError::InvalidDestination),
|
||||||
}
|
}
|
||||||
@@ -332,13 +364,11 @@ impl GameState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when all four foundations each contain 13 cards.
|
/// Returns `true` when all four foundation slots each contain 13 cards.
|
||||||
pub fn check_win(&self) -> bool {
|
pub fn check_win(&self) -> bool {
|
||||||
[Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
|
(0..4_u8).all(|slot| {
|
||||||
.iter()
|
|
||||||
.all(|&suit| {
|
|
||||||
self.piles
|
self.piles
|
||||||
.get(&PileType::Foundation(suit))
|
.get(&PileType::Foundation(slot))
|
||||||
.is_some_and(|p| p.cards.len() == 13)
|
.is_some_and(|p| p.cards.len() == 13)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -379,13 +409,34 @@ impl GameState {
|
|||||||
if !self.is_auto_completable || self.is_won {
|
if !self.is_auto_completable || self.is_won {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
let tableau = PileType::Tableau(i);
|
let tableau = PileType::Tableau(i);
|
||||||
if let Some(card) = self.piles[&tableau].cards.last() {
|
if let Some(card) = self.piles[&tableau].cards.last() {
|
||||||
for &suit in &suits {
|
// Prefer the slot that already claims this card's suit so
|
||||||
let foundation = PileType::Foundation(suit);
|
// Aces don't sometimes land in slot 0 and then leave the
|
||||||
if can_place_on_foundation(card, &self.piles[&foundation], suit) {
|
// matching suit-claimed slot empty.
|
||||||
|
let mut candidate: Option<u8> = None;
|
||||||
|
let mut empty_slot: Option<u8> = None;
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
let foundation = PileType::Foundation(slot);
|
||||||
|
let pile = &self.piles[&foundation];
|
||||||
|
if pile.cards.is_empty() {
|
||||||
|
if empty_slot.is_none() {
|
||||||
|
empty_slot = Some(slot);
|
||||||
|
}
|
||||||
|
} else if pile.claimed_suit() == Some(card.suit) {
|
||||||
|
candidate = Some(slot);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let target_slot = candidate.or_else(|| {
|
||||||
|
// Only fall back to an empty slot if the card is an Ace,
|
||||||
|
// which is the only rank that can claim an empty slot.
|
||||||
|
if card.rank.value() == 1 { empty_slot } else { None }
|
||||||
|
});
|
||||||
|
if let Some(slot) = target_slot {
|
||||||
|
let foundation = PileType::Foundation(slot);
|
||||||
|
if can_place_on_foundation(card, &self.piles[&foundation]) {
|
||||||
return Some((tableau, foundation));
|
return Some((tableau, foundation));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,7 +454,7 @@ impl GameState {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::card::{Card, Rank};
|
use crate::card::{Card, Rank, Suit};
|
||||||
|
|
||||||
fn new_game() -> GameState {
|
fn new_game() -> GameState {
|
||||||
GameState::new(42, DrawMode::DrawOne)
|
GameState::new(42, DrawMode::DrawOne)
|
||||||
@@ -434,8 +485,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn new_game_foundations_are_empty() {
|
fn new_game_foundations_are_empty() {
|
||||||
let g = new_game();
|
let g = new_game();
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
for slot in 0..4_u8 {
|
||||||
assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty());
|
assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,7 +713,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let result = g.move_cards(
|
let result = g.move_cards(
|
||||||
PileType::Tableau(0),
|
PileType::Tableau(0),
|
||||||
PileType::Foundation(Suit::Clubs),
|
PileType::Foundation(0),
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -706,8 +757,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn win_detection_all_foundations_complete() {
|
fn win_detection_all_foundations_complete() {
|
||||||
let mut g = new_game();
|
let mut g = new_game();
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
let f = g.piles.get_mut(&PileType::Foundation(suit)).unwrap();
|
for (slot, suit) in suits.into_iter().enumerate() {
|
||||||
|
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
|
||||||
f.cards.clear();
|
f.cards.clear();
|
||||||
for rank in [
|
for rank in [
|
||||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||||
@@ -763,11 +815,6 @@ mod tests {
|
|||||||
assert!(g.undo_stack_len() <= 64);
|
assert!(g.undo_stack_len() <= 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn undo_count_starts_at_zero() {
|
|
||||||
assert_eq!(new_game().undo_count, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undo_count_increments_on_each_undo() {
|
fn undo_count_increments_on_each_undo() {
|
||||||
let mut g = new_game();
|
let mut g = new_game();
|
||||||
@@ -848,11 +895,6 @@ mod tests {
|
|||||||
assert_eq!(g.score, 0);
|
assert_eq!(g.score, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zen_mode_default_is_classic_via_default_trait() {
|
|
||||||
assert_eq!(GameMode::default(), GameMode::Classic);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zen_mode_field_persists_through_construction() {
|
fn zen_mode_field_persists_through_construction() {
|
||||||
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
||||||
@@ -904,12 +946,6 @@ mod tests {
|
|||||||
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn time_attack_score_starts_at_zero() {
|
|
||||||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
|
|
||||||
assert_eq!(g.score, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn time_attack_draw_three_combination() {
|
fn time_attack_draw_three_combination() {
|
||||||
// TimeAttack + DrawThree is a valid combination; verify construction.
|
// TimeAttack + DrawThree is a valid combination; verify construction.
|
||||||
@@ -1039,7 +1075,8 @@ mod tests {
|
|||||||
|
|
||||||
let mv = g.next_auto_complete_move().expect("should find a move");
|
let mv = g.next_auto_complete_move().expect("should find a move");
|
||||||
assert_eq!(mv.0, PileType::Tableau(0));
|
assert_eq!(mv.0, PileType::Tableau(0));
|
||||||
assert_eq!(mv.1, PileType::Foundation(Suit::Clubs));
|
// Slot 0 is the first empty foundation; the Ace lands there.
|
||||||
|
assert_eq!(mv.1, PileType::Foundation(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1049,4 +1086,143 @@ mod tests {
|
|||||||
g.is_won = true;
|
g.is_won = true;
|
||||||
assert!(g.next_auto_complete_move().is_none());
|
assert!(g.next_auto_complete_move().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Slot-based foundation behaviour (refactor coverage) ---
|
||||||
|
|
||||||
|
/// Aces land in the first empty slot regardless of suit, and successive
|
||||||
|
/// Aces fan out across slots 0, 1, 2, 3 in deterministic order.
|
||||||
|
#[test]
|
||||||
|
fn any_ace_lands_in_first_empty_foundation() {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Clear stock/waste/tableau so we can hand-construct moves directly.
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
// Place an Ace of Clubs on tableau 0; move it to slot 0.
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||||
|
});
|
||||||
|
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||||
|
// Now place an Ace of Spades on tableau 0 and move it to slot 1.
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true,
|
||||||
|
});
|
||||||
|
g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs));
|
||||||
|
assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Pile::claimed_suit` reads the bottom card's suit on a populated
|
||||||
|
/// foundation slot, regardless of which slot index the pile occupies.
|
||||||
|
#[test]
|
||||||
|
fn claimed_suit_is_derived_from_bottom_card() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||||
|
});
|
||||||
|
g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
g.piles[&PileType::Foundation(2)].claimed_suit(),
|
||||||
|
Some(Suit::Hearts)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undoing the only card from a foundation slot drops the claimed suit;
|
||||||
|
/// the slot then accepts a different Ace.
|
||||||
|
#[test]
|
||||||
|
fn foundation_claim_drops_when_emptied_via_undo() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||||
|
});
|
||||||
|
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||||
|
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts));
|
||||||
|
|
||||||
|
g.undo().unwrap();
|
||||||
|
assert!(g.piles[&PileType::Foundation(0)].cards.is_empty());
|
||||||
|
assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none());
|
||||||
|
|
||||||
|
// A different Ace can now claim slot 0.
|
||||||
|
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||||
|
t0.cards.clear();
|
||||||
|
t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||||||
|
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||||
|
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successive Aces from the waste pile distribute across slots 0..=3 in
|
||||||
|
/// order — the player picks the slot, but `move_cards` accepts any
|
||||||
|
/// empty-slot placement for an Ace.
|
||||||
|
#[test]
|
||||||
|
fn multiple_aces_distribute_across_slots() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
let aces = [
|
||||||
|
(Suit::Clubs, 10),
|
||||||
|
(Suit::Diamonds, 11),
|
||||||
|
(Suit::Hearts, 12),
|
||||||
|
(Suit::Spades, 13),
|
||||||
|
];
|
||||||
|
for (slot, (suit, id)) in aces.iter().enumerate() {
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||||
|
id: *id, suit: *suit, rank: Rank::Ace, face_up: true,
|
||||||
|
});
|
||||||
|
g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap();
|
||||||
|
}
|
||||||
|
for (slot, (suit, _)) in aces.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
g.piles[&PileType::Foundation(slot as u8)].claimed_suit(),
|
||||||
|
Some(*suit),
|
||||||
|
"slot {slot} should claim {suit:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-complete prefers the foundation slot whose claimed suit matches
|
||||||
|
/// the candidate card's suit, even if an empty slot exists at a lower
|
||||||
|
/// index.
|
||||||
|
#[test]
|
||||||
|
fn next_auto_complete_move_picks_slot_with_matching_claim() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
// Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts.
|
||||||
|
g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card {
|
||||||
|
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||||||
|
});
|
||||||
|
// Tableau 0 holds the 2 of Hearts to play.
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true,
|
||||||
|
});
|
||||||
|
g.is_auto_completable = true;
|
||||||
|
|
||||||
|
let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1");
|
||||||
|
assert_eq!(mv.0, PileType::Tableau(0));
|
||||||
|
assert_eq!(
|
||||||
|
mv.1,
|
||||||
|
PileType::Foundation(1),
|
||||||
|
"must target the Hearts-claimed slot, not the empty slot 0",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ pub mod game_state;
|
|||||||
pub mod pile;
|
pub mod pile;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
|
pub mod solver;
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ pub enum PileType {
|
|||||||
Stock,
|
Stock,
|
||||||
/// The face-up discard pile drawn to.
|
/// The face-up discard pile drawn to.
|
||||||
Waste,
|
Waste,
|
||||||
/// One of the four suit-ordered foundation piles.
|
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||||||
Foundation(Suit),
|
/// is derived from the bottom card of the pile (always an Ace by
|
||||||
|
/// construction).
|
||||||
|
Foundation(u8),
|
||||||
/// One of the seven tableau columns (0–6).
|
/// One of the seven tableau columns (0–6).
|
||||||
Tableau(usize),
|
Tableau(usize),
|
||||||
}
|
}
|
||||||
@@ -17,7 +19,7 @@ pub enum PileType {
|
|||||||
/// A named collection of cards in a specific board position.
|
/// A named collection of cards in a specific board position.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Pile {
|
pub struct Pile {
|
||||||
/// Which pile this is (Stock, Waste, Foundation suit, or Tableau column).
|
/// Which pile this is (Stock, Waste, Foundation slot, or Tableau column).
|
||||||
pub pile_type: PileType,
|
pub pile_type: PileType,
|
||||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||||
pub cards: Vec<Card>,
|
pub cards: Vec<Card>,
|
||||||
@@ -33,6 +35,16 @@ impl Pile {
|
|||||||
pub fn top(&self) -> Option<&Card> {
|
pub fn top(&self) -> Option<&Card> {
|
||||||
self.cards.last()
|
self.cards.last()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// For foundation piles: returns `Some(suit)` once at least one card has
|
||||||
|
/// landed (the bottom card is always an Ace of the claimed suit).
|
||||||
|
/// Returns `None` for empty foundations or non-foundation piles.
|
||||||
|
pub fn claimed_suit(&self) -> Option<Suit> {
|
||||||
|
match self.pile_type {
|
||||||
|
PileType::Foundation(_) => self.cards.first().map(|c| c.suit),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -61,12 +73,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pile_type_foundation_uses_suit() {
|
fn pile_type_foundation_uses_slot_index() {
|
||||||
assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades));
|
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pile_type_tableau_uses_index() {
|
fn pile_type_tableau_uses_index() {
|
||||||
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
assert_ne!(PileType::Tableau(0), PileType::Tableau(6));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claimed_suit_is_none_for_empty_foundation() {
|
||||||
|
let pile = Pile::new(PileType::Foundation(0));
|
||||||
|
assert!(pile.claimed_suit().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claimed_suit_is_none_for_non_foundation() {
|
||||||
|
let mut pile = Pile::new(PileType::Tableau(0));
|
||||||
|
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||||||
|
assert!(pile.claimed_suit().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claimed_suit_returns_bottom_card_suit() {
|
||||||
|
let mut pile = Pile::new(PileType::Foundation(2));
|
||||||
|
pile.cards.push(Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: true });
|
||||||
|
pile.cards.push(Card { id: 1, suit: Suit::Hearts, rank: Rank::Two, face_up: true });
|
||||||
|
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use crate::card::{Card, Suit};
|
use crate::card::Card;
|
||||||
use crate::pile::Pile;
|
use crate::pile::Pile;
|
||||||
|
|
||||||
/// Returns `true` if `card` can be placed on `pile` as the next card in the foundation for `suit`.
|
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||||
///
|
///
|
||||||
/// Foundation rules: same suit, Ace starts, each subsequent card is one rank higher.
|
/// Foundation rules:
|
||||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool {
|
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
||||||
if card.suit != suit {
|
/// becomes the pile's claimed suit (derived from the bottom card via
|
||||||
return false;
|
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||||
}
|
/// - When the pile is non-empty, the next card must match the top card's
|
||||||
|
/// suit and be exactly one rank higher.
|
||||||
|
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||||
match pile.cards.last() {
|
match pile.cards.last() {
|
||||||
None => card.rank.value() == 1,
|
None => card.rank.value() == 1,
|
||||||
Some(top) => card.rank.value() == top.rank.value() + 1,
|
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +30,18 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if `cards` is a legal tableau run on its own — every
|
||||||
|
/// adjacent pair descends by one rank and alternates colour. A single
|
||||||
|
/// card is trivially valid. The destination check is separate; this
|
||||||
|
/// only validates the sequence's *internal* structure, which the tableau
|
||||||
|
/// move path must enforce so a player can't smuggle an arbitrary stack
|
||||||
|
/// onto another column when the bottom card happens to land legally.
|
||||||
|
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||||
|
cards.windows(2).all(|w| {
|
||||||
|
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -45,37 +59,46 @@ mod tests {
|
|||||||
// Foundation tests
|
// Foundation tests
|
||||||
#[test]
|
#[test]
|
||||||
fn foundation_ace_on_empty_is_valid() {
|
fn foundation_ace_on_empty_is_valid() {
|
||||||
let c = card(Suit::Hearts, Rank::Ace);
|
// Every suit's Ace must land on an empty foundation slot regardless of
|
||||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
// its slot index; the slot claims the suit only after the Ace lands.
|
||||||
assert!(can_place_on_foundation(&c, &p, Suit::Hearts));
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
|
let c = card(suit, Rank::Ace);
|
||||||
|
let p = Pile::new(PileType::Foundation(0));
|
||||||
|
assert!(
|
||||||
|
can_place_on_foundation(&c, &p),
|
||||||
|
"Ace of {suit:?} must land on empty slot 0",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foundation_non_ace_on_empty_is_invalid() {
|
fn foundation_non_ace_on_empty_is_invalid() {
|
||||||
let c = card(Suit::Hearts, Rank::Two);
|
let c = card(Suit::Hearts, Rank::Two);
|
||||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
let p = Pile::new(PileType::Foundation(0));
|
||||||
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts));
|
assert!(!can_place_on_foundation(&c, &p));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foundation_two_on_ace_same_suit_is_valid() {
|
fn foundation_two_on_ace_same_suit_is_valid() {
|
||||||
let c = card(Suit::Clubs, Rank::Two);
|
let c = card(Suit::Clubs, Rank::Two);
|
||||||
let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]);
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||||
assert!(can_place_on_foundation(&c, &p, Suit::Clubs));
|
assert!(can_place_on_foundation(&c, &p));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foundation_wrong_suit_is_invalid() {
|
fn foundation_second_card_must_match_claimed_suit() {
|
||||||
let c = card(Suit::Hearts, Rank::Ace);
|
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
||||||
let p = Pile::new(PileType::Foundation(Suit::Spades));
|
// because the slot's claimed suit is Hearts after the Ace lands.
|
||||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Hearts, Rank::Ace)]);
|
||||||
|
let c = card(Suit::Spades, Rank::Two);
|
||||||
|
assert!(!can_place_on_foundation(&c, &p));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foundation_skipping_rank_is_invalid() {
|
fn foundation_skipping_rank_is_invalid() {
|
||||||
let c = card(Suit::Diamonds, Rank::Three);
|
let c = card(Suit::Diamonds, Rank::Three);
|
||||||
let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]);
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||||
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds));
|
assert!(!can_place_on_foundation(&c, &p));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tableau tests
|
// Tableau tests
|
||||||
@@ -125,16 +148,16 @@ mod tests {
|
|||||||
fn foundation_king_on_queen_completes_suit() {
|
fn foundation_king_on_queen_completes_suit() {
|
||||||
// The last card placed to complete a foundation is always King on Queen.
|
// The last card placed to complete a foundation is always King on Queen.
|
||||||
let c = card(Suit::Spades, Rank::King);
|
let c = card(Suit::Spades, Rank::King);
|
||||||
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||||
assert!(can_place_on_foundation(&c, &p, Suit::Spades));
|
assert!(can_place_on_foundation(&c, &p));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foundation_king_wrong_suit_is_invalid() {
|
fn foundation_king_wrong_suit_is_invalid() {
|
||||||
// King of Hearts cannot go on a Spades foundation even if rank matches.
|
// King of Hearts cannot go on a Spades-claimed foundation even if rank matches.
|
||||||
let c = card(Suit::Hearts, Rank::King);
|
let c = card(Suit::Hearts, Rank::King);
|
||||||
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
assert!(!can_place_on_foundation(&c, &p));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -163,4 +186,26 @@ mod tests {
|
|||||||
let p = pile_with(PileType::Tableau(0), vec![top]);
|
let p = pile_with(PileType::Tableau(0), vec![top]);
|
||||||
assert!(!can_place_on_tableau(&c, &p));
|
assert!(!can_place_on_tableau(&c, &p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tableau_sequence_validation() {
|
||||||
|
// Single card is trivially a valid sequence.
|
||||||
|
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
|
||||||
|
// Valid descending alternating-colour run K♠ Q♥ J♣.
|
||||||
|
assert!(is_valid_tableau_sequence(&[
|
||||||
|
card(Suit::Spades, Rank::King),
|
||||||
|
card(Suit::Hearts, Rank::Queen),
|
||||||
|
card(Suit::Clubs, Rank::Jack),
|
||||||
|
]));
|
||||||
|
// Same colour twice (Q♠ on K♠) — invalid.
|
||||||
|
assert!(!is_valid_tableau_sequence(&[
|
||||||
|
card(Suit::Spades, Rank::King),
|
||||||
|
card(Suit::Spades, Rank::Queen),
|
||||||
|
]));
|
||||||
|
// Rank gap (K♠ → J♥) — invalid.
|
||||||
|
assert!(!is_valid_tableau_sequence(&[
|
||||||
|
card(Suit::Spades, Rank::King),
|
||||||
|
card(Suit::Hearts, Rank::Jack),
|
||||||
|
]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,11 @@ pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::card::Suit;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_to_foundation_scores_ten() {
|
fn move_to_foundation_scores_ten() {
|
||||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10);
|
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
||||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10);
|
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -74,7 +73,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn non_waste_to_tableau_scores_zero() {
|
fn non_waste_to_tableau_scores_zero() {
|
||||||
// Foundation → Tableau is impossible in practice but must score 0.
|
// Foundation → Tableau is impossible in practice but must score 0.
|
||||||
assert_eq!(score_move(&PileType::Foundation(Suit::Clubs), &PileType::Tableau(0)), 0);
|
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
|
||||||
// Tableau → Tableau (restack) scores 0.
|
// Tableau → Tableau (restack) scores 0.
|
||||||
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,12 @@ dirs = { workspace = true }
|
|||||||
keyring-core = { workspace = true }
|
keyring-core = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
solitaire_server = { path = "../solitaire_server" }
|
||||||
|
solitaire_sync = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
jsonwebtoken = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ const SERVICE: &str = "solitaire_quest_server";
|
|||||||
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
match err {
|
match err {
|
||||||
keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
keyring_core::Error::NoStorageAccess(_) | keyring_core::Error::NoDefaultStore => {
|
||||||
keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
|
TokenError::KeychainUnavailable(msg)
|
||||||
|
}
|
||||||
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||||
_ => TokenError::Keyring(msg),
|
_ => TokenError::Keyring(msg),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,4 @@ mod tests {
|
|||||||
seeds.dedup();
|
seeds.dedup();
|
||||||
assert_eq!(seeds.len(), len_before);
|
assert_eq!(seeds.len(), len_before);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn challenge_count_matches_seed_list_length() {
|
|
||||||
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
/// Upload a winning replay to the backend. On success, returns the
|
||||||
|
/// shareable web URL the player can copy to their clipboard
|
||||||
|
/// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
|
||||||
|
/// so backends without a server (e.g. `LocalOnlyProvider`) are
|
||||||
|
/// silently no-op'd by the engine's push-on-win system, matching
|
||||||
|
/// the same pattern `pull` / `push` follow.
|
||||||
|
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||||
|
Err(SyncError::UnsupportedPlatform)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
||||||
@@ -92,6 +101,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
|||||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
(**self).delete_account().await
|
(**self).delete_account().await
|
||||||
}
|
}
|
||||||
|
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
|
||||||
|
(**self).push_replay(replay).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
@@ -99,8 +111,11 @@ pub use stats::{StatsExt, StatsSnapshot};
|
|||||||
|
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
cleanup_orphaned_tmp_files, delete_game_state_at, game_state_file_path, load_game_state_from,
|
cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at,
|
||||||
load_stats, load_stats_from, save_game_state_to, save_stats, save_stats_to, stats_file_path,
|
game_state_file_path, load_game_state_from, load_stats, load_stats_from,
|
||||||
|
load_time_attack_session_from, load_time_attack_session_from_at, save_game_state_to,
|
||||||
|
save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
|
||||||
|
time_attack_session_path, time_attack_session_with_now, TimeAttackSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod achievements;
|
pub mod achievements;
|
||||||
@@ -126,7 +141,10 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
Theme,
|
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||||
|
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
|
||||||
|
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
|
||||||
|
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
@@ -136,3 +154,12 @@ pub use auth_tokens::{
|
|||||||
|
|
||||||
pub mod sync_client;
|
pub mod sync_client;
|
||||||
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
||||||
|
|
||||||
|
pub mod replay;
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
|
||||||
|
pub use replay::{
|
||||||
|
append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
|
||||||
|
replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
|
||||||
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
||||||
|
};
|
||||||
|
|||||||
@@ -162,21 +162,6 @@ mod tests {
|
|||||||
|
|
||||||
// --- Persistence ---
|
// --- Persistence ---
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load() {
|
|
||||||
let path = tmp_path("round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut p = PlayerProgress::default();
|
|
||||||
p.add_xp(1234);
|
|
||||||
p.unlocked_card_backs.push(2);
|
|
||||||
save_progress_to(&path, &p).expect("save");
|
|
||||||
let loaded = load_progress_from(&path);
|
|
||||||
assert_eq!(loaded.total_xp, 1234);
|
|
||||||
assert_eq!(loaded.level, p.level);
|
|
||||||
assert!(loaded.unlocked_card_backs.contains(&2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_missing_file_returns_default() {
|
fn load_from_missing_file_returns_default() {
|
||||||
let path = tmp_path("missing_xyz");
|
let path = tmp_path("missing_xyz");
|
||||||
@@ -298,4 +283,70 @@ mod tests {
|
|||||||
assert!(!recorded_again, "same-day completion must report no-op");
|
assert!(!recorded_again, "same-day completion must report no-op");
|
||||||
assert_eq!(p.daily_challenge_streak, 1);
|
assert_eq!(p.daily_challenge_streak, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Daily challenge history & longest streak ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_daily_completion_appends_to_history() {
|
||||||
|
// Recording a completion adds the date to history, preserving the
|
||||||
|
// pre-call length + 1, and the new entry is the chronological tail.
|
||||||
|
let mut p = PlayerProgress::default();
|
||||||
|
let prev_len = p.daily_challenge_history.len();
|
||||||
|
let today = NaiveDate::from_ymd_opt(2026, 5, 5).unwrap();
|
||||||
|
let recorded = p.record_daily_completion(today);
|
||||||
|
assert!(recorded);
|
||||||
|
assert_eq!(p.daily_challenge_history.len(), prev_len + 1);
|
||||||
|
assert_eq!(p.daily_challenge_history.last().copied(), Some(today));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_daily_completion_updates_longest_streak() {
|
||||||
|
// A streak of 4 must lift `daily_challenge_longest_streak` from 2 to 4
|
||||||
|
// (we seed the previous best at 2 and watch it get overtaken).
|
||||||
|
let mut p = PlayerProgress {
|
||||||
|
daily_challenge_longest_streak: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let d = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
|
||||||
|
p.record_daily_completion(d);
|
||||||
|
p.record_daily_completion(d + Duration::days(1));
|
||||||
|
p.record_daily_completion(d + Duration::days(2));
|
||||||
|
// 3rd consecutive day equals the previous best; longest should match.
|
||||||
|
assert_eq!(p.daily_challenge_streak, 3);
|
||||||
|
assert_eq!(p.daily_challenge_longest_streak, 3);
|
||||||
|
// 4th consecutive day overtakes the previous best.
|
||||||
|
p.record_daily_completion(d + Duration::days(3));
|
||||||
|
assert_eq!(p.daily_challenge_streak, 4);
|
||||||
|
assert_eq!(p.daily_challenge_longest_streak, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_progress_without_history_deserializes_to_empty() {
|
||||||
|
// A progress.json file produced before the history fields existed
|
||||||
|
// must still round-trip through serde::from_slice without error,
|
||||||
|
// with the new fields landing on their `#[serde(default)]` values.
|
||||||
|
let path = tmp_path("legacy_no_history");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let legacy_json = br#"{
|
||||||
|
"total_xp": 1500,
|
||||||
|
"level": 3,
|
||||||
|
"daily_challenge_last_completed": null,
|
||||||
|
"daily_challenge_streak": 0,
|
||||||
|
"weekly_goal_progress": {},
|
||||||
|
"unlocked_card_backs": [0],
|
||||||
|
"unlocked_backgrounds": [0],
|
||||||
|
"last_modified": "2026-04-29T12:00:00Z"
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, legacy_json).expect("write");
|
||||||
|
let p = load_progress_from(&path);
|
||||||
|
assert_eq!(p.total_xp, 1500);
|
||||||
|
assert!(
|
||||||
|
p.daily_challenge_history.is_empty(),
|
||||||
|
"legacy file lacking daily_challenge_history must default to empty"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p.daily_challenge_longest_streak, 0,
|
||||||
|
"legacy file lacking daily_challenge_longest_streak must default to 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,740 @@
|
|||||||
|
//! Win-game replay recording + storage.
|
||||||
|
//!
|
||||||
|
//! When a player wins, the engine freezes the in-memory recording into a
|
||||||
|
//! [`Replay`] and persists it to `<data_dir>/solitaire_quest/latest_replay.json`
|
||||||
|
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
|
||||||
|
//! action that loads it via [`load_latest_replay_from`] so the player can
|
||||||
|
//! revisit (or, in a future build, watch the engine re-execute) the path
|
||||||
|
//! they took to victory.
|
||||||
|
//!
|
||||||
|
//! Schema versioning: bump [`REPLAY_SCHEMA_VERSION`] whenever the on-disk
|
||||||
|
//! shape changes. [`load_latest_replay_from`] returns `None` when the file
|
||||||
|
//! carries any other version so older replays are silently dropped instead
|
||||||
|
//! of crashing the loader.
|
||||||
|
//!
|
||||||
|
//! The recording is intentionally minimal — only [`ReplayMove`] entries
|
||||||
|
//! that successfully advanced the game. `Undo` is **not** recorded: a
|
||||||
|
//! replay represents the canonical path the player ultimately took to win,
|
||||||
|
//! so backed-out missteps simply do not appear in the move list. The
|
||||||
|
//! starting deal is not stored either — the [`seed`](Replay::seed) +
|
||||||
|
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||||
|
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
|
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||||
|
|
||||||
|
/// Maximum number of recent winning replays the rolling history retains.
|
||||||
|
///
|
||||||
|
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
|
||||||
|
/// the oldest entry is dropped so the file never grows unbounded. The
|
||||||
|
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
|
||||||
|
/// the Stats overlay's replay selector — older wins age out silently.
|
||||||
|
pub const REPLAY_HISTORY_CAP: usize = 8;
|
||||||
|
|
||||||
|
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
|
||||||
|
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
|
||||||
|
/// returns `None` for older files (the player simply sees an empty
|
||||||
|
/// history rather than a half-loaded broken one). Bumping
|
||||||
|
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
|
||||||
|
/// [`Replay`] payloads inside an otherwise-current history.
|
||||||
|
///
|
||||||
|
/// History:
|
||||||
|
/// - v1 (current): initial release of the rolling history wrapper.
|
||||||
|
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Default value for [`ReplayHistory::schema_version`] when deserialising
|
||||||
|
/// files that pre-date the field. Any value other than
|
||||||
|
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
|
||||||
|
/// to return `None`.
|
||||||
|
fn history_schema_v0() -> u32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save-file schema version for [`Replay`]. Increment when the on-disk
|
||||||
|
/// representation changes incompatibly so [`load_latest_replay_from`] can
|
||||||
|
/// reject older formats and the player simply has no replay rather than
|
||||||
|
/// seeing a broken one.
|
||||||
|
///
|
||||||
|
/// History:
|
||||||
|
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
|
||||||
|
/// variants which carried the *outcome* of a stock interaction rather
|
||||||
|
/// than the player's atomic input.
|
||||||
|
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
|
||||||
|
/// variant. The engine resolves draw-vs-recycle deterministically from
|
||||||
|
/// the current stock state, so the input alone is sufficient and the
|
||||||
|
/// replay model now stores atomic player inputs end-to-end.
|
||||||
|
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||||
|
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||||
|
/// causes [`load_latest_replay_from`] to return `None`.
|
||||||
|
fn schema_v0() -> u32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One atomic player input recorded during a winning game, in the order
|
||||||
|
/// it was applied to the live `GameState`.
|
||||||
|
///
|
||||||
|
/// `Undo` is intentionally absent — see the module-level docs.
|
||||||
|
///
|
||||||
|
/// The variants represent *inputs*, not outcomes. `StockClick` covers
|
||||||
|
/// every player click on the stock pile; the engine then resolves
|
||||||
|
/// draw-vs-recycle deterministically from the current state during both
|
||||||
|
/// recording and playback, so the same input always produces the same
|
||||||
|
/// effect on the same starting deal.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ReplayMove {
|
||||||
|
/// A successful `move_cards(from, to, count)` call.
|
||||||
|
Move {
|
||||||
|
/// Source pile.
|
||||||
|
from: PileType,
|
||||||
|
/// Destination pile.
|
||||||
|
to: PileType,
|
||||||
|
/// Number of cards moved.
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
/// A click on the stock pile. Resolves to a draw when stock is
|
||||||
|
/// non-empty and to a waste→stock recycle when stock is empty.
|
||||||
|
StockClick,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete recording of a single winning game.
|
||||||
|
///
|
||||||
|
/// Replays are reconstructed by rebuilding a fresh
|
||||||
|
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
||||||
|
/// [`moves`](Self::moves) in order. The presentation fields
|
||||||
|
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||||
|
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||||
|
/// "Replay (2:14 win on 2026-05-02)".
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Replay {
|
||||||
|
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||||
|
#[serde(default = "schema_v0")]
|
||||||
|
pub schema_version: u32,
|
||||||
|
/// Seed used for the deal — replay rasterises the deck via
|
||||||
|
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||||
|
pub seed: u64,
|
||||||
|
/// Draw mode the recorded game was played in.
|
||||||
|
pub draw_mode: DrawMode,
|
||||||
|
/// Game mode the recorded game was played in.
|
||||||
|
pub mode: GameMode,
|
||||||
|
/// Total wall-clock seconds the win took. Used for the Stats UI
|
||||||
|
/// "Replay (2:14 win on 2026-05-02)" caption.
|
||||||
|
pub time_seconds: u64,
|
||||||
|
/// Final score at the moment of the win.
|
||||||
|
pub final_score: i32,
|
||||||
|
/// ISO-8601 date the win was recorded.
|
||||||
|
pub recorded_at: NaiveDate,
|
||||||
|
/// Ordered move list. Each entry is what the player did, replayable
|
||||||
|
/// against a fresh `GameState` constructed from the seed.
|
||||||
|
pub moves: Vec<ReplayMove>,
|
||||||
|
/// Public share URL for this replay on the active sync backend, set
|
||||||
|
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||||
|
/// task resolves. `None` when the player won on a local-only
|
||||||
|
/// backend, the upload failed, or the replay pre-dates v0.19.0
|
||||||
|
/// share-link persistence. `#[serde(default)]` keeps older
|
||||||
|
/// `replays.json` files loadable without bumping
|
||||||
|
/// [`REPLAY_SCHEMA_VERSION`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub share_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Replay {
|
||||||
|
/// Construct a fresh replay with the current schema version. The
|
||||||
|
/// caller fills in the recorded fields; this is the canonical
|
||||||
|
/// constructor used by the engine on win.
|
||||||
|
pub fn new(
|
||||||
|
seed: u64,
|
||||||
|
draw_mode: DrawMode,
|
||||||
|
mode: GameMode,
|
||||||
|
time_seconds: u64,
|
||||||
|
final_score: i32,
|
||||||
|
recorded_at: NaiveDate,
|
||||||
|
moves: Vec<ReplayMove>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
|
seed,
|
||||||
|
draw_mode,
|
||||||
|
mode,
|
||||||
|
time_seconds,
|
||||||
|
final_score,
|
||||||
|
recorded_at,
|
||||||
|
moves,
|
||||||
|
share_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling history of the player's most recent winning replays.
|
||||||
|
///
|
||||||
|
/// Stored as a single JSON file at
|
||||||
|
/// `<data_dir>/solitaire_quest/replays.json` (see
|
||||||
|
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||||
|
/// when [`append_replay_to_history`] pushes past the cap, the oldest
|
||||||
|
/// entry is dropped so the file never grows unbounded.
|
||||||
|
///
|
||||||
|
/// `replays[0]` is always the most recent win; the Stats overlay's
|
||||||
|
/// replay selector defaults to that entry and surfaces the older
|
||||||
|
/// entries behind a small chooser so the player can revisit a memorable
|
||||||
|
/// game even after a more recent win.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ReplayHistory {
|
||||||
|
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
|
||||||
|
#[serde(default = "history_schema_v0")]
|
||||||
|
pub schema_version: u32,
|
||||||
|
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||||
|
/// older entries drop off when the cap is hit.
|
||||||
|
pub replays: Vec<Replay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ReplayHistory {
|
||||||
|
/// An empty history at the current schema version. Used by callers
|
||||||
|
/// that need a starting point before the first winning replay has
|
||||||
|
/// ever been recorded.
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||||
|
replays: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplayHistory {
|
||||||
|
/// Returns the most recent replay (`replays[0]`), or `None` when the
|
||||||
|
/// history is empty. Convenience used by the Stats overlay's default
|
||||||
|
/// selector position.
|
||||||
|
pub fn most_recent(&self) -> Option<&Replay> {
|
||||||
|
self.replays.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of replays currently retained.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.replays.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when no replays have been recorded yet.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.replays.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the platform-specific path to `latest_replay.json`, or `None`
|
||||||
|
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||||
|
#[deprecated(
|
||||||
|
note = "single-slot replay storage replaced by the rolling history at \
|
||||||
|
replay_history_path(); kept for the one-shot legacy migration \
|
||||||
|
in migrate_legacy_latest_replay"
|
||||||
|
)]
|
||||||
|
pub fn latest_replay_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the platform-specific path to `replays.json`, the rolling
|
||||||
|
/// history file, or `None` if `dirs::data_dir()` is unavailable (e.g.
|
||||||
|
/// minimal Linux containers).
|
||||||
|
pub fn replay_history_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
||||||
|
/// rename contract that the rest of `storage.rs` uses.
|
||||||
|
///
|
||||||
|
/// Overwrites any existing replay — only the most recent winning replay
|
||||||
|
/// is retained on disk.
|
||||||
|
#[deprecated(
|
||||||
|
note = "single-slot replay storage replaced by the rolling history; \
|
||||||
|
use append_replay_to_history instead. Kept for the one-shot \
|
||||||
|
legacy migration."
|
||||||
|
)]
|
||||||
|
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(replay).map_err(io::Error::other)?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes())?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a [`Replay`] from `path`, returning `None` when the file is
|
||||||
|
/// missing, corrupt, or carries a [`schema_version`](Replay::schema_version)
|
||||||
|
/// other than [`REPLAY_SCHEMA_VERSION`].
|
||||||
|
///
|
||||||
|
/// Schema-mismatch is treated as "no replay" so the player just sees the
|
||||||
|
/// "No replay recorded yet" caption rather than a half-loaded broken
|
||||||
|
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
|
||||||
|
/// older save without further migration code.
|
||||||
|
#[deprecated(
|
||||||
|
note = "single-slot replay storage replaced by the rolling history; \
|
||||||
|
use load_replay_history_from instead. Kept for the one-shot \
|
||||||
|
legacy migration."
|
||||||
|
)]
|
||||||
|
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
|
||||||
|
let data = fs::read(path).ok()?;
|
||||||
|
let replay: Replay = serde_json::from_slice(&data).ok()?;
|
||||||
|
if replay.schema_version != REPLAY_SCHEMA_VERSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(replay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a [`ReplayHistory`] atomically to `path` using the standard
|
||||||
|
/// `.tmp` → rename contract.
|
||||||
|
///
|
||||||
|
/// The on-disk encoding is pretty-printed JSON; the file is intended to
|
||||||
|
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
|
||||||
|
/// hundred move records at most) so the readability tradeoff is fine.
|
||||||
|
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes())?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
|
||||||
|
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
|
||||||
|
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
|
||||||
|
///
|
||||||
|
/// Individual [`Replay`] entries inside an otherwise-current history are
|
||||||
|
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
|
||||||
|
/// entries are silently dropped so a future bump of the inner replay
|
||||||
|
/// schema does not corrupt the wrapper.
|
||||||
|
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
|
||||||
|
let data = fs::read(path).ok()?;
|
||||||
|
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
|
||||||
|
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let filtered: Vec<Replay> = history
|
||||||
|
.replays
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
|
||||||
|
.collect();
|
||||||
|
Some(ReplayHistory {
|
||||||
|
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||||
|
replays: filtered,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append `replay` to the front of the rolling history at `path`,
|
||||||
|
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
|
||||||
|
/// and persist the updated history atomically.
|
||||||
|
///
|
||||||
|
/// If `path` has no existing history (missing file, corrupt, or
|
||||||
|
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
|
||||||
|
/// starting point so the new replay is always saved. The returned
|
||||||
|
/// [`ReplayHistory`] is the exact value written to disk so callers can
|
||||||
|
/// update an in-memory mirror (e.g. the Stats overlay's
|
||||||
|
/// `ReplayHistoryResource`) without a follow-up `load`.
|
||||||
|
pub fn append_replay_to_history(
|
||||||
|
path: &Path,
|
||||||
|
replay: Replay,
|
||||||
|
) -> io::Result<ReplayHistory> {
|
||||||
|
let mut history = load_replay_history_from(path).unwrap_or_default();
|
||||||
|
// Most recent first. Reserve the front slot; pop the oldest if we
|
||||||
|
// exceed the cap so the file never grows unbounded.
|
||||||
|
history.replays.insert(0, replay);
|
||||||
|
if history.replays.len() > REPLAY_HISTORY_CAP {
|
||||||
|
history.replays.truncate(REPLAY_HISTORY_CAP);
|
||||||
|
}
|
||||||
|
save_replay_history_to(path, &history)?;
|
||||||
|
Ok(history)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot migration from the legacy single-slot
|
||||||
|
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
|
||||||
|
/// `history_path`.
|
||||||
|
///
|
||||||
|
/// Behaviour matrix:
|
||||||
|
/// - `history_path` already exists → no-op (the rolling history wins).
|
||||||
|
/// - `history_path` is absent and `latest_path` is absent → no-op.
|
||||||
|
/// - `history_path` is absent and `latest_path` exists with a valid
|
||||||
|
/// replay → seed a fresh history with that one replay and write it.
|
||||||
|
/// - `history_path` is absent and `latest_path` exists but is corrupt /
|
||||||
|
/// schema-mismatched → write an empty history (we know the player is
|
||||||
|
/// on the new build and shouldn't keep being prompted to migrate).
|
||||||
|
///
|
||||||
|
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
|
||||||
|
/// this helper — keep it for one release as a safety net so a player
|
||||||
|
/// rolling back to the previous build doesn't lose their last winning
|
||||||
|
/// replay. The deletion is planned for the release after this one.
|
||||||
|
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||||
|
if history_path.exists() {
|
||||||
|
// Rolling history is authoritative once it exists.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !latest_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Use the deprecated loader directly — the migration is the one
|
||||||
|
// place we still consult the legacy file shape on purpose.
|
||||||
|
#[allow(deprecated)]
|
||||||
|
let legacy = load_latest_replay_from(latest_path);
|
||||||
|
let history = match legacy {
|
||||||
|
Some(replay) => ReplayHistory {
|
||||||
|
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||||
|
replays: vec![replay],
|
||||||
|
},
|
||||||
|
None => ReplayHistory::default(),
|
||||||
|
};
|
||||||
|
if let Err(e) = save_replay_history_to(history_path, &history) {
|
||||||
|
// Migration failure is non-fatal: on the next launch we'll just
|
||||||
|
// try again. We log to stderr rather than panic so headless
|
||||||
|
// tests stay quiet.
|
||||||
|
eprintln!(
|
||||||
|
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
// The legacy single-slot tests still exercise `save_latest_replay_to` /
|
||||||
|
// `load_latest_replay_from` on purpose — they're the round-trip
|
||||||
|
// guardrails for the migration source format.
|
||||||
|
#[allow(deprecated)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
|
env::temp_dir().join(format!("solitaire_test_replay_{name}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_replay() -> Replay {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||||
|
Replay::new(
|
||||||
|
12345,
|
||||||
|
DrawMode::DrawThree,
|
||||||
|
GameMode::Classic,
|
||||||
|
134,
|
||||||
|
5_120,
|
||||||
|
date,
|
||||||
|
vec![
|
||||||
|
ReplayMove::StockClick,
|
||||||
|
ReplayMove::Move {
|
||||||
|
from: PileType::Waste,
|
||||||
|
to: PileType::Tableau(3),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
ReplayMove::StockClick,
|
||||||
|
ReplayMove::Move {
|
||||||
|
from: PileType::Tableau(3),
|
||||||
|
to: PileType::Foundation(0),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-trivial replay with mixed move kinds must round-trip
|
||||||
|
/// byte-identically through `save_latest_replay_to` /
|
||||||
|
/// `load_latest_replay_from`. Catches any future field that forgets
|
||||||
|
/// `Serialize`/`Deserialize` or breaks the on-disk format.
|
||||||
|
#[test]
|
||||||
|
fn replay_round_trips_through_save_and_load() {
|
||||||
|
let path = tmp_path("round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let replay = sample_replay();
|
||||||
|
save_latest_replay_to(&path, &replay).expect("save");
|
||||||
|
|
||||||
|
let loaded = load_latest_replay_from(&path).expect("load must succeed");
|
||||||
|
assert_eq!(loaded, replay, "round-trip must preserve every field");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file written by an older schema (or a pre-`schema_version`
|
||||||
|
/// build) must be rejected. We write a minimal v0 fixture and assert
|
||||||
|
/// that `load_latest_replay_from` returns `None` so the player gets
|
||||||
|
/// a clean "no replay" state instead of a broken one.
|
||||||
|
#[test]
|
||||||
|
fn replay_legacy_schema_version_falls_through_to_none() {
|
||||||
|
let path = tmp_path("legacy_schema");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// No `schema_version` key — defaults to 0 via `schema_v0()`. Even
|
||||||
|
// if the rest of the JSON parses cleanly, the version gate must
|
||||||
|
// reject it.
|
||||||
|
let v0_json = r#"{
|
||||||
|
"seed": 1,
|
||||||
|
"draw_mode": "DrawOne",
|
||||||
|
"mode": "Classic",
|
||||||
|
"time_seconds": 60,
|
||||||
|
"final_score": 100,
|
||||||
|
"recorded_at": "2025-01-01",
|
||||||
|
"moves": []
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, v0_json).expect("write v0 fixture");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
load_latest_replay_from(&path).is_none(),
|
||||||
|
"v0 replay must be rejected (schema gate)",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backwards-compat: a `Replay` record persisted before v0.19.0
|
||||||
|
/// share-link persistence carries no `share_url` field on disk.
|
||||||
|
/// `#[serde(default)]` must let it deserialise cleanly with
|
||||||
|
/// `share_url == None`, so existing players don't see their
|
||||||
|
/// rolling history wiped on the v0.19.0 update.
|
||||||
|
#[test]
|
||||||
|
fn replay_loads_when_share_url_field_is_absent() {
|
||||||
|
let pre_v019_json = format!(
|
||||||
|
r#"{{
|
||||||
|
"schema_version": {schema},
|
||||||
|
"seed": 1,
|
||||||
|
"draw_mode": "DrawOne",
|
||||||
|
"mode": "Classic",
|
||||||
|
"time_seconds": 60,
|
||||||
|
"final_score": 100,
|
||||||
|
"recorded_at": "2025-01-01",
|
||||||
|
"moves": []
|
||||||
|
}}"#,
|
||||||
|
schema = REPLAY_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
let parsed: Replay = serde_json::from_str(&pre_v019_json)
|
||||||
|
.expect("pre-v0.19.0 replay JSON must still deserialise");
|
||||||
|
assert!(
|
||||||
|
parsed.share_url.is_none(),
|
||||||
|
"missing share_url field must default to None",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||||
|
/// `save_latest_replay_to` returns. Mirrors the same check that
|
||||||
|
/// guards `save_game_state_to` in `storage.rs`.
|
||||||
|
#[test]
|
||||||
|
fn replay_save_is_atomic() {
|
||||||
|
let path = tmp_path("atomic");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
save_latest_replay_to(&path, &sample_replay()).expect("save");
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading from a path that does not exist must return `None`, not
|
||||||
|
/// panic or surface an `Err`.
|
||||||
|
#[test]
|
||||||
|
fn replay_missing_file_returns_none() {
|
||||||
|
let path = tmp_path("missing_xyz");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
assert!(load_latest_replay_from(&path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading from a corrupt / partially-written file must return
|
||||||
|
/// `None`, not surface a deserialiser error to the engine.
|
||||||
|
#[test]
|
||||||
|
fn replay_corrupt_file_returns_none() {
|
||||||
|
let path = tmp_path("corrupt");
|
||||||
|
fs::write(&path, b"not valid json!!!").expect("write");
|
||||||
|
assert!(load_latest_replay_from(&path).is_none());
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// ReplayHistory — rolling list of recent wins
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
|
||||||
|
/// assert ordering / identity without writing a deep equality match.
|
||||||
|
fn replay_with_id(id: i32) -> Replay {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||||
|
Replay::new(
|
||||||
|
id as u64,
|
||||||
|
DrawMode::DrawOne,
|
||||||
|
GameMode::Classic,
|
||||||
|
60,
|
||||||
|
id,
|
||||||
|
date,
|
||||||
|
vec![ReplayMove::StockClick],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
|
||||||
|
/// the on-disk file (and the in-memory mirror returned by the helper)
|
||||||
|
/// stays bounded so the user's data dir never grows unbounded.
|
||||||
|
#[test]
|
||||||
|
fn append_replay_to_history_caps_at_eight() {
|
||||||
|
let path = tmp_path("history_cap");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut last_returned = ReplayHistory::default();
|
||||||
|
for i in 0..10 {
|
||||||
|
last_returned = append_replay_to_history(&path, replay_with_id(i))
|
||||||
|
.expect("append must succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
last_returned.replays.len(),
|
||||||
|
REPLAY_HISTORY_CAP,
|
||||||
|
"history must be capped at REPLAY_HISTORY_CAP entries",
|
||||||
|
);
|
||||||
|
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
|
||||||
|
// survive (newest first), ids 0 and 1 aged out.
|
||||||
|
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec![9, 8, 7, 6, 5, 4, 3, 2],
|
||||||
|
"newest entries must survive, oldest must age out",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The on-disk file must agree with the returned in-memory copy.
|
||||||
|
let loaded = load_replay_history_from(&path).expect("load must succeed");
|
||||||
|
assert_eq!(loaded, last_returned, "disk must mirror returned history");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `append_replay_to_history` must place new entries at index 0 so
|
||||||
|
/// the Stats overlay's default selector (most recent) lands on the
|
||||||
|
/// just-saved replay.
|
||||||
|
#[test]
|
||||||
|
fn append_replay_inserts_at_front() {
|
||||||
|
let path = tmp_path("history_front");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
|
||||||
|
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
|
||||||
|
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
|
||||||
|
|
||||||
|
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec![3, 2, 1],
|
||||||
|
"history must be reverse-chronological (newest first)",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On first launch with the new code, a pre-existing
|
||||||
|
/// `latest_replay.json` must seed the new rolling history so the
|
||||||
|
/// player doesn't lose their last winning replay across the upgrade.
|
||||||
|
#[test]
|
||||||
|
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
|
||||||
|
let latest = tmp_path("legacy_migrate_latest");
|
||||||
|
let history = tmp_path("legacy_migrate_history");
|
||||||
|
let _ = fs::remove_file(&latest);
|
||||||
|
let _ = fs::remove_file(&history);
|
||||||
|
|
||||||
|
// Seed the legacy file with a real replay.
|
||||||
|
let legacy_replay = sample_replay();
|
||||||
|
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
|
||||||
|
assert!(!history.exists(), "history file must not exist pre-migration");
|
||||||
|
|
||||||
|
migrate_legacy_latest_replay(&latest, &history);
|
||||||
|
|
||||||
|
assert!(history.exists(), "migration must create the history file");
|
||||||
|
let loaded = load_replay_history_from(&history)
|
||||||
|
.expect("post-migration history must load");
|
||||||
|
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
|
||||||
|
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
|
||||||
|
// Legacy file is intentionally retained for one release as a
|
||||||
|
// safety net — see `migrate_legacy_latest_replay` doc comment.
|
||||||
|
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&latest);
|
||||||
|
let _ = fs::remove_file(&history);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the rolling history file already exists, the migration must
|
||||||
|
/// be a no-op — we never want to overwrite the player's accumulated
|
||||||
|
/// history with a stale single-slot legacy entry.
|
||||||
|
#[test]
|
||||||
|
fn migrate_is_noop_when_history_already_exists() {
|
||||||
|
let latest = tmp_path("legacy_noop_latest");
|
||||||
|
let history = tmp_path("legacy_noop_history");
|
||||||
|
let _ = fs::remove_file(&latest);
|
||||||
|
let _ = fs::remove_file(&history);
|
||||||
|
|
||||||
|
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
|
||||||
|
let pre_existing = ReplayHistory {
|
||||||
|
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||||
|
replays: vec![replay_with_id(42)],
|
||||||
|
};
|
||||||
|
save_replay_history_to(&history, &pre_existing).expect("seed history");
|
||||||
|
|
||||||
|
migrate_legacy_latest_replay(&latest, &history);
|
||||||
|
|
||||||
|
let loaded = load_replay_history_from(&history).expect("load");
|
||||||
|
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&latest);
|
||||||
|
let _ = fs::remove_file(&history);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A populated [`ReplayHistory`] must round-trip byte-identically
|
||||||
|
/// through `save_replay_history_to` / `load_replay_history_from`.
|
||||||
|
#[test]
|
||||||
|
fn replay_history_round_trips_through_save_and_load() {
|
||||||
|
let path = tmp_path("history_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let history = ReplayHistory {
|
||||||
|
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
|
||||||
|
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
|
||||||
|
};
|
||||||
|
save_replay_history_to(&path, &history).expect("save");
|
||||||
|
let loaded = load_replay_history_from(&path).expect("load");
|
||||||
|
assert_eq!(loaded, history, "round-trip must preserve every field");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file written by an older history schema must be rejected so the
|
||||||
|
/// player sees a clean empty history rather than a half-loaded one.
|
||||||
|
#[test]
|
||||||
|
fn replay_history_legacy_schema_version_falls_through_to_none() {
|
||||||
|
let path = tmp_path("history_legacy_schema");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
|
||||||
|
let v0_json = r#"{
|
||||||
|
"replays": []
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, v0_json).expect("write v0 fixture");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
load_replay_history_from(&path).is_none(),
|
||||||
|
"v0 history must be rejected (schema gate)",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic-write contract for the rolling history — `.tmp` must not be
|
||||||
|
/// left behind after `save_replay_history_to` returns.
|
||||||
|
#[test]
|
||||||
|
fn replay_history_save_is_atomic() {
|
||||||
|
let path = tmp_path("history_atomic");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,25 @@ pub enum SyncBackend {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persisted window size (in logical pixels) and screen position
|
||||||
|
/// (top-left corner, in physical pixels) — restored on next launch.
|
||||||
|
///
|
||||||
|
/// Stored inside [`Settings::window_geometry`]. `None` on `Settings`
|
||||||
|
/// means "use platform defaults"; a populated value is written every
|
||||||
|
/// time the player resizes or moves the window so the next launch
|
||||||
|
/// reopens at the same geometry.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WindowGeometry {
|
||||||
|
/// Logical width of the window in pixels.
|
||||||
|
pub width: u32,
|
||||||
|
/// Logical height of the window in pixels.
|
||||||
|
pub height: u32,
|
||||||
|
/// X coordinate of the window's top-left corner, in physical pixels.
|
||||||
|
pub x: i32,
|
||||||
|
/// Y coordinate of the window's top-left corner, in physical pixels.
|
||||||
|
pub y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent user settings.
|
/// Persistent user settings.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
@@ -98,6 +117,81 @@ pub struct Settings {
|
|||||||
/// solely on colour.
|
/// solely on colour.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color_blind_mode: bool,
|
pub color_blind_mode: bool,
|
||||||
|
/// Window size and screen position to restore on next launch. `None`
|
||||||
|
/// means "use platform defaults" — set on first run, then populated
|
||||||
|
/// as the player resizes / moves the window. Older `settings.json`
|
||||||
|
/// files written before this field existed deserialize cleanly to
|
||||||
|
/// `None` thanks to `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub window_geometry: Option<WindowGeometry>,
|
||||||
|
/// Identifier of the active card-art theme. Matches `meta.id` from
|
||||||
|
/// the theme's `theme.ron` manifest. `"default"` is the bundled
|
||||||
|
/// theme and is always present in the registry; user-supplied
|
||||||
|
/// themes register under their own ids when they're imported.
|
||||||
|
/// Older `settings.json` files default cleanly to `"default"` via
|
||||||
|
/// `#[serde(default = ...)]`.
|
||||||
|
#[serde(default = "default_theme_id")]
|
||||||
|
pub selected_theme_id: String,
|
||||||
|
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||||
|
/// shown to the player after their very first win. Acts as a
|
||||||
|
/// one-shot teach: subsequent wins must not re-fire the cue. Older
|
||||||
|
/// `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `false` thanks to `#[serde(default)]` —
|
||||||
|
/// players who already had wins recorded before this field was
|
||||||
|
/// introduced are guarded by the post-condition `games_won == 1`
|
||||||
|
/// checked by `achievement_plugin::fire_achievement_onboarding_toast`,
|
||||||
|
/// so the toast still does not fire for them.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_achievement_onboarding: bool,
|
||||||
|
/// Hover delay (seconds) before a tooltip appears. Range
|
||||||
|
/// `[0.0, 1.5]`; default matches `MOTION_TOOLTIP_DELAY_SECS` (0.5 s).
|
||||||
|
/// `0.0` means tooltips fire on the very next tick after hover —
|
||||||
|
/// the "Instant" setting. Older `settings.json` files written before
|
||||||
|
/// this field existed deserialize cleanly to the default via
|
||||||
|
/// `#[serde(default = "default_tooltip_delay")]`.
|
||||||
|
#[serde(default = "default_tooltip_delay")]
|
||||||
|
pub tooltip_delay_secs: f32,
|
||||||
|
/// Multiplier applied to the post-game time-bonus score component
|
||||||
|
/// shown in the win-summary modal. Range
|
||||||
|
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`
|
||||||
|
/// (`0.0`–`2.0`); default `1.0` keeps the existing behaviour.
|
||||||
|
///
|
||||||
|
/// **COSMETIC ONLY** — this multiplier changes what the player
|
||||||
|
/// sees in the win modal's score breakdown but does **not** affect
|
||||||
|
/// achievement unlock thresholds, lifetime score totals, or
|
||||||
|
/// leaderboard submissions, which all use the raw, unmultiplied
|
||||||
|
/// score values produced by `solitaire_core`. Older
|
||||||
|
/// `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `1.0` via
|
||||||
|
/// `#[serde(default = "default_time_bonus_multiplier")]`.
|
||||||
|
#[serde(default = "default_time_bonus_multiplier")]
|
||||||
|
pub time_bonus_multiplier: f32,
|
||||||
|
/// When `true`, the engine rejects new-game deals the
|
||||||
|
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
||||||
|
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||||
|
/// giving up and using the last tried seed. Off by default —
|
||||||
|
/// the solver adds a few hundred milliseconds of latency on the
|
||||||
|
/// pathological deals that hit the budget cap, and not every
|
||||||
|
/// player wants to wait. Older `settings.json` files written
|
||||||
|
/// before this field existed deserialize cleanly to `false` via
|
||||||
|
/// `#[serde(default)]`.
|
||||||
|
///
|
||||||
|
/// Scope: only random-seed Classic-mode deals are filtered.
|
||||||
|
/// Daily challenges, replays, and explicit-seed requests skip the
|
||||||
|
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub winnable_deals_only: bool,
|
||||||
|
/// Per-move duration during replay playback, in seconds. Range
|
||||||
|
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
|
||||||
|
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||||
|
/// (0.45 s/move) so existing playback behaviour is unchanged for
|
||||||
|
/// players who never touch the slider. Smaller values scrub
|
||||||
|
/// faster through the recorded move list. Older `settings.json`
|
||||||
|
/// files written before this field existed deserialize cleanly to
|
||||||
|
/// the default via
|
||||||
|
/// `#[serde(default = "default_replay_move_interval_secs")]`.
|
||||||
|
#[serde(default = "default_replay_move_interval_secs")]
|
||||||
|
pub replay_move_interval_secs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_draw_mode() -> DrawMode {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -112,6 +206,87 @@ fn default_music_volume() -> f32 {
|
|||||||
0.5
|
0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_theme_id() -> String {
|
||||||
|
"default".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||||
|
/// `solitaire_engine::ui_theme::MOTION_TOOLTIP_DELAY_SECS` so legacy
|
||||||
|
/// `settings.json` files load to the existing baseline. The constant
|
||||||
|
/// lives in the engine crate (which the data crate cannot depend on),
|
||||||
|
/// so the value is duplicated here — kept in sync by the
|
||||||
|
/// `settings_tooltip_delay_default_is_existing_baseline` test in
|
||||||
|
/// `solitaire_engine::settings_plugin`.
|
||||||
|
fn default_tooltip_delay() -> f32 {
|
||||||
|
0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower bound of the player-tunable tooltip delay slider, in seconds.
|
||||||
|
pub const TOOLTIP_DELAY_MIN_SECS: f32 = 0.0;
|
||||||
|
|
||||||
|
/// Upper bound of the player-tunable tooltip delay slider, in seconds.
|
||||||
|
pub const TOOLTIP_DELAY_MAX_SECS: f32 = 1.5;
|
||||||
|
|
||||||
|
/// Increment applied by the tooltip-delay decrement / increment buttons.
|
||||||
|
pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1;
|
||||||
|
|
||||||
|
/// Lower bound of the player-tunable time-bonus multiplier. `0.0`
|
||||||
|
/// disables the time-bonus row entirely (renders as "Off" in the UI).
|
||||||
|
pub const TIME_BONUS_MULTIPLIER_MIN: f32 = 0.0;
|
||||||
|
|
||||||
|
/// Upper bound of the player-tunable time-bonus multiplier. `2.0`
|
||||||
|
/// doubles the displayed time bonus.
|
||||||
|
pub const TIME_BONUS_MULTIPLIER_MAX: f32 = 2.0;
|
||||||
|
|
||||||
|
/// Increment applied by the time-bonus multiplier decrement /
|
||||||
|
/// increment buttons.
|
||||||
|
pub const TIME_BONUS_MULTIPLIER_STEP: f32 = 0.1;
|
||||||
|
|
||||||
|
/// Default value for [`Settings::time_bonus_multiplier`]. `1.0` keeps
|
||||||
|
/// the displayed time bonus identical to the raw value produced by
|
||||||
|
/// `solitaire_core::scoring::compute_time_bonus`.
|
||||||
|
fn default_time_bonus_multiplier() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default per-move duration during replay playback, in seconds.
|
||||||
|
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
|
||||||
|
/// so legacy `settings.json` files load to the existing baseline and
|
||||||
|
/// playback feels identical for players who never touch the slider.
|
||||||
|
/// The constant is duplicated across the data and engine crates
|
||||||
|
/// because `solitaire_data` cannot depend on the engine crate — keep
|
||||||
|
/// the two values in sync when adjusting either.
|
||||||
|
fn default_replay_move_interval_secs() -> f32 {
|
||||||
|
0.45
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower bound of the player-tunable replay-playback per-move interval,
|
||||||
|
/// in seconds. Below this the cards barely register visually before
|
||||||
|
/// the next move fires; the cap keeps the playback legible.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
|
||||||
|
|
||||||
|
/// Upper bound of the player-tunable replay-playback per-move interval,
|
||||||
|
/// in seconds. One second per move is a comfortable upper limit for
|
||||||
|
/// players who want to study a recorded game frame by frame.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
|
||||||
|
|
||||||
|
/// Increment applied by the replay-playback decrement / increment
|
||||||
|
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
|
||||||
|
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
|
||||||
|
/// without making the slider feel stuck on the same value.
|
||||||
|
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
|
||||||
|
|
||||||
|
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||||
|
/// is willing to attempt before giving up and accepting the latest
|
||||||
|
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||||
|
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
||||||
|
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
||||||
|
/// deal than spin forever on the main thread.
|
||||||
|
///
|
||||||
|
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||||
|
/// the upper bound on UI freeze when the toggle is on.
|
||||||
|
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -125,17 +300,35 @@ impl Default for Settings {
|
|||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: false,
|
first_run_complete: false,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
|
window_geometry: None,
|
||||||
|
selected_theme_id: default_theme_id(),
|
||||||
|
shown_achievement_onboarding: false,
|
||||||
|
tooltip_delay_secs: default_tooltip_delay(),
|
||||||
|
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||||
|
winnable_deals_only: false,
|
||||||
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
|
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
|
||||||
/// deserialization or hand-editing of `settings.json`.
|
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
|
||||||
|
/// their respective ranges after deserialization or hand-editing of
|
||||||
|
/// `settings.json`.
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
|
||||||
music_volume: self.music_volume.clamp(0.0, 1.0),
|
music_volume: self.music_volume.clamp(0.0, 1.0),
|
||||||
|
tooltip_delay_secs: self
|
||||||
|
.tooltip_delay_secs
|
||||||
|
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS),
|
||||||
|
time_bonus_multiplier: self
|
||||||
|
.time_bonus_multiplier
|
||||||
|
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
|
||||||
|
replay_move_interval_secs: self
|
||||||
|
.replay_move_interval_secs
|
||||||
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +344,44 @@ impl Settings {
|
|||||||
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
||||||
self.music_volume
|
self.music_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adjust the tooltip-hover dwell delay by `delta` seconds, clamped
|
||||||
|
/// to `[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS]`. Returns the
|
||||||
|
/// new value.
|
||||||
|
pub fn adjust_tooltip_delay(&mut self, delta: f32) -> f32 {
|
||||||
|
self.tooltip_delay_secs = (self.tooltip_delay_secs + delta)
|
||||||
|
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
|
||||||
|
self.tooltip_delay_secs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust the time-bonus multiplier by `delta`, clamped to
|
||||||
|
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`. The
|
||||||
|
/// result is rounded to one decimal place so the readout stays
|
||||||
|
/// clean across repeated `±` clicks (avoids float drift like
|
||||||
|
/// `0.30000004`). Returns the new value.
|
||||||
|
pub fn adjust_time_bonus_multiplier(&mut self, delta: f32) -> f32 {
|
||||||
|
let raw = (self.time_bonus_multiplier + delta)
|
||||||
|
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX);
|
||||||
|
// Round to 1 decimal place — the slider step is 0.1, so this
|
||||||
|
// collapses any FP drift introduced by repeated additions.
|
||||||
|
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
|
||||||
|
self.time_bonus_multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust the replay-playback per-move interval by `delta`
|
||||||
|
/// seconds, clamped to
|
||||||
|
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
|
||||||
|
/// The result is rounded to two decimal places so the readout
|
||||||
|
/// stays clean across repeated `±` clicks at the 0.05 s step
|
||||||
|
/// (avoids float drift like `0.45000003`). Returns the new value.
|
||||||
|
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
|
||||||
|
let raw = (self.replay_move_interval_secs + delta)
|
||||||
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||||
|
// Round to 2 decimal places — the slider step is 0.05, so this
|
||||||
|
// collapses any FP drift introduced by repeated additions.
|
||||||
|
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
|
||||||
|
self.replay_move_interval_secs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||||
@@ -191,18 +422,6 @@ mod tests {
|
|||||||
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_are_reasonable() {
|
|
||||||
let s = Settings::default();
|
|
||||||
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
|
|
||||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
|
||||||
assert!(!s.first_run_complete);
|
|
||||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
|
||||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
|
||||||
assert_eq!(s.theme, Theme::Green);
|
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adjust_sfx_volume_clamps() {
|
fn adjust_sfx_volume_clamps() {
|
||||||
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
|
||||||
@@ -235,70 +454,6 @@ mod tests {
|
|||||||
assert!(s.first_run_complete);
|
assert!(s.first_run_complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitized_clamps_music_volume() {
|
|
||||||
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
|
|
||||||
assert_eq!(s.music_volume, 1.0);
|
|
||||||
|
|
||||||
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
|
|
||||||
assert_eq!(s2.music_volume, 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load() {
|
|
||||||
let path = tmp_path("round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
sfx_volume: 0.42,
|
|
||||||
first_run_complete: true,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_save_and_load_full_settings() {
|
|
||||||
let path = tmp_path("round_trip_full");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
draw_mode: DrawMode::DrawThree,
|
|
||||||
sfx_volume: 0.3,
|
|
||||||
music_volume: 0.7,
|
|
||||||
animation_speed: AnimSpeed::Fast,
|
|
||||||
theme: Theme::Dark,
|
|
||||||
sync_backend: SyncBackend::SolitaireServer {
|
|
||||||
url: "https://example.com".to_string(),
|
|
||||||
username: "testuser".to_string(),
|
|
||||||
},
|
|
||||||
selected_card_back: 0,
|
|
||||||
selected_background: 0,
|
|
||||||
first_run_complete: true,
|
|
||||||
color_blind_mode: false,
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_preserves_non_default_cosmetic_selections() {
|
|
||||||
// selected_card_back and selected_background must survive save→load with
|
|
||||||
// non-zero values — zero is the default and not a meaningful regression check.
|
|
||||||
let path = tmp_path("cosmetic_selections");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_card_back: 3,
|
|
||||||
selected_background: 2,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_card_back, 3);
|
|
||||||
assert_eq!(loaded.selected_background, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_missing_file_returns_default() {
|
fn load_from_missing_file_returns_default() {
|
||||||
let path = tmp_path("missing_xyz");
|
let path = tmp_path("missing_xyz");
|
||||||
@@ -316,94 +471,70 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_from_old_format_uses_defaults_for_new_fields() {
|
fn adjust_tooltip_delay_clamps_to_range() {
|
||||||
// Simulate a settings.json written by an older version that only had
|
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
||||||
// sfx_volume and first_run_complete.
|
// Step up to 0.6.
|
||||||
let path = tmp_path("old_format");
|
assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6);
|
||||||
fs::write(
|
// Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS.
|
||||||
&path,
|
assert!((s.adjust_tooltip_delay(5.0) - TOOLTIP_DELAY_MAX_SECS).abs() < 1e-6);
|
||||||
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
|
// Big negative jump clamps to TOOLTIP_DELAY_MIN_SECS.
|
||||||
)
|
assert!((s.adjust_tooltip_delay(-99.0) - TOOLTIP_DELAY_MIN_SECS).abs() < 1e-6);
|
||||||
.expect("write");
|
// Confirm the floor is exactly zero.
|
||||||
let s = load_settings_from(&path);
|
assert_eq!(s.tooltip_delay_secs, 0.0);
|
||||||
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
|
|
||||||
assert!(s.first_run_complete);
|
|
||||||
// New fields should fall back to their defaults.
|
|
||||||
assert!((s.music_volume - 0.5).abs() < 1e-6);
|
|
||||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
|
||||||
assert_eq!(s.theme, Theme::Green);
|
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
|
||||||
assert_eq!(s.draw_mode, DrawMode::DrawOne);
|
|
||||||
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
|
|
||||||
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
|
|
||||||
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn color_blind_mode_defaults_to_false_when_field_absent() {
|
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
||||||
// Simulate a JSON file that has no color_blind_mode field.
|
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
||||||
let json = br#"{ "sfx_volume": 0.7 }"#;
|
// Step up to 1.1.
|
||||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6);
|
||||||
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
|
// Big positive jump clamps to TIME_BONUS_MULTIPLIER_MAX.
|
||||||
|
assert!(
|
||||||
|
(s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6
|
||||||
|
);
|
||||||
|
// Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN.
|
||||||
|
assert!(
|
||||||
|
(s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6
|
||||||
|
);
|
||||||
|
assert_eq!(s.time_bonus_multiplier, 0.0);
|
||||||
|
|
||||||
|
// Repeated incremental adds must not drift past the 0.1 grid.
|
||||||
|
let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() };
|
||||||
|
for _ in 0..10 {
|
||||||
|
s2.adjust_time_bonus_multiplier(0.1);
|
||||||
|
}
|
||||||
|
// After ten +0.1 steps, value should be exactly 1.0 (1 decimal).
|
||||||
|
assert!(
|
||||||
|
(s2.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||||
|
"rounding should pin repeated 0.1 steps to the decimal grid, got {}",
|
||||||
|
s2.time_bonus_multiplier
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn color_blind_mode_round_trips() {
|
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||||
let path = tmp_path("color_blind");
|
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||||
let _ = std::fs::remove_file(&path);
|
// Step down to 0.40.
|
||||||
let s = Settings {
|
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
|
||||||
color_blind_mode: true,
|
// Big positive jump clamps to MAX.
|
||||||
..Settings::default()
|
assert!(
|
||||||
};
|
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
|
||||||
save_settings_to(&path, &s).expect("save");
|
);
|
||||||
let loaded = load_settings_from(&path);
|
// Big negative jump clamps to MIN.
|
||||||
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
|
assert!(
|
||||||
let _ = std::fs::remove_file(&path);
|
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
|
||||||
|
);
|
||||||
|
|
||||||
|
// Repeated 0.05 steps must not drift past the 0.05 grid.
|
||||||
|
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
|
||||||
|
for _ in 0..6 {
|
||||||
|
s2.adjust_replay_move_interval(0.05);
|
||||||
}
|
}
|
||||||
|
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
|
||||||
// -----------------------------------------------------------------------
|
assert!(
|
||||||
// Task #62 — selected_card_back
|
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
|
||||||
// -----------------------------------------------------------------------
|
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
|
||||||
|
s2.replay_move_interval_secs
|
||||||
#[test]
|
);
|
||||||
fn settings_card_back_default_is_zero() {
|
|
||||||
assert_eq!(Settings::default().selected_card_back, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_card_back_serializes_round_trip() {
|
|
||||||
let path = tmp_path("card_back_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_card_back: 2,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Task #63 — selected_background
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_background_default_is_zero() {
|
|
||||||
assert_eq!(Settings::default().selected_background, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn settings_background_serializes_round_trip() {
|
|
||||||
let path = tmp_path("background_round_trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
let s = Settings {
|
|
||||||
selected_background: 3,
|
|
||||||
..Settings::default()
|
|
||||||
};
|
|
||||||
save_settings_to(&path, &s).expect("save");
|
|
||||||
let loaded = load_settings_from(&path);
|
|
||||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,35 @@
|
|||||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
|
||||||
pub use solitaire_sync::StatsSnapshot;
|
pub use solitaire_sync::StatsSnapshot;
|
||||||
|
|
||||||
/// Extension trait providing game-logic mutation helpers for [`StatsSnapshot`].
|
/// Extension trait providing game-logic mutation helpers for [`StatsSnapshot`].
|
||||||
///
|
///
|
||||||
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
|
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`
|
||||||
|
/// and [`StatsExt::update_per_mode_bests`].
|
||||||
pub trait StatsExt {
|
pub trait StatsExt {
|
||||||
/// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
|
/// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
|
||||||
|
///
|
||||||
|
/// Tracks lifetime totals only — per-mode best scores and times are
|
||||||
|
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
|
||||||
|
/// long-standing call sites that only know about [`DrawMode`] keep
|
||||||
|
/// compiling.
|
||||||
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
|
||||||
|
|
||||||
|
/// Updates the per-mode best score and fastest-win-time fields for the
|
||||||
|
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
|
||||||
|
/// the win handler.
|
||||||
|
///
|
||||||
|
/// Behaviour:
|
||||||
|
/// - `Classic`, `Zen`, `Challenge`: updates the matching `*_best_score`
|
||||||
|
/// (max) and `*_fastest_win_seconds` (zero-aware min — 0 means
|
||||||
|
/// "no win recorded yet").
|
||||||
|
/// - `TimeAttack`: no-op. Time Attack uses session-level scoring (count
|
||||||
|
/// of wins in 10 minutes); a per-game best wouldn't compose with
|
||||||
|
/// the other modes' single-game scoring.
|
||||||
|
fn update_per_mode_bests(&mut self, score: i32, time_seconds: u64, mode: GameMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StatsExt for StatsSnapshot {
|
impl StatsExt for StatsSnapshot {
|
||||||
@@ -51,6 +70,43 @@ impl StatsExt for StatsSnapshot {
|
|||||||
|
|
||||||
self.last_modified = Utc::now();
|
self.last_modified = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_per_mode_bests(&mut self, score: i32, time_seconds: u64, mode: GameMode) {
|
||||||
|
let score_u32 = score.max(0) as u32;
|
||||||
|
// Zero-aware min — 0 means "no win recorded yet" for the per-mode
|
||||||
|
// fastest fields, so we must not let a real time get clobbered to 0.
|
||||||
|
// (Mirrors the merge logic in `solitaire_sync::merge`.)
|
||||||
|
let min_ignore_zero = |existing: u64, candidate: u64| -> u64 {
|
||||||
|
if existing == 0 {
|
||||||
|
candidate
|
||||||
|
} else if candidate == 0 {
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
existing.min(candidate)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match mode {
|
||||||
|
GameMode::Classic => {
|
||||||
|
self.classic_best_score = self.classic_best_score.max(score_u32);
|
||||||
|
self.classic_fastest_win_seconds =
|
||||||
|
min_ignore_zero(self.classic_fastest_win_seconds, time_seconds);
|
||||||
|
}
|
||||||
|
GameMode::Zen => {
|
||||||
|
self.zen_best_score = self.zen_best_score.max(score_u32);
|
||||||
|
self.zen_fastest_win_seconds =
|
||||||
|
min_ignore_zero(self.zen_fastest_win_seconds, time_seconds);
|
||||||
|
}
|
||||||
|
GameMode::Challenge => {
|
||||||
|
self.challenge_best_score = self.challenge_best_score.max(score_u32);
|
||||||
|
self.challenge_fastest_win_seconds =
|
||||||
|
min_ignore_zero(self.challenge_fastest_win_seconds, time_seconds);
|
||||||
|
}
|
||||||
|
// Time Attack uses its own session-level scoring; a per-game best
|
||||||
|
// wouldn't compose with the other modes' single-game numbers.
|
||||||
|
GameMode::TimeAttack => {}
|
||||||
|
}
|
||||||
|
self.last_modified = Utc::now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -177,4 +233,123 @@ mod tests {
|
|||||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||||
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Per-mode bests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classic_win_updates_classic_best_score_only() {
|
||||||
|
let mut s = StatsSnapshot::default();
|
||||||
|
s.update_per_mode_bests(1500, 200, GameMode::Classic);
|
||||||
|
assert_eq!(s.classic_best_score, 1500);
|
||||||
|
assert_eq!(s.classic_fastest_win_seconds, 200);
|
||||||
|
// Other modes untouched.
|
||||||
|
assert_eq!(s.zen_best_score, 0);
|
||||||
|
assert_eq!(s.zen_fastest_win_seconds, 0);
|
||||||
|
assert_eq!(s.challenge_best_score, 0);
|
||||||
|
assert_eq!(s.challenge_fastest_win_seconds, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zen_win_updates_zen_best_score_only() {
|
||||||
|
let mut s = StatsSnapshot::default();
|
||||||
|
s.update_per_mode_bests(1800, 600, GameMode::Zen);
|
||||||
|
assert_eq!(s.zen_best_score, 1800);
|
||||||
|
assert_eq!(s.zen_fastest_win_seconds, 600);
|
||||||
|
assert_eq!(s.classic_best_score, 0);
|
||||||
|
assert_eq!(s.challenge_best_score, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_win_updates_challenge_best_score_only() {
|
||||||
|
let mut s = StatsSnapshot::default();
|
||||||
|
s.update_per_mode_bests(2400, 480, GameMode::Challenge);
|
||||||
|
assert_eq!(s.challenge_best_score, 2400);
|
||||||
|
assert_eq!(s.challenge_fastest_win_seconds, 480);
|
||||||
|
assert_eq!(s.classic_best_score, 0);
|
||||||
|
assert_eq!(s.zen_best_score, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_attack_win_does_not_touch_per_mode_bests() {
|
||||||
|
let mut s = StatsSnapshot::default();
|
||||||
|
s.update_per_mode_bests(9999, 1, GameMode::TimeAttack);
|
||||||
|
assert_eq!(s.classic_best_score, 0);
|
||||||
|
assert_eq!(s.zen_best_score, 0);
|
||||||
|
assert_eq!(s.challenge_best_score, 0);
|
||||||
|
assert_eq!(s.classic_fastest_win_seconds, 0);
|
||||||
|
assert_eq!(s.zen_fastest_win_seconds, 0);
|
||||||
|
assert_eq!(s.challenge_fastest_win_seconds, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_mode_best_score_takes_max_across_calls() {
|
||||||
|
let mut s = StatsSnapshot::default();
|
||||||
|
s.update_per_mode_bests(500, 200, GameMode::Classic);
|
||||||
|
s.update_per_mode_bests(200, 200, GameMode::Classic);
|
||||||
|
s.update_per_mode_bests(900, 200, GameMode::Classic);
|
||||||
|
assert_eq!(s.classic_best_score, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_mode_fastest_uses_zero_aware_min() {
|
||||||
|
// First Classic win: 240s. Field starts at 0 (no win yet) — we
|
||||||
|
// must adopt 240, not stay at 0 like a naive `min` would.
|
||||||
|
let mut s = StatsSnapshot::default();
|
||||||
|
s.update_per_mode_bests(100, 240, GameMode::Classic);
|
||||||
|
assert_eq!(s.classic_fastest_win_seconds, 240);
|
||||||
|
// Faster Classic win replaces it.
|
||||||
|
s.update_per_mode_bests(100, 120, GameMode::Classic);
|
||||||
|
assert_eq!(s.classic_fastest_win_seconds, 120);
|
||||||
|
// Slower Classic win does not.
|
||||||
|
s.update_per_mode_bests(100, 300, GameMode::Classic);
|
||||||
|
assert_eq!(s.classic_fastest_win_seconds, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_score_treated_as_zero_in_per_mode() {
|
||||||
|
let mut s = StatsSnapshot::default();
|
||||||
|
s.update_per_mode_bests(-50, 240, GameMode::Classic);
|
||||||
|
assert_eq!(s.classic_best_score, 0);
|
||||||
|
// Time still recorded — a win with a low score is still a win.
|
||||||
|
assert_eq!(s.classic_fastest_win_seconds, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_stats_without_per_mode_fields_deserializes_to_zero() {
|
||||||
|
// A pre-per-mode `stats.json` must still deserialise cleanly:
|
||||||
|
// every new field falls back to 0 via `#[serde(default)]` so
|
||||||
|
// updating the binary never wipes the player's old stats file.
|
||||||
|
let legacy_json = r#"{
|
||||||
|
"games_played": 12,
|
||||||
|
"games_won": 5,
|
||||||
|
"games_lost": 7,
|
||||||
|
"win_streak_current": 1,
|
||||||
|
"win_streak_best": 3,
|
||||||
|
"avg_time_seconds": 240,
|
||||||
|
"fastest_win_seconds": 180,
|
||||||
|
"lifetime_score": 8500,
|
||||||
|
"best_single_score": 2200,
|
||||||
|
"draw_one_wins": 4,
|
||||||
|
"draw_three_wins": 1,
|
||||||
|
"last_modified": "2026-04-29T12:00:00Z"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let s: StatsSnapshot = serde_json::from_str(legacy_json)
|
||||||
|
.expect("legacy payload must deserialise without per-mode fields");
|
||||||
|
|
||||||
|
// Pre-existing fields kept their values.
|
||||||
|
assert_eq!(s.games_played, 12);
|
||||||
|
assert_eq!(s.best_single_score, 2200);
|
||||||
|
assert_eq!(s.fastest_win_seconds, 180);
|
||||||
|
|
||||||
|
// Every new per-mode field defaulted to 0 ("no win yet").
|
||||||
|
assert_eq!(s.classic_best_score, 0);
|
||||||
|
assert_eq!(s.classic_fastest_win_seconds, 0);
|
||||||
|
assert_eq!(s.zen_best_score, 0);
|
||||||
|
assert_eq!(s.zen_fastest_win_seconds, 0);
|
||||||
|
assert_eq!(s.challenge_best_score, 0);
|
||||||
|
assert_eq!(s.challenge_fastest_win_seconds, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,17 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use solitaire_core::game_state::GameState;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
|
||||||
|
|
||||||
use crate::stats::StatsSnapshot;
|
use crate::stats::StatsSnapshot;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
const STATS_FILE_NAME: &str = "stats.json";
|
const STATS_FILE_NAME: &str = "stats.json";
|
||||||
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
||||||
|
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
||||||
|
|
||||||
/// Returns the platform-specific path to `stats.json`, or `None` if
|
/// Returns the platform-specific path to `stats.json`, or `None` if
|
||||||
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||||
@@ -72,10 +75,21 @@ pub fn game_state_file_path() -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
||||||
/// missing, corrupt, or represents a finished game.
|
/// missing, corrupt, represents a finished game, or carries a save-schema
|
||||||
|
/// version other than [`GAME_STATE_SCHEMA_VERSION`].
|
||||||
|
///
|
||||||
|
/// Schema mismatch is treated as "no save" so a player upgrading across an
|
||||||
|
/// incompatible game-state format change starts fresh instead of seeing a
|
||||||
|
/// half-loaded game (or a deserialiser error). v1 saves with the old
|
||||||
|
/// `Foundation(Suit)` key shape will fail to parse outright; any v1 saves
|
||||||
|
/// that happen to round-trip but report `schema_version: 1` are also rejected
|
||||||
|
/// here.
|
||||||
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
pub fn load_game_state_from(path: &Path) -> Option<GameState> {
|
||||||
let data = fs::read(path).ok()?;
|
let data = fs::read(path).ok()?;
|
||||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||||
|
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if gs.is_won {
|
if gs.is_won {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -128,6 +142,131 @@ pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Time Attack session (mode-specific sibling of game_state.json)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// `GameState` carries `mode: GameMode`, so an in-progress Zen / Challenge /
|
||||||
|
// Classic / TimeAttack deal is already round-tripped through `game_state.json`
|
||||||
|
// — closing the window mid-deal in any of those modes restores the deal on
|
||||||
|
// next launch. Time Attack adds a 10-minute session window and a per-session
|
||||||
|
// win counter that live OUTSIDE `GameState` (in `TimeAttackResource` on the
|
||||||
|
// engine side), so they are NOT covered by the game-state save/load. This
|
||||||
|
// sibling file persists just that extra session-level state.
|
||||||
|
//
|
||||||
|
// The Bevy plugin layer (`solitaire_engine::time_attack_plugin`) is the only
|
||||||
|
// caller. The file lives next to `game_state.json` in the same data dir and
|
||||||
|
// is written using the same `.tmp` → rename atomic-write contract that the
|
||||||
|
// rest of `storage.rs` uses.
|
||||||
|
|
||||||
|
/// Persisted state for an in-progress Time Attack session.
|
||||||
|
///
|
||||||
|
/// Fields mirror the live `TimeAttackResource` minus the `active` flag (the
|
||||||
|
/// presence of the file *is* the active flag — a missing file means no
|
||||||
|
/// session in progress).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TimeAttackSession {
|
||||||
|
/// Seconds remaining in the 10-minute window when the save was written.
|
||||||
|
pub remaining_secs: f32,
|
||||||
|
/// Wins accumulated during the session so far.
|
||||||
|
pub wins: u32,
|
||||||
|
/// Wall-clock instant the save was written, as unix seconds. Used at
|
||||||
|
/// load time to detect whether the session window expired in real
|
||||||
|
/// time while the app was closed and to decrement `remaining_secs`
|
||||||
|
/// by the real elapsed time so the resumed session reflects how
|
||||||
|
/// long the window has actually been running.
|
||||||
|
pub saved_at_unix_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the platform-specific path to `time_attack_session.json`, or
|
||||||
|
/// `None` if `dirs::data_dir()` is unavailable.
|
||||||
|
pub fn time_attack_session_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
|
||||||
|
/// `.tmp` → rename contract.
|
||||||
|
pub fn save_time_attack_session_to(path: &Path, session: &TimeAttackSession) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(session).map_err(io::Error::other)?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes())?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a Time Attack session from `path`, decrementing `remaining_secs`
|
||||||
|
/// by the wall-clock time elapsed between the save and now.
|
||||||
|
///
|
||||||
|
/// Returns `None` when:
|
||||||
|
/// - the file is missing or unreadable,
|
||||||
|
/// - the JSON is corrupt / malformed, or
|
||||||
|
/// - the session window expired during the time the app was closed
|
||||||
|
/// (`saved_at_unix_secs + remaining_secs <= now_unix_secs`).
|
||||||
|
///
|
||||||
|
/// The `now_unix_secs` parameter is injectable so unit tests can simulate
|
||||||
|
/// arbitrary wall-clock gaps without touching the real system clock. The
|
||||||
|
/// public companion [`load_time_attack_session_from`] resolves "now" from
|
||||||
|
/// `SystemTime::now()`.
|
||||||
|
pub fn load_time_attack_session_from_at(
|
||||||
|
path: &Path,
|
||||||
|
now_unix_secs: u64,
|
||||||
|
) -> Option<TimeAttackSession> {
|
||||||
|
let data = fs::read(path).ok()?;
|
||||||
|
let session: TimeAttackSession = serde_json::from_slice(&data).ok()?;
|
||||||
|
// Compute wall-clock elapsed seconds since the save was written.
|
||||||
|
// Saturating subtraction guards against a clock that moved backwards
|
||||||
|
// (rare, but possible across NTP corrections or VM clock drift).
|
||||||
|
let elapsed = now_unix_secs.saturating_sub(session.saved_at_unix_secs);
|
||||||
|
let remaining = session.remaining_secs - elapsed as f32;
|
||||||
|
if remaining <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(TimeAttackSession {
|
||||||
|
remaining_secs: remaining,
|
||||||
|
wins: session.wins,
|
||||||
|
saved_at_unix_secs: session.saved_at_unix_secs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a Time Attack session from `path`, using `SystemTime::now()` as
|
||||||
|
/// the reference for the wall-clock-elapsed adjustment.
|
||||||
|
///
|
||||||
|
/// See [`load_time_attack_session_from_at`] for the rules under which
|
||||||
|
/// the call returns `None` (missing file, corrupt JSON, expired window).
|
||||||
|
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_secs());
|
||||||
|
load_time_attack_session_from_at(path, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the Time Attack session file (called on session end, on session
|
||||||
|
/// start, or on game completion). Silently ignores `NotFound` errors.
|
||||||
|
pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
||||||
|
match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience helper for callers that want to stamp a session with the
|
||||||
|
/// current wall-clock time. Equivalent to constructing the struct
|
||||||
|
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
||||||
|
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_secs());
|
||||||
|
TimeAttackSession {
|
||||||
|
remaining_secs,
|
||||||
|
wins,
|
||||||
|
saved_at_unix_secs: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
||||||
///
|
///
|
||||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||||
@@ -138,8 +277,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
|||||||
if path
|
if path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.map(|n| n.ends_with(".json.tmp"))
|
.is_some_and(|n| n.ends_with(".json.tmp"))
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
{
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
@@ -332,4 +470,235 @@ mod tests {
|
|||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-v2 save files used `Foundation(Suit)` keys and either fail to
|
||||||
|
/// parse outright or surface a `schema_version: 1`. Either path must
|
||||||
|
/// produce `None` so the player launches into a fresh game.
|
||||||
|
///
|
||||||
|
/// Sibling assertion: the stats round-trip path is unaffected — only
|
||||||
|
/// the game-state schema bumped.
|
||||||
|
#[test]
|
||||||
|
fn save_format_v1_is_rejected() {
|
||||||
|
let path = gs_path("schema_v1");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// A pared-down v1 JSON literal: foundation pile keys use the old
|
||||||
|
// suit-tagged form and the file omits `schema_version` (so it
|
||||||
|
// deserialises with the default of 1). Even if a future change
|
||||||
|
// makes `Foundation(Suit)` parse-compatible, the schema-version
|
||||||
|
// gate keeps this case rejected.
|
||||||
|
let v1_json = r#"{
|
||||||
|
"piles": [
|
||||||
|
[{"Foundation": "Hearts"}, {"pile_type": {"Foundation": "Hearts"}, "cards": []}]
|
||||||
|
],
|
||||||
|
"draw_mode": "DrawOne",
|
||||||
|
"score": 0,
|
||||||
|
"move_count": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"seed": 42,
|
||||||
|
"is_won": false,
|
||||||
|
"is_auto_completable": false,
|
||||||
|
"undo_count": 0,
|
||||||
|
"undo_stack": []
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, v1_json).expect("write v1 fixture");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
load_game_state_from(&path).is_none(),
|
||||||
|
"v1 game_state.json must be rejected (parse failure or schema bump)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sibling sanity: stats files are independent and still round-trip.
|
||||||
|
let stats_path = tmp_path("schema_unrelated_stats");
|
||||||
|
let _ = fs::remove_file(&stats_path);
|
||||||
|
save_stats_to(&stats_path, &StatsSnapshot::default()).expect("save stats");
|
||||||
|
let loaded = load_stats_from(&stats_path);
|
||||||
|
assert_eq!(loaded, StatsSnapshot::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Time Attack session persistence
|
||||||
|
//
|
||||||
|
// Documents the contract that closing the window mid-Time-Attack does
|
||||||
|
// NOT lose the 10-minute window or the running win count. Classic /
|
||||||
|
// Zen / Challenge are covered by `game_state.json` because their entire
|
||||||
|
// mid-deal state lives in `GameState.mode` + `GameState.piles`; Time
|
||||||
|
// Attack additionally needs the session timer + wins counter, both of
|
||||||
|
// which live in `TimeAttackResource` on the engine side and are NOT
|
||||||
|
// part of `GameState`. This sibling file persists exactly that.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn ta_path(name: &str) -> PathBuf {
|
||||||
|
env::temp_dir().join(format!("solitaire_test_ta_{name}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-trip a session that was saved "just now" (zero wall-clock
|
||||||
|
/// elapsed). All three persisted fields must come back unchanged.
|
||||||
|
#[test]
|
||||||
|
fn time_attack_session_round_trips_through_save_and_load() {
|
||||||
|
let path = ta_path("round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// Use a fixed unix timestamp so the load step (which receives the
|
||||||
|
// SAME timestamp as "now") sees zero wall-clock elapsed.
|
||||||
|
let saved_at: u64 = 1_800_000_000;
|
||||||
|
let session = TimeAttackSession {
|
||||||
|
remaining_secs: 240.0,
|
||||||
|
wins: 3,
|
||||||
|
saved_at_unix_secs: saved_at,
|
||||||
|
};
|
||||||
|
save_time_attack_session_to(&path, &session).expect("save");
|
||||||
|
|
||||||
|
let loaded = load_time_attack_session_from_at(&path, saved_at)
|
||||||
|
.expect("session must load when not yet expired");
|
||||||
|
assert!(
|
||||||
|
(loaded.remaining_secs - 240.0).abs() < 0.01,
|
||||||
|
"remaining_secs must be unchanged when no wall-clock time has passed; got {}",
|
||||||
|
loaded.remaining_secs,
|
||||||
|
);
|
||||||
|
assert_eq!(loaded.wins, 3, "wins must round-trip");
|
||||||
|
assert_eq!(loaded.saved_at_unix_secs, saved_at, "timestamp must round-trip");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session whose window expired entirely between launches must be
|
||||||
|
/// discarded on load — the caller starts fresh rather than resuming a
|
||||||
|
/// dead session.
|
||||||
|
#[test]
|
||||||
|
fn time_attack_session_discarded_when_expired_between_launches() {
|
||||||
|
let path = ta_path("expired");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// Saved 20 minutes ago with 240 s remaining — long expired.
|
||||||
|
let saved_at: u64 = 1_800_000_000;
|
||||||
|
let session = TimeAttackSession {
|
||||||
|
remaining_secs: 240.0,
|
||||||
|
wins: 5,
|
||||||
|
saved_at_unix_secs: saved_at,
|
||||||
|
};
|
||||||
|
save_time_attack_session_to(&path, &session).expect("save");
|
||||||
|
|
||||||
|
// 20 minutes (1200 s) later → 240 - 1200 = -960 s remaining.
|
||||||
|
let now = saved_at + 1200;
|
||||||
|
assert!(
|
||||||
|
load_time_attack_session_from_at(&path, now).is_none(),
|
||||||
|
"an expired session must return None so the player starts fresh",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `remaining_secs` returned at load time must be the persisted
|
||||||
|
/// value minus the wall-clock seconds that elapsed while the app was
|
||||||
|
/// closed.
|
||||||
|
#[test]
|
||||||
|
fn time_attack_session_remaining_secs_decremented_by_real_elapsed() {
|
||||||
|
let path = ta_path("decremented");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let saved_at: u64 = 1_800_000_000;
|
||||||
|
let session = TimeAttackSession {
|
||||||
|
remaining_secs: 240.0,
|
||||||
|
wins: 2,
|
||||||
|
saved_at_unix_secs: saved_at,
|
||||||
|
};
|
||||||
|
save_time_attack_session_to(&path, &session).expect("save");
|
||||||
|
|
||||||
|
// 60 s elapsed in real time → expect 180 s remaining.
|
||||||
|
let now = saved_at + 60;
|
||||||
|
let loaded = load_time_attack_session_from_at(&path, now)
|
||||||
|
.expect("session must still load — 180 s left");
|
||||||
|
assert!(
|
||||||
|
(loaded.remaining_secs - 180.0).abs() < 5.0,
|
||||||
|
"remaining_secs ≈ 180 ± 5 s after a 60 s wall-clock gap; got {}",
|
||||||
|
loaded.remaining_secs,
|
||||||
|
);
|
||||||
|
assert_eq!(loaded.wins, 2, "wins must survive the elapsed adjustment");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||||
|
/// `save_time_attack_session_to` returns.
|
||||||
|
#[test]
|
||||||
|
fn time_attack_session_save_is_atomic() {
|
||||||
|
let path = ta_path("atomic");
|
||||||
|
let session = TimeAttackSession {
|
||||||
|
remaining_secs: 100.0,
|
||||||
|
wins: 0,
|
||||||
|
saved_at_unix_secs: 1_800_000_000,
|
||||||
|
};
|
||||||
|
save_time_attack_session_to(&path, &session).expect("save");
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading from a path that does not exist must return `None`, not
|
||||||
|
/// panic.
|
||||||
|
#[test]
|
||||||
|
fn time_attack_session_missing_file_returns_none() {
|
||||||
|
let path = ta_path("missing_xyz");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
assert!(load_time_attack_session_from_at(&path, 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading from a corrupt / partially-written file must return `None`,
|
||||||
|
/// not surface a deserialiser error.
|
||||||
|
#[test]
|
||||||
|
fn time_attack_session_corrupt_file_returns_none() {
|
||||||
|
let path = ta_path("corrupt");
|
||||||
|
fs::write(&path, b"not valid json!!!").expect("write");
|
||||||
|
assert!(load_time_attack_session_from_at(&path, 0).is_none());
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `delete_time_attack_session_at` removes the file when it exists
|
||||||
|
/// and returns `Ok(())` when it does not.
|
||||||
|
#[test]
|
||||||
|
fn time_attack_session_delete_handles_present_and_absent() {
|
||||||
|
let path = ta_path("delete");
|
||||||
|
let session = TimeAttackSession {
|
||||||
|
remaining_secs: 50.0,
|
||||||
|
wins: 0,
|
||||||
|
saved_at_unix_secs: 1_800_000_000,
|
||||||
|
};
|
||||||
|
save_time_attack_session_to(&path, &session).expect("save");
|
||||||
|
assert!(path.exists());
|
||||||
|
delete_time_attack_session_at(&path).expect("delete");
|
||||||
|
assert!(!path.exists());
|
||||||
|
// Second delete on the now-absent file must succeed.
|
||||||
|
delete_time_attack_session_at(&path).expect("missing-file delete is ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session whose `saved_at_unix_secs` is in the future (e.g. the
|
||||||
|
/// system clock moved backward across NTP correction) must NOT be
|
||||||
|
/// rejected as expired. Saturating subtraction must clamp the
|
||||||
|
/// "elapsed" value to zero.
|
||||||
|
#[test]
|
||||||
|
fn time_attack_session_handles_clock_running_backwards() {
|
||||||
|
let path = ta_path("clock_backwards");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let saved_at: u64 = 1_800_000_000;
|
||||||
|
let session = TimeAttackSession {
|
||||||
|
remaining_secs: 60.0,
|
||||||
|
wins: 1,
|
||||||
|
saved_at_unix_secs: saved_at,
|
||||||
|
};
|
||||||
|
save_time_attack_session_to(&path, &session).expect("save");
|
||||||
|
|
||||||
|
// "now" is BEFORE the saved time — should not crash, should not expire.
|
||||||
|
let now_in_past = saved_at - 100;
|
||||||
|
let loaded = load_time_attack_session_from_at(&path, now_in_past)
|
||||||
|
.expect("clock-backwards must not discard the session");
|
||||||
|
assert!(
|
||||||
|
(loaded.remaining_secs - 60.0).abs() < 0.01,
|
||||||
|
"remaining_secs must clamp elapsed to 0 when clock ran backwards; got {}",
|
||||||
|
loaded.remaining_secs,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||||
|
replay::Replay,
|
||||||
settings::SyncBackend,
|
settings::SyncBackend,
|
||||||
SyncError, SyncProvider,
|
SyncError, SyncProvider,
|
||||||
};
|
};
|
||||||
@@ -356,6 +357,69 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
extract_leaderboard_body(resp).await
|
extract_leaderboard_body(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a winning replay to `POST /api/replays`. On success the
|
||||||
|
/// server returns `{ "id": "<uuid>" }`; this method composes that
|
||||||
|
/// id with the configured base URL into the player-shareable
|
||||||
|
/// `<base>/replays/<id>` link and returns it. Mirrors the `push`
|
||||||
|
/// auth flow: 401 triggers a token refresh and one retry.
|
||||||
|
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/replays", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(replay)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.json(replay)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
return self.share_url_from_response(resp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.share_url_from_response(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolitaireServerClient {
|
||||||
|
/// Pulled out of `push_replay` so both the first attempt and the
|
||||||
|
/// post-401-retry attempt go through the same parse path.
|
||||||
|
async fn share_url_from_response(
|
||||||
|
&self,
|
||||||
|
resp: reqwest::Response,
|
||||||
|
) -> Result<String, SyncError> {
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|
|| status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
{
|
||||||
|
SyncError::Auth(format!("server returned {status}"))
|
||||||
|
} else {
|
||||||
|
SyncError::Network(format!("server returned {status}"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
|
let id = body["id"].as_str().ok_or_else(|| {
|
||||||
|
SyncError::Serialization("upload response missing `id`".into())
|
||||||
|
})?;
|
||||||
|
Ok(format!("{}/replays/{}", self.base_url, id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
//! Client-side sync round-trip integration tests for `solitaire_data`.
|
||||||
|
//!
|
||||||
|
//! These tests spin up the actual `solitaire_server` Axum app in-process on a
|
||||||
|
//! random TCP port (allocated by the OS) and drive the production
|
||||||
|
//! [`SolitaireServerClient`] HTTP client against it via `reqwest`. They are
|
||||||
|
//! the client-side counterpart to `solitaire_server/tests/server_tests.rs`,
|
||||||
|
//! which exercises the server endpoints directly via `tower::ServiceExt`.
|
||||||
|
//!
|
||||||
|
//! # Keyring
|
||||||
|
//!
|
||||||
|
//! [`SolitaireServerClient`] reads tokens from the OS keyring via
|
||||||
|
//! `keyring_core`. Headless test environments may not have a real secret
|
||||||
|
//! service, so we install the in-memory `keyring_core::mock::Store` exactly
|
||||||
|
//! once via [`std::sync::Once`]. Every test uses a unique username so the
|
||||||
|
//! shared mock store does not leak credentials between tests.
|
||||||
|
//!
|
||||||
|
//! # Server harness
|
||||||
|
//!
|
||||||
|
//! Each test calls [`spawn_test_server`] which:
|
||||||
|
//! 1. Binds a `tokio::net::TcpListener` on `127.0.0.1:0` (OS picks a port).
|
||||||
|
//! 2. Builds the in-memory SQLite pool, runs migrations.
|
||||||
|
//! 3. Builds the test router via `solitaire_server::build_test_router`
|
||||||
|
//! (rate limiting OFF, fixed test JWT secret).
|
||||||
|
//! 4. Spawns the server in a background `tokio::spawn` task.
|
||||||
|
//! 5. Returns the server URL (`http://127.0.0.1:{port}`).
|
||||||
|
//!
|
||||||
|
//! # Test JWT secret
|
||||||
|
//!
|
||||||
|
//! Must match the constant inside `build_test_router` so we can craft
|
||||||
|
//! expired-on-purpose tokens for the JWT-refresh test.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use solitaire_data::{
|
||||||
|
delete_tokens, store_tokens, SolitaireServerClient, SyncError, SyncProvider,
|
||||||
|
};
|
||||||
|
use solitaire_sync::{PlayerProgress, StatsSnapshot, SyncPayload};
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::sync::Once;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// JWT secret used by `solitaire_server::build_test_router`. Must stay in
|
||||||
|
/// sync with the constant inside that function.
|
||||||
|
const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock keyring setup (process-wide; install once)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static MOCK_KEYRING_INIT: Once = Once::new();
|
||||||
|
|
||||||
|
/// Install the `keyring_core` mock in-memory store as the process-wide
|
||||||
|
/// default. Safe to call from any test — only the first call has effect.
|
||||||
|
fn ensure_mock_keyring() {
|
||||||
|
MOCK_KEYRING_INIT.call_once(|| {
|
||||||
|
let store = keyring_core::mock::Store::new()
|
||||||
|
.expect("failed to construct mock keyring store");
|
||||||
|
keyring_core::set_default_store(store);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build a fresh in-memory SQLite pool with all migrations applied.
|
||||||
|
///
|
||||||
|
/// `max_connections(1)` is required: each connection to `sqlite::memory:` is
|
||||||
|
/// a *separate* database, so a larger pool sees an empty schema on the second
|
||||||
|
/// borrow. Mirrors the pattern in `solitaire_server/tests/server_tests.rs`.
|
||||||
|
async fn fresh_pool() -> SqlitePool {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("failed to connect to in-memory SQLite database");
|
||||||
|
sqlx::migrate!("../solitaire_server/migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("failed to run database migrations");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the test server on a random localhost port and return its base URL.
|
||||||
|
///
|
||||||
|
/// The server runs until the test process exits — there is no explicit
|
||||||
|
/// shutdown. This is acceptable for `cargo test` where each test binary is a
|
||||||
|
/// separate process.
|
||||||
|
async fn spawn_test_server() -> String {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("failed to bind test listener");
|
||||||
|
let addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener has no local addr");
|
||||||
|
|
||||||
|
let app = solitaire_server::build_test_router(fresh_pool().await);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Errors here cannot fail the test directly because we are inside a
|
||||||
|
// `tokio::spawn`; we just log so a rogue panic doesn't go unnoticed.
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
eprintln!("test server crashed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Register a fresh user against `base_url` and return the access + refresh
|
||||||
|
/// tokens straight from the response body. Bypasses the keyring entirely so
|
||||||
|
/// the caller can store the tokens under whatever username they want.
|
||||||
|
async fn register_user_raw(
|
||||||
|
base_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> (String, String) {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{base_url}/api/auth/register"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("register request failed");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"register must succeed (got {})",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
let body: serde_json::Value = resp.json().await.expect("register body must be JSON");
|
||||||
|
let access = body["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("access_token missing")
|
||||||
|
.to_string();
|
||||||
|
let refresh = body["refresh_token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("refresh_token missing")
|
||||||
|
.to_string();
|
||||||
|
(access, refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a JWT's `sub` claim without validating expiry (so test crafted
|
||||||
|
/// tokens still parse). Returns the user UUID as a `String`.
|
||||||
|
fn decode_sub(token: &str) -> String {
|
||||||
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
}
|
||||||
|
let mut v = Validation::default();
|
||||||
|
v.validate_exp = false;
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||||
|
&v,
|
||||||
|
)
|
||||||
|
.expect("failed to decode JWT");
|
||||||
|
data.claims.sub
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce a `SyncPayload` with `user_id` (parsed from the JWT sub) and a
|
||||||
|
/// non-default `games_played` so we can verify round-trips.
|
||||||
|
fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload {
|
||||||
|
SyncPayload {
|
||||||
|
user_id: Uuid::parse_str(user_id_str)
|
||||||
|
.expect("user_id_str from JWT sub must be a valid UUID"),
|
||||||
|
stats: StatsSnapshot {
|
||||||
|
games_played,
|
||||||
|
games_won: 7,
|
||||||
|
best_single_score: 1234,
|
||||||
|
..StatsSnapshot::default()
|
||||||
|
},
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **Full happy-path round-trip.**
|
||||||
|
///
|
||||||
|
/// 1. Spin up server.
|
||||||
|
/// 2. Register a user via raw HTTP.
|
||||||
|
/// 3. Persist the tokens in the (mock) keyring under the same username.
|
||||||
|
/// 4. Construct a `SolitaireServerClient` and call `push()` with a known
|
||||||
|
/// payload, then call `pull()` on the *same* client.
|
||||||
|
/// 5. Assert the server-merged stats reflect the values we pushed.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn register_login_push_pull_round_trip() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_alice";
|
||||||
|
|
||||||
|
let (access, refresh) = register_user_raw(&base, username, "alicepass1!").await;
|
||||||
|
store_tokens(username, &access, &refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
let payload = make_payload(&user_id, 42);
|
||||||
|
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
|
||||||
|
// Push.
|
||||||
|
let push_resp = client
|
||||||
|
.push(&payload)
|
||||||
|
.await
|
||||||
|
.expect("push must succeed for an authenticated client");
|
||||||
|
assert_eq!(
|
||||||
|
push_resp.merged.stats.games_played, 42,
|
||||||
|
"merged stats from push must reflect pushed games_played"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pull on the same client.
|
||||||
|
let pulled = client
|
||||||
|
.pull()
|
||||||
|
.await
|
||||||
|
.expect("pull must succeed for an authenticated client");
|
||||||
|
assert_eq!(
|
||||||
|
pulled.stats.games_played, 42,
|
||||||
|
"pulled games_played must match what we pushed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pulled.stats.best_single_score, 1234,
|
||||||
|
"pulled best_single_score must match what we pushed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup so the shared mock store doesn't leak this username's tokens.
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Concurrent two-client merge.**
|
||||||
|
///
|
||||||
|
/// Two clients (same user) push payloads with different `games_played`. The
|
||||||
|
/// server's merge keeps the higher of the two values. A subsequent pull from
|
||||||
|
/// either client must observe the merged max.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn pull_after_concurrent_pushes_merges_correctly() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_bob";
|
||||||
|
|
||||||
|
let (access, refresh) = register_user_raw(&base, username, "bobpass1!").await;
|
||||||
|
store_tokens(username, &access, &refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
|
||||||
|
// Two separate clients; both authenticate as the same user via the same
|
||||||
|
// tokens in the mock keyring.
|
||||||
|
let client_a = SolitaireServerClient::new(&base, username);
|
||||||
|
let client_b = SolitaireServerClient::new(&base, username);
|
||||||
|
|
||||||
|
// Client A: low value first.
|
||||||
|
let payload_a = make_payload(&user_id, 5);
|
||||||
|
client_a.push(&payload_a).await.expect("client A push must succeed");
|
||||||
|
|
||||||
|
// Client B: higher value second.
|
||||||
|
let payload_b = make_payload(&user_id, 99);
|
||||||
|
client_b.push(&payload_b).await.expect("client B push must succeed");
|
||||||
|
|
||||||
|
// Either client should now pull max(5, 99) = 99.
|
||||||
|
let pulled = client_a
|
||||||
|
.pull()
|
||||||
|
.await
|
||||||
|
.expect("pull after concurrent pushes must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
pulled.stats.games_played, 99,
|
||||||
|
"merged games_played must be max(5, 99) = 99"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Unauthenticated pull surfaces an `Auth` error.**
|
||||||
|
///
|
||||||
|
/// We construct a client for a user who has *no* tokens in the keyring at
|
||||||
|
/// all. `pull()` must return `SyncError::Auth(_)` — never `Network` or
|
||||||
|
/// `Serialization`.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn unauthenticated_pull_returns_authentication_error() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
// Use a username that we never call `store_tokens` for so the keyring
|
||||||
|
// lookup fails before any HTTP request is made.
|
||||||
|
let username = "rt_no_creds";
|
||||||
|
// Defensive: in case a previous test run left tokens behind.
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
let err = client
|
||||||
|
.pull()
|
||||||
|
.await
|
||||||
|
.expect_err("pull must fail without stored credentials");
|
||||||
|
assert!(
|
||||||
|
matches!(err, SyncError::Auth(_)),
|
||||||
|
"expected SyncError::Auth, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **JWT auto-refresh on 401.**
|
||||||
|
///
|
||||||
|
/// We register a user, then deliberately overwrite the stored access token
|
||||||
|
/// with one whose `exp` is in the past (signed with the same `TEST_SECRET`
|
||||||
|
/// so the signature verifies). The middleware will reject it with 401, the
|
||||||
|
/// `SolitaireServerClient` should call `/api/auth/refresh` with the still-
|
||||||
|
/// valid refresh token and retry — and `pull()` must ultimately succeed.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn jwt_refresh_on_401_succeeds() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_expiring";
|
||||||
|
|
||||||
|
// Register to get a real, valid refresh token signed with TEST_SECRET.
|
||||||
|
let (_real_access, real_refresh) =
|
||||||
|
register_user_raw(&base, username, "expirepass1!").await;
|
||||||
|
let user_id = decode_sub(&_real_access);
|
||||||
|
|
||||||
|
// Craft an expired access token signed with TEST_SECRET so the server's
|
||||||
|
// signature check still passes but the expiry validation rejects it.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: usize,
|
||||||
|
kind: String,
|
||||||
|
}
|
||||||
|
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||||
|
let expired_access = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&Claims {
|
||||||
|
sub: user_id.clone(),
|
||||||
|
exp,
|
||||||
|
kind: "access".into(),
|
||||||
|
},
|
||||||
|
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||||
|
)
|
||||||
|
.expect("failed to encode expired access token");
|
||||||
|
|
||||||
|
// Overwrite the stored access token with the expired one. The refresh
|
||||||
|
// token stays valid so the client's refresh path can succeed.
|
||||||
|
store_tokens(username, &expired_access, &real_refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
// Pull: server returns 401, client refreshes, retries, succeeds.
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
let pulled = client.pull().await.expect(
|
||||||
|
"pull must succeed after the client transparently refreshes the access token",
|
||||||
|
);
|
||||||
|
// Default merge for a never-pushed user yields games_played = 0.
|
||||||
|
assert_eq!(
|
||||||
|
pulled.stats.games_played, 0,
|
||||||
|
"default empty payload after refresh must have games_played = 0"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Account-deletion locks the client out.**
|
||||||
|
///
|
||||||
|
/// Register, push some data, then delete the account via the trait method.
|
||||||
|
/// A subsequent push with the *same* tokens (still cryptographically valid —
|
||||||
|
/// the server has no revocation list) must surface a non-success response
|
||||||
|
/// because the user row is gone and the server rejects the foreign-key push.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn pull_after_account_deletion_returns_default_or_error() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_deleter";
|
||||||
|
|
||||||
|
let (access, refresh) = register_user_raw(&base, username, "deletepass1!").await;
|
||||||
|
store_tokens(username, &access, &refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
let user_id = decode_sub(&access);
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
|
||||||
|
// Establish data first.
|
||||||
|
client
|
||||||
|
.push(&make_payload(&user_id, 3))
|
||||||
|
.await
|
||||||
|
.expect("initial push must succeed");
|
||||||
|
|
||||||
|
// Delete the account.
|
||||||
|
client
|
||||||
|
.delete_account()
|
||||||
|
.await
|
||||||
|
.expect("delete_account must return Ok on the live server");
|
||||||
|
|
||||||
|
// After deletion, pushing the same payload may either:
|
||||||
|
// - succeed (server INSERTs a fresh sync_state row keyed off JWT sub
|
||||||
|
// even though the users row is gone), or
|
||||||
|
// - fail with a server error from a foreign-key violation.
|
||||||
|
//
|
||||||
|
// We do not pin down which behaviour the server picks — the contract we
|
||||||
|
// assert is just that the client surfaces *some* result without panicking
|
||||||
|
// and that the trait remains usable.
|
||||||
|
let post_delete_push = client.push(&make_payload(&user_id, 4)).await;
|
||||||
|
let _ = post_delete_push; // either Ok or Err is fine; no panic is the win
|
||||||
|
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
@@ -13,6 +13,16 @@ solitaire_sync = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
usvg = { workspace = true }
|
||||||
|
resvg = { workspace = true }
|
||||||
|
tiny-skia = { workspace = true }
|
||||||
|
ron = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
zip = { workspace = true }
|
||||||
|
arboard = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Default theme — provenance
|
||||||
|
|
||||||
|
This directory is the bundled-default card theme that ships embedded in
|
||||||
|
the binary via Bevy's `embedded_asset!` macro (see
|
||||||
|
`solitaire_engine/src/assets/sources.rs`). At runtime its files are
|
||||||
|
addressable as `embedded://solitaire_engine/assets/themes/default/...`.
|
||||||
|
|
||||||
|
## Current state (Phase 3)
|
||||||
|
|
||||||
|
The `theme.ron` manifest in this directory lists all 52 face slots plus
|
||||||
|
a back slot, but **the referenced SVG files do not yet exist**. The
|
||||||
|
manifest is intentionally a stub so that:
|
||||||
|
|
||||||
|
1. `embedded_asset!` has a real file to bundle (the manifest itself).
|
||||||
|
2. `ThemeManifest::validate` accepts the manifest (it requires all 52
|
||||||
|
faces to be listed by name).
|
||||||
|
3. The `embedded://` asset source can be source-registered and queried
|
||||||
|
without runtime errors during Phase 3.
|
||||||
|
|
||||||
|
The actual SVG art will be added when the project swaps in the
|
||||||
|
`hayeah/playing-cards-assets` artwork — see the implementation plan in
|
||||||
|
`/CARD_PLAN.md`. At that point, every `.svg` filename listed in
|
||||||
|
`theme.ron`'s `faces` map (and `back.svg`) must be added here, and each
|
||||||
|
new file needs a corresponding `embedded_asset!(app, ...)` call in
|
||||||
|
`solitaire_engine/src/assets/sources.rs::register_default_theme`.
|
||||||
|
|
||||||
|
## How to add files to the bundled default theme
|
||||||
|
|
||||||
|
For each new file you drop into this directory:
|
||||||
|
|
||||||
|
1. Drop the file under `solitaire_engine/assets/themes/default/`.
|
||||||
|
2. Add one line to `register_default_theme` in
|
||||||
|
`solitaire_engine/src/assets/sources.rs` of the form:
|
||||||
|
```rust
|
||||||
|
embedded_asset!(app, "../../assets/themes/default/<filename>");
|
||||||
|
```
|
||||||
|
(The path is relative to `sources.rs`, which lives in
|
||||||
|
`solitaire_engine/src/assets/`.)
|
||||||
|
3. Update this file with the licence and origin of the new asset.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
To be filled in once real artwork lands.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Default theme card back — Solitaire Quest's midnight-purple palette.
|
||||||
|
Original work, MIT-licensed alongside the rest of this project.
|
||||||
|
Aspect 2:3 to match the face SVGs from hayeah/playing-cards-assets.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
||||||
|
<defs>
|
||||||
|
<pattern id="diamonds" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<rect x="0" y="0" width="20" height="20" fill="#1A0F2E"/>
|
||||||
|
<path d="M 10 0 L 20 10 L 10 20 L 0 10 Z"
|
||||||
|
fill="none" stroke="#3A2580" stroke-width="1"/>
|
||||||
|
<circle cx="10" cy="10" r="1" fill="#FFD23F"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Outer card surface with a midnight-purple base + diamond lattice -->
|
||||||
|
<rect x="0" y="0" width="200" height="300" rx="12" ry="12" fill="#1A0F2E"/>
|
||||||
|
<rect x="6" y="6" width="188" height="288" rx="9" ry="9" fill="url(#diamonds)"/>
|
||||||
|
|
||||||
|
<!-- Bordered inset so the lattice has a clear edge -->
|
||||||
|
<rect x="14" y="14" width="172" height="272" rx="6" ry="6"
|
||||||
|
fill="none" stroke="#FFD23F" stroke-width="1.5" opacity="0.85"/>
|
||||||
|
|
||||||
|
<!-- Centred diamond medallion -->
|
||||||
|
<g transform="translate(100 150)">
|
||||||
|
<path d="M 0 -42 L 42 0 L 0 42 L -42 0 Z" fill="#2D1B69" stroke="#FFD23F" stroke-width="2"/>
|
||||||
|
<path d="M 0 -22 L 22 0 L 0 22 L -22 0 Z" fill="#3A2580" stroke="#FFD23F" stroke-width="1"/>
|
||||||
|
<circle cx="0" cy="0" r="4" fill="#FFD23F"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Corner pips picking up the magenta secondary accent so the back
|
||||||
|
still reads as part of the design system at a glance -->
|
||||||
|
<g fill="#FF6B9D">
|
||||||
|
<circle cx="22" cy="22" r="2.5"/>
|
||||||
|
<circle cx="178" cy="22" r="2.5"/>
|
||||||
|
<circle cx="22" cy="278" r="2.5"/>
|
||||||
|
<circle cx="178" cy="278" r="2.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,281 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="10_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/10_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 57.572834,25.099947 c 0,0 5.967372,-4.773898 5.967372,-11.392027 0,-3.8743954 -3.43972,-10.3065945 -11.392028,-10.3065945 -7.952308,0 -11.392028,6.4347116 -11.392028,10.3065945 0,6.618129 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163348 -18.444833,-1.638201 -18.444833,8.680956 0,5.16586 4.22113,10.849311 10.849311,10.849311 7.952308,0 11.392027,-8.680956 11.392027,-8.680956 0,0 1.010056,9.894531 -4.881939,15.191045 h 13.020178 c -5.891994,-5.294001 -4.881938,-15.191045 -4.881938,-15.191045 0,0 3.439718,8.680956 11.392027,8.680956 6.630693,0 10.849311,-5.685963 10.849311,-10.849311 0,-10.319157 -11.816654,-13.844304 -18.444833,-8.680956 z"
|
||||||
|
id="cl-9-8" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 57.110434,93.200747 c 0,0 5.967372,-4.773898 5.967372,-11.392027 0,-3.874396 -3.43972,-10.306594 -11.392028,-10.306594 -7.952308,0 -11.392028,6.434711 -11.392028,10.306594 0,6.618129 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163348 -18.444833,-1.638201 -18.444833,8.680953 0,5.16587 4.22113,10.84932 10.849311,10.84932 7.952308,0 11.392027,-8.68096 11.392027,-8.68096 0,0 1.010056,9.89453 -4.881939,15.19104 h 13.020178 c -5.891994,-5.294 -4.881938,-15.19104 -4.881938,-15.19104 0,0 3.439718,8.68096 11.392027,8.68096 6.630693,0 10.849311,-5.68597 10.849311,-10.84932 0,-10.319154 -11.816654,-13.844301 -18.444833,-8.680953 z"
|
||||||
|
id="cl-9-8-0" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 121.55789,24.926219 c 0,0 5.96737,-4.773898 5.96737,-11.392027 0,-3.8743954 -3.43971,-10.3065945 -11.39203,-10.3065945 -7.95231,0 -11.39202,6.4347116 -11.39202,10.3065945 0,6.618129 5.96737,11.392027 5.96737,11.392027 -6.62818,-5.163348 -18.444834,-1.638201 -18.444834,8.680956 0,5.16586 4.22113,10.849311 10.849304,10.849311 7.95231,0 11.39203,-8.680956 11.39203,-8.680956 0,0 1.01006,9.894531 -4.88193,15.191045 h 13.02017 c -5.89199,-5.294001 -4.88193,-15.191045 -4.88193,-15.191045 0,0 3.43971,8.680956 11.39202,8.680956 6.63069,0 10.84931,-5.685963 10.84931,-10.849311 0,-10.319157 -11.81665,-13.844304 -18.44483,-8.680956 z"
|
||||||
|
id="cl-9-8-9" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 121.55789,93.027019 c 0,0 5.96737,-4.773898 5.96737,-11.392028 0,-3.874395 -3.43971,-10.306593 -11.39203,-10.306593 -7.95231,0 -11.39202,6.434711 -11.39202,10.306593 0,6.61813 5.96737,11.392028 5.96737,11.392028 -6.62818,-5.163348 -18.444834,-1.638201 -18.444834,8.680951 0,5.16587 4.22113,10.84932 10.849304,10.84932 7.95231,0 11.39203,-8.68096 11.39203,-8.68096 0,0 1.01006,9.89453 -4.88193,15.19104 h 13.02017 c -5.89199,-5.294 -4.88193,-15.19104 -4.88193,-15.19104 0,0 3.43971,8.68096 11.39202,8.68096 6.63069,0 10.84931,-5.68597 10.84931,-10.84932 0,-10.319152 -11.81665,-13.844299 -18.44483,-8.680951 z"
|
||||||
|
id="cl-9-8-0-4" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 89.576798,59.281103 c 0,0 5.967372,-4.773897 5.967372,-11.392027 0,-3.874395 -3.43972,-10.306594 -11.392028,-10.306594 -7.952308,0 -11.392028,6.434712 -11.392028,10.306594 0,6.61813 5.967373,11.392027 5.967373,11.392027 -6.62818,-5.163347 -18.444833,-1.638201 -18.444833,8.680957 0,5.165859 4.22113,10.84931 10.849311,10.84931 7.952308,0 11.392027,-8.680956 11.392027,-8.680956 0,0 1.010056,9.894531 -4.881939,15.191045 h 13.020178 c -5.891994,-5.294001 -4.881938,-15.191045 -4.881938,-15.191045 0,0 3.439718,8.680956 11.392027,8.680956 6.63069,0 10.84931,-5.685963 10.84931,-10.84931 0,-10.319158 -11.816653,-13.844304 -18.444832,-8.680957 z"
|
||||||
|
id="cl-9-8-8" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 110.06258,217.80216 c 0,0 -5.96737,4.77391 -5.96737,11.39203 0,3.8744 3.43971,10.3066 11.39202,10.3066 7.95232,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61812 -5.96737,-11.39203 -5.96737,-11.39203 6.62818,5.16335 18.44483,1.6382 18.44483,-8.68095 0,-5.16586 -4.22112,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39202,8.68095 -11.39202,8.68095 0,0 -1.01006,-9.89453 4.88193,-15.19104 h -13.02017 c 5.89199,5.294 4.88193,15.19104 4.88193,15.19104 0,0 -3.43972,-8.68095 -11.39203,-8.68095 -6.630687,0 -10.849305,5.68596 -10.849305,10.84931 0,10.31915 11.816655,13.8443 18.444835,8.68095 z"
|
||||||
|
id="cl-9-8-4" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 110.70832,149.70136 c 0,0 -5.96737,4.77391 -5.96737,11.39203 0,3.8744 3.43971,10.3066 11.39202,10.3066 7.95232,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61812 -5.96737,-11.39203 -5.96737,-11.39203 6.62818,5.16335 18.44483,1.6382 18.44483,-8.68095 0,-5.16586 -4.22112,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39202,8.68095 -11.39202,8.68095 0,0 -1.01006,-9.89453 4.88193,-15.19104 h -13.02017 c 5.89199,5.294 4.88193,15.19104 4.88193,15.19104 0,0 -3.43972,-8.68095 -11.39203,-8.68095 -6.630687,0 -10.849305,5.68596 -10.849305,10.84931 0,10.31915 11.816655,13.8443 18.444835,8.68095 z"
|
||||||
|
id="cl-9-8-0-2" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 46.077633,217.97556 c 0,0 -5.967372,4.77391 -5.967372,11.39203 0,3.8744 3.43972,10.3066 11.392028,10.3066 7.952308,0 11.392028,-6.43471 11.392028,-10.3066 0,-6.61812 -5.967373,-11.39203 -5.967373,-11.39203 6.62818,5.16335 18.444833,1.6382 18.444833,-8.68095 0,-5.16586 -4.22113,-10.84931 -10.849311,-10.84931 -7.952308,0 -11.392027,8.68095 -11.392027,8.68095 0,0 -1.010056,-9.89453 4.881939,-15.19104 H 44.9922 c 5.891994,5.294 4.881938,15.19104 4.881938,15.19104 0,0 -3.439718,-8.68095 -11.392027,-8.68095 -6.630693,0 -10.849311,5.68596 -10.849311,10.84931 0,10.31915 11.816654,13.8443 18.444833,8.68095 z"
|
||||||
|
id="cl-9-8-9-6" /><path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 46.261118,149.87509 c 0,0 -5.967372,4.77391 -5.967372,11.39203 0,3.8744 3.43972,10.3066 11.392028,10.3066 7.952308,0 11.392028,-6.43471 11.392028,-10.3066 0,-6.61812 -5.967373,-11.39203 -5.967373,-11.39203 6.62818,5.16335 18.444833,1.6382 18.444833,-8.68095 0,-5.16586 -4.22113,-10.84931 -10.849311,-10.84931 -7.952308,0 -11.392027,8.68095 -11.392027,8.68095 0,0 -1.010056,-9.89453 4.881939,-15.19104 H 45.175685 c 5.891994,5.294 4.881938,15.19104 4.881938,15.19104 0,0 -3.439718,-8.68095 -11.392027,-8.68095 -6.630693,0 -10.849311,5.68596 -10.849311,10.84931 0,10.31915 11.816654,13.8443 18.444833,8.68095 z"
|
||||||
|
id="cl-9-8-0-4-9" /><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-1.1621548"
|
||||||
|
y="27.170401"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="-1.1621548"
|
||||||
|
y="27.170401"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="11.000458"
|
||||||
|
y="27.499109"
|
||||||
|
id="text3038"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3040"
|
||||||
|
x="11.000458"
|
||||||
|
y="27.499109">0</tspan></text>
|
||||||
|
<path
|
||||||
|
style="fill:#000000"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
d="m 78.0698,183.9376 c 0,0 -5.96738,4.77389 -5.96738,11.39202 0,3.8744 3.43972,10.3066 11.39203,10.3066 7.95231,0 11.39203,-6.43471 11.39203,-10.3066 0,-6.61813 -5.96737,-11.39202 -5.96737,-11.39202 6.62818,5.16334 18.44483,1.6382 18.44483,-8.68096 0,-5.16586 -4.22113,-10.84931 -10.84931,-10.84931 -7.95231,0 -11.39203,8.68096 -11.39203,8.68096 0,0 -1.01005,-9.89454 4.88194,-15.19105 H 76.98436 c 5.892,5.294 4.88194,15.19105 4.88194,15.19105 0,0 -3.43972,-8.68096 -11.39203,-8.68096 -6.630688,0 -10.849308,5.68596 -10.849308,10.84931 0,10.31916 11.816658,13.8443 18.444838,8.68096 z"
|
||||||
|
id="cl-9-8-8-8" /><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-168.80901"
|
||||||
|
y="-216.22618"
|
||||||
|
id="text3788-0"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-6"
|
||||||
|
x="-168.80901"
|
||||||
|
y="-216.22618"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">1</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-156.64639"
|
||||||
|
y="-215.89748"
|
||||||
|
id="text3038-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3040-9"
|
||||||
|
x="-156.64639"
|
||||||
|
y="-215.89748">0</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,216 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="2_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/2_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">2</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,-1.5311156)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,205.12954,245.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 8.4 KiB |
@@ -0,0 +1,224 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="3_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/3_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">3</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,-9.5311159)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,205.12954,253.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-36.788386,60.169684)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-0"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,230 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="4_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/4_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">4</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-67.188386,-1.5311156)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,174.72954,245.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-9.1115857,-1.5311131)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-66"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,232.80634,245.27515)"
|
||||||
|
id="layer1-1-4-8-0-4"><path
|
||||||
|
id="cl-9-8-6-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,238 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="5_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/5_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">5</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-67.188386,-1.5311156)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,174.72954,245.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-9.1115857,-1.5311131)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-66"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,232.80634,245.27515)"
|
||||||
|
id="layer1-1-4-8-0-4"><path
|
||||||
|
id="cl-9-8-6-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-38.388386,61.769684)"
|
||||||
|
id="layer1-1-4-8-2-6"><path
|
||||||
|
id="cl-9-8-0"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,244 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
|
||||||
|
<!-- http://code.google.com/p/vector-playing-cards/ -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="167.0869141pt"
|
||||||
|
height="242.6669922pt"
|
||||||
|
viewBox="0 0 167.0869141 242.6669922"
|
||||||
|
xml:space="preserve"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.48.0 r9654"
|
||||||
|
sodipodi:docname="6_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/6_of_clubs.png"
|
||||||
|
inkscape:export-xdpi="215.44792"
|
||||||
|
inkscape:export-ydpi="215.44792"><metadata
|
||||||
|
id="metadata43"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs41"><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient2984"
|
||||||
|
id="radialGradient3760"
|
||||||
|
cx="48.231091"
|
||||||
|
cy="18.137882"
|
||||||
|
fx="48.231091"
|
||||||
|
fy="18.137882"
|
||||||
|
r="9.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(-1.5605256,0.01828294,-0.02684055,-2.2909528,123.98377,58.809108)" /><linearGradient
|
||||||
|
id="linearGradient2984"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2986" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0.65648854;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2988" /></linearGradient><radialGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient3784"
|
||||||
|
id="radialGradient3792"
|
||||||
|
cx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
fy="511.22299"
|
||||||
|
r="81.902771"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
id="linearGradient3784"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.53435117;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="511.22299"
|
||||||
|
fx="171.48665"
|
||||||
|
cy="511.22299"
|
||||||
|
cx="171.48665"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3855"
|
||||||
|
xlink:href="#linearGradient3784-4"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-4"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.51908398;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-8" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-6" /></linearGradient><radialGradient
|
||||||
|
r="81.902771"
|
||||||
|
fy="461.84113"
|
||||||
|
fx="181.69392"
|
||||||
|
cy="461.84113"
|
||||||
|
cx="181.69392"
|
||||||
|
gradientTransform="matrix(1.1529891,-0.67391547,0.39482025,0.67549043,-233.63262,270.40076)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="radialGradient3916"
|
||||||
|
xlink:href="#linearGradient3784-3"
|
||||||
|
inkscape:collect="always" /><linearGradient
|
||||||
|
id="linearGradient3784-3"><stop
|
||||||
|
style="stop-color:#ffffff;stop-opacity:0.70229006;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3786-86" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop3788-2" /></linearGradient></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1680"
|
||||||
|
inkscape:window-height="977"
|
||||||
|
id="namedview39"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.4336873"
|
||||||
|
inkscape:cx="117.62976"
|
||||||
|
inkscape:cy="148.16686"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg2" />
|
||||||
|
<g
|
||||||
|
id="Layer_x0020_1"
|
||||||
|
style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||||
|
<path
|
||||||
|
style="fill:#FFFFFF;stroke-width:0.5;"
|
||||||
|
d="M166.8369141,235.5478516c0,3.7773438-3.0869141,6.8691406-6.8710938,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805 c3.7841797,0,6.8710938,3.0927734,6.8710938,6.8701172v228.4277344z"
|
||||||
|
id="path5" />
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g7">
|
||||||
|
<g
|
||||||
|
id="g9">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g15">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g19">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g23">
|
||||||
|
<g
|
||||||
|
id="g25">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="stroke:none;"
|
||||||
|
id="g31">
|
||||||
|
<g
|
||||||
|
id="g33">
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="8.3105459"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(1.4856506,0,0,1.4856506,-54.024661,10.018072)"
|
||||||
|
id="layer1-1-4"><path
|
||||||
|
id="cl-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:32px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-158.86395"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">6</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(-1.4856506,0,0,-1.4856506,221.19916,232.46182)"
|
||||||
|
id="layer1-1-4-1"><path
|
||||||
|
id="cl-9-7"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-63.988386,-9.5311159)"
|
||||||
|
id="layer1-1-4-8"><path
|
||||||
|
id="cl-9-8"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,177.92954,253.27515)"
|
||||||
|
id="layer1-1-4-8-0"><path
|
||||||
|
id="cl-9-8-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-63.988386,60.169684)"
|
||||||
|
id="layer1-1-4-8-2"><path
|
||||||
|
id="cl-9-8-0"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-11.20333,-9.7048439)"
|
||||||
|
id="layer1-1-4-8-8"><path
|
||||||
|
id="cl-9-8-9"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(-2.5125778,0,0,-2.5125778,230.7146,253.10142)"
|
||||||
|
id="layer1-1-4-8-0-2"><path
|
||||||
|
id="cl-9-8-6-6"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-11.20333,59.995956)"
|
||||||
|
id="layer1-1-4-8-2-6"><path
|
||||||
|
id="cl-9-8-0-4"
|
||||||
|
d="m 50.291466,22.698228 c 0,0 2.375,-1.9 2.375,-4.534 0,-1.542 -1.369,-4.102 -4.534,-4.102 -3.165,0 -4.534,2.561 -4.534,4.102 0,2.634 2.375,4.534 2.375,4.534 -2.638,-2.055 -7.341,-0.652 -7.341,3.455 0,2.056 1.68,4.318 4.318,4.318 3.165,0 4.534,-3.455 4.534,-3.455 0,0 0.402,3.938 -1.943,6.046 h 5.182 c -2.345,-2.107 -1.943,-6.046 -1.943,-6.046 0,0 1.369,3.455 4.534,3.455 2.639,0 4.318,-2.263 4.318,-4.318 0,-4.107 -4.703,-5.51 -7.341,-3.455 z"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#000000" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |