Compare commits
144 Commits
adece12cf1
...
v0.17.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 534870a68a | |||
| 0066ca6205 | |||
| 54e024c1b0 | |||
| 3a01318fbd | |||
| 79d391724e | |||
| ba019c0ba7 | |||
| 18d7c121a3 | |||
| cb93bd9265 | |||
| 6723416a55 | |||
| afb08799e8 | |||
| 3b619b8950 | |||
| 37681cf33e | |||
| 99064ce808 | |||
| de4dba6f98 | |||
| 75fc3aa3d6 | |||
| deb034c5fb | |||
| 242b5fef21 | |||
| 3f922ede28 | |||
| 8da62bd05f | |||
| 73cad7e205 | |||
| e14852c093 | |||
| 6240156fee | |||
| 1d9fb1884a | |||
| 97f38085e3 | |||
| 62cd1cf924 | |||
| b10e1a5a87 | |||
| 366fd6d127 | |||
| 7a77c66f6d |
@@ -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"
|
||||||
|
}
|
||||||
@@ -70,8 +70,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 +133,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,12 +246,12 @@ 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 |
|
||||||
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
|
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
|
||||||
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge |
|
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge, and the top-right action button bar (Undo / Pause / Help / New Game). Each button fires the same request event the corresponding hotkey does. |
|
||||||
| `StatsPlugin` | S | Stats overlay and persistence |
|
| `StatsPlugin` | S | Stats overlay and persistence |
|
||||||
| `ProgressPlugin` | — | XP/level system, persistence |
|
| `ProgressPlugin` | — | XP/level system, persistence |
|
||||||
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
|
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
|
||||||
@@ -716,11 +716,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 +757,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 +768,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 +1012,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,568 @@
|
|||||||
|
# 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.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
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -35,9 +36,72 @@ 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/wayland/webgl/gilrs/sysinfo)
|
||||||
|
"std",
|
||||||
|
"bevy_winit",
|
||||||
|
"default_font",
|
||||||
|
"multi_threaded",
|
||||||
|
"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).
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
|
**Last updated:** 2026-05-06 (post-v0.17.0) — v0.17.0 cut on top of v0.16.0 bundling the solver-driven hints (`87275bf`) and the replay-rate slider (`53e3b81`). An async-solver attempt earlier in the session was rolled back when an agent left 3 failing tests during interruption — flagged as carryover. Test-to-work ratio noted as a quality signal: future agent briefs scale back to behaviour-level tests only, not stdlib/serde-derive coverage.
|
||||||
|
|
||||||
|
## Status at pause
|
||||||
|
|
||||||
|
- **HEAD on origin:** v0.17.0's tag commit.
|
||||||
|
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
|
||||||
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
|
- **Tests:** **1208 passed / 0 failed** across the workspace.
|
||||||
|
- **Tags on origin:** `v0.9.0` through `v0.17.0`.
|
||||||
|
|
||||||
|
## Where we are
|
||||||
|
|
||||||
|
v0.16.0 is the smallest meaningful release in a while — a focused round on how modals feel rather than what they contain. The originating bug was "I can't scroll on the Achievements list"; the sweep that followed found four other modals with the same problem plus three smaller modal-feel gaps (no pointer cursor on buttons, focus arriving a frame late, no click-outside-to-dismiss).
|
||||||
|
|
||||||
|
Every overlay screen now: scrolls if its content can overflow at 800×600, shows a hand cursor when you hover any button, has its primary auto-focused the moment the modal appears so the very first Tab/Enter is meaningful, and (for read-only screens) dismisses when you click outside the card.
|
||||||
|
|
||||||
|
The post-v0.15.0 next-round candidates are still mostly open — solver-driven hints, replay-rate slider, solver progress overlay, async solver, "won previously" indicator, replay sharing. Direction is open.
|
||||||
|
|
||||||
|
### Design direction (unchanged)
|
||||||
|
|
||||||
|
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
|
||||||
|
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
|
||||||
|
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (machine-local).
|
||||||
|
|
||||||
|
### Canonical remote
|
||||||
|
|
||||||
|
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
|
||||||
|
|
||||||
|
## v0.17.0 (shipped 2026-05-06)
|
||||||
|
|
||||||
|
| Area | Commit | What landed |
|
||||||
|
|---|---|---|
|
||||||
|
| Solver-driven hints | `87275bf` | The H-key hint asks the solver for the actual best first move via `try_solve_with_first_move` / `try_solve_from_state`. Heuristic stays as fallback. Median 2 ms per H press. |
|
||||||
|
| Replay-rate slider | `53e3b81` | Settings → Gameplay slider tunes `replay_move_interval_secs` 0.10–1.00 s in 0.05 s steps; default 0.45 s. Read per frame from `SettingsResource`. |
|
||||||
|
|
||||||
|
## v0.16.0 (shipped 2026-05-06)
|
||||||
|
|
||||||
|
| Area | Commit | What landed |
|
||||||
|
|---|---|---|
|
||||||
|
| Modal scroll | `7a3032b` | Achievements / Help / Stats / Profile / Leaderboard bodies now carry `Overflow::scroll_y()` + a `max_height` constraint + a per-plugin `*Scrollable` marker. Sibling `scroll_*_panel` systems route `MouseWheel` into the body's `ScrollPosition`. Mirrors the existing `SettingsPanelScrollable` pattern. Home modal not scrolled — five mode cards + Cancel are sized to fit by design. |
|
||||||
|
| Pointer cursor | `cd54ce1` | `update_cursor_icon` gains a fourth branch: `SystemCursorIcon::Pointer` whenever any `Interaction::Hovered`/`Pressed` button is detected and no card drag is active. Branch order Grabbing → Pointer → Grab → Default. Pure `pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered)` helper unit-tests the priority. |
|
||||||
|
| Same-frame focus | `48e4121` | `attach_focusable_to_modal_buttons` and `auto_focus_on_modal_open` moved from `Update` to `PostUpdate`. The schedule boundary supplies the sync point so a click-handler in `Update` that spawns a modal has its `Commands` materialised before attach runs. `FocusedButton` is populated before `app.update()` returns; the very first Tab/Enter after open lands on a populated resource. |
|
||||||
|
| Scrim dismiss core | `a54201e` | New `ScrimDismissible` marker on `ModalScrim` opts a modal into click-outside-to-close. `dismiss_modal_on_scrim_click` system in `ui_modal` despawns the topmost dismissible scrim on a left-mouse press whose cursor lands on the scrim and outside every `ModalCard`. Stats / Achievements / Help opted in. |
|
||||||
|
| Scrim dismiss tail | `cbf2483` | One-line opt-in (capture scrim + insert marker) for Profile / Leaderboard / Home, completing all six read-only modals. |
|
||||||
|
|
||||||
|
## Open punch list
|
||||||
|
|
||||||
|
### Release prep
|
||||||
|
|
||||||
|
1. **Desktop packaging** per `ARCHITECTURE.md §17`. Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
|
||||||
|
|
||||||
|
### Process note (raised this session)
|
||||||
|
|
||||||
|
Recent agent briefs reflexively asked for ≥3 tests per feature, which produced low-value coverage on trivial settings fields (default-value tests, serde-derive round-trips, clamp tests that just exercise stdlib `clamp`). Future agent briefs should ask only for tests that pin **behaviour contracts or regressions on real bugs** — not coverage of language/library mechanics.
|
||||||
|
|
||||||
|
### Carryover candidates — still open
|
||||||
|
|
||||||
|
- **Solver-on-AsyncComputeTaskPool** — current solver runs synchronously on the main thread. Worst-case 50 attempts × 120 ms = 6 s of UI stall on pathological seeds. **An attempt this session was rolled back** when an agent was interrupted leaving 3 failing tests; redoing this needs more careful scoping (smaller pieces, real cancel-and-test flow, NOT a parallel agent split). Worth taking next.
|
||||||
|
- **Per-deal "won previously" indicator** — the rolling replay history's seeds make this easy: when a new game starts on a seed the player has already won, surface a tiny indicator on the HUD.
|
||||||
|
- **Replay sharing** — `replays.json` is per-machine. Allow a player to copy a replay's URL (already wired via `solitaire_server`) and post it elsewhere. The web-viewer already exists.
|
||||||
|
|
||||||
|
## Resume prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||||
|
Working directory: <Rusty_Solitaire clone path on this machine — local
|
||||||
|
directory may still be named Rusty_Solitare from earlier; that's fine>.
|
||||||
|
Branch: master. Direction is OPEN — v0.16.0 just shipped covering
|
||||||
|
modal scroll fixes, pointer cursor, same-frame focus, and scrim-click
|
||||||
|
dismiss across all six read-only modals.
|
||||||
|
|
||||||
|
State: HEAD at v0.17.0 (solver hints + replay-rate slider on top
|
||||||
|
of v0.16.0). Working tree clean apart from untracked CARD_PLAN.md
|
||||||
|
(intentional).
|
||||||
|
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||||
|
Tests: 1208 passed / 0 failed.
|
||||||
|
|
||||||
|
READ FIRST (in order, before doing anything):
|
||||||
|
1. SESSION_HANDOFF.md — v0.16.0 changelog + open punch list
|
||||||
|
2. CHANGELOG.md — release-by-release record
|
||||||
|
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||||
|
4. ARCHITECTURE.md — crate responsibilities + data flow
|
||||||
|
5. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
|
— saved feedback / project context (machine-local;
|
||||||
|
may be missing on a fresh machine)
|
||||||
|
|
||||||
|
DECISION TO ASK THE PLAYER FIRST:
|
||||||
|
A. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
|
||||||
|
A previous attempt was rolled back when an agent left 3
|
||||||
|
failing tests; redoing it needs smaller pieces. Eliminates the
|
||||||
|
worst-case 6 s UI stall — highest gameplay impact left.
|
||||||
|
B. Per-deal "won previously" HUD indicator using the rolling
|
||||||
|
replay history's seeds.
|
||||||
|
C. Replay sharing — copyable URL via the existing web viewer.
|
||||||
|
D. Take the deferred desktop-packaging item (needs artwork +
|
||||||
|
signing certs from the user).
|
||||||
|
|
||||||
|
WORKFLOW NOTES:
|
||||||
|
- Commits use:
|
||||||
|
git -c user.name=funman300 -c user.email=root@vscode.infinity \
|
||||||
|
commit -m "..."
|
||||||
|
- When attributing playtester feedback in commits/docs, use "Quat"
|
||||||
|
not "Rhys" (saved feedback memory).
|
||||||
|
- Sub-agents stage + verify only; orchestrator commits.
|
||||||
|
- Every commit must pass build / clippy / test before pushing.
|
||||||
|
- Push to GitHub (origin) — that is the canonical remote.
|
||||||
|
|
||||||
|
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
||||||
|
```
|
||||||
|
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,14 +1,27 @@
|
|||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::{MonitorSelection, PresentMode, 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, WeeklyGoalsPlugin, WinSummaryPlugin,
|
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||||
|
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.
|
||||||
@@ -28,13 +41,49 @@ 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). The window_geometry field is None on first run
|
||||||
|
// and after upgrading from a build that didn't persist geometry.
|
||||||
|
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,
|
||||||
@@ -54,17 +103,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())
|
||||||
@@ -83,5 +138,38 @@ fn main() {
|
|||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
.add_plugins(LeaderboardPlugin)
|
.add_plugins(LeaderboardPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
|
.add_plugins(UiModalPlugin)
|
||||||
|
.add_plugins(UiFocusPlugin)
|
||||||
|
.add_plugins(UiTooltipPlugin)
|
||||||
|
.add_plugins(SplashPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
|||||||
@@ -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,15 +364,13 @@ 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()
|
self.piles
|
||||||
.all(|&suit| {
|
.get(&PileType::Foundation(slot))
|
||||||
self.piles
|
.is_some_and(|p| p.cards.len() == 13)
|
||||||
.get(&PileType::Foundation(suit))
|
})
|
||||||
.is_some_and(|p| p.cards.len() == 13)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
||||||
@@ -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,
|
||||||
@@ -1039,7 +1091,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 +1102,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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 so it's available for web
|
||||||
|
/// playback at `<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<(), 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<(), 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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -298,4 +298,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,702 @@
|
|||||||
|
//! 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ const APP_DIR_NAME: &str = "solitaire_quest";
|
|||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
/// Animation playback speed for card transitions.
|
/// Animation playback speed for card transitions.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub enum AnimSpeed {
|
pub enum AnimSpeed {
|
||||||
/// Standard animation timing (default).
|
/// Standard animation timing (default).
|
||||||
#[default]
|
#[default]
|
||||||
@@ -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
|
||||||
@@ -201,6 +432,7 @@ mod tests {
|
|||||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||||
assert_eq!(s.theme, Theme::Green);
|
assert_eq!(s.theme, Theme::Green);
|
||||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||||
|
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -276,6 +508,13 @@ mod tests {
|
|||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: true,
|
first_run_complete: true,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
|
window_geometry: None,
|
||||||
|
selected_theme_id: "default".to_string(),
|
||||||
|
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(),
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
@@ -406,4 +645,423 @@ mod tests {
|
|||||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// window_geometry — persisted window size/position
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_window_geometry_default_is_none() {
|
||||||
|
assert!(
|
||||||
|
Settings::default().window_geometry.is_none(),
|
||||||
|
"default window_geometry must be None so first launch uses platform defaults"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_with_window_geometry_round_trip() {
|
||||||
|
let path = tmp_path("window_geometry_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let geom = WindowGeometry {
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
x: 120,
|
||||||
|
y: 80,
|
||||||
|
};
|
||||||
|
let s = Settings {
|
||||||
|
window_geometry: Some(geom),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.window_geometry,
|
||||||
|
Some(geom),
|
||||||
|
"window_geometry must survive serde round-trip"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_window_geometry_deserializes_to_none() {
|
||||||
|
// A settings.json written by an older version of the game will be
|
||||||
|
// missing this field entirely. `#[serde(default)]` on the field
|
||||||
|
// must yield `None` rather than failing the whole deserialise.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
s.window_geometry.is_none(),
|
||||||
|
"legacy settings.json missing window_geometry must deserialize to None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_geometry_explicit_null_deserializes_to_none() {
|
||||||
|
// An explicit `"window_geometry": null` is also valid input that
|
||||||
|
// must yield None — keeps tooling that hand-edits the file safe.
|
||||||
|
let json = br#"{ "window_geometry": null }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(s.window_geometry.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// shown_achievement_onboarding — first-win cue one-shot guard
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_shown_achievement_onboarding_default_is_false() {
|
||||||
|
assert!(
|
||||||
|
!Settings::default().shown_achievement_onboarding,
|
||||||
|
"default shown_achievement_onboarding must be false so the cue fires once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_shown_achievement_onboarding_round_trip() {
|
||||||
|
let path = tmp_path("achievement_onboarding_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
shown_achievement_onboarding: true,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
loaded.shown_achievement_onboarding,
|
||||||
|
"shown_achievement_onboarding must survive serde round-trip"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
|
||||||
|
// A settings.json written by an older version of the game will be
|
||||||
|
// missing this field entirely. `#[serde(default)]` on the field
|
||||||
|
// must yield `false` — the cue then fires on the next win, but
|
||||||
|
// only when stats.games_won == 1, so existing players who have
|
||||||
|
// already won past their first game won't see the toast either.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
!s.shown_achievement_onboarding,
|
||||||
|
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// tooltip_delay_secs — player-tunable tooltip hover delay
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_tooltip_delay_default_is_existing_baseline() {
|
||||||
|
// The existing baseline pre-slider is 0.5 s, matching the
|
||||||
|
// `MOTION_TOOLTIP_DELAY_SECS` constant in
|
||||||
|
// `solitaire_engine::ui_theme`. The default must not regress.
|
||||||
|
let s = Settings::default();
|
||||||
|
assert!(
|
||||||
|
(s.tooltip_delay_secs - 0.5).abs() < 1e-6,
|
||||||
|
"tooltip_delay_secs default must be 0.5 (the pre-slider baseline), got {}",
|
||||||
|
s.tooltip_delay_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_tooltip_delay_round_trip() {
|
||||||
|
let path = tmp_path("tooltip_delay_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
tooltip_delay_secs: 1.2,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
(loaded.tooltip_delay_secs - 1.2).abs() < 1e-6,
|
||||||
|
"tooltip_delay_secs must survive serde round-trip; got {}",
|
||||||
|
loaded.tooltip_delay_secs
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_tooltip_delay_deserializes_to_default() {
|
||||||
|
// A settings.json written before this field existed must
|
||||||
|
// deserialize cleanly to the existing 0.5 s baseline rather
|
||||||
|
// than failing the whole load or yielding a zero value.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
(s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6,
|
||||||
|
"legacy settings.json missing tooltip_delay_secs must deserialize to default ({}), got {}",
|
||||||
|
default_tooltip_delay(),
|
||||||
|
s.tooltip_delay_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjust_tooltip_delay_clamps_to_range() {
|
||||||
|
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
|
||||||
|
// Step up to 0.6.
|
||||||
|
assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6);
|
||||||
|
// Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS.
|
||||||
|
assert!((s.adjust_tooltip_delay(5.0) - TOOLTIP_DELAY_MAX_SECS).abs() < 1e-6);
|
||||||
|
// Big negative jump clamps to TOOLTIP_DELAY_MIN_SECS.
|
||||||
|
assert!((s.adjust_tooltip_delay(-99.0) - TOOLTIP_DELAY_MIN_SECS).abs() < 1e-6);
|
||||||
|
// Confirm the floor is exactly zero.
|
||||||
|
assert_eq!(s.tooltip_delay_secs, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitized_clamps_out_of_range_tooltip_delay() {
|
||||||
|
// Negative or oversized values from a hand-edited file must be
|
||||||
|
// clamped on load.
|
||||||
|
let s = Settings {
|
||||||
|
tooltip_delay_secs: -0.4,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s.tooltip_delay_secs, TOOLTIP_DELAY_MIN_SECS);
|
||||||
|
|
||||||
|
let s2 = Settings {
|
||||||
|
tooltip_delay_secs: 99.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// time_bonus_multiplier — cosmetic win-modal time-bonus weight
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_time_bonus_multiplier_default_is_one() {
|
||||||
|
let s = Settings::default();
|
||||||
|
assert!(
|
||||||
|
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||||
|
"default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}",
|
||||||
|
s.time_bonus_multiplier
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_time_bonus_multiplier_round_trip() {
|
||||||
|
let path = tmp_path("time_bonus_multiplier_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
time_bonus_multiplier: 1.5,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
(loaded.time_bonus_multiplier - 1.5).abs() < 1e-6,
|
||||||
|
"time_bonus_multiplier must survive serde round-trip; got {}",
|
||||||
|
loaded.time_bonus_multiplier
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() {
|
||||||
|
// A settings.json written before this field existed must
|
||||||
|
// deserialize cleanly to the existing 1.0 baseline so old
|
||||||
|
// players see no change to their win-modal bonuses.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
|
||||||
|
"legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}",
|
||||||
|
s.time_bonus_multiplier
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_time_bonus_multiplier_clamps_to_range() {
|
||||||
|
// Negative or oversized values from a hand-edited file must be
|
||||||
|
// clamped on load.
|
||||||
|
let s = Settings {
|
||||||
|
time_bonus_multiplier: -0.5,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN);
|
||||||
|
|
||||||
|
let s2 = Settings {
|
||||||
|
time_bonus_multiplier: 99.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
|
||||||
|
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
|
||||||
|
// Step up to 1.1.
|
||||||
|
assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6);
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// winnable_deals_only — solver-backed deal filter toggle
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_winnable_deals_only_default_is_false() {
|
||||||
|
// Off by default — the solver adds latency we shouldn't impose
|
||||||
|
// on every player without their consent.
|
||||||
|
assert!(
|
||||||
|
!Settings::default().winnable_deals_only,
|
||||||
|
"default winnable_deals_only must be false"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_winnable_deals_only_round_trip() {
|
||||||
|
let path = tmp_path("winnable_deals_only_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
winnable_deals_only: true,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
loaded.winnable_deals_only,
|
||||||
|
"winnable_deals_only must survive serde round-trip"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_winnable_deals_only_deserializes_to_false() {
|
||||||
|
// A settings.json written before this field existed must
|
||||||
|
// deserialize cleanly to `false` (the default-off behaviour)
|
||||||
|
// rather than failing the whole load or surprising the player
|
||||||
|
// by switching the toggle on.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
!s.winnable_deals_only,
|
||||||
|
"legacy settings.json missing winnable_deals_only must deserialize to false"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// replay_move_interval_secs — player-tunable replay playback speed
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_replay_move_interval_default_is_zero_point_four_five() {
|
||||||
|
// The pre-slider baseline is 0.45 s/move, matching
|
||||||
|
// `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`.
|
||||||
|
// The default must not regress for players who never touch
|
||||||
|
// the slider.
|
||||||
|
let s = Settings::default();
|
||||||
|
assert!(
|
||||||
|
(s.replay_move_interval_secs - 0.45).abs() < 1e-6,
|
||||||
|
"replay_move_interval_secs default must be 0.45 (the pre-slider baseline), got {}",
|
||||||
|
s.replay_move_interval_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_replay_move_interval_round_trip() {
|
||||||
|
let path = tmp_path("replay_move_interval_round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let s = Settings {
|
||||||
|
replay_move_interval_secs: 0.20,
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
save_settings_to(&path, &s).expect("save");
|
||||||
|
let loaded = load_settings_from(&path);
|
||||||
|
assert!(
|
||||||
|
(loaded.replay_move_interval_secs - 0.20).abs() < 1e-6,
|
||||||
|
"replay_move_interval_secs must survive serde round-trip; got {}",
|
||||||
|
loaded.replay_move_interval_secs
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_settings_without_replay_move_interval_deserializes_to_default() {
|
||||||
|
// A settings.json written before this field existed must
|
||||||
|
// deserialize cleanly to the existing 0.45 s baseline so old
|
||||||
|
// players see no change to replay playback speed.
|
||||||
|
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||||
|
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
(s.replay_move_interval_secs - default_replay_move_interval_secs()).abs() < 1e-6,
|
||||||
|
"legacy settings.json missing replay_move_interval_secs must deserialize to default ({}), got {}",
|
||||||
|
default_replay_move_interval_secs(),
|
||||||
|
s.replay_move_interval_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_replay_move_interval_clamps_to_range() {
|
||||||
|
// Negative or oversized values from a hand-edited file must be
|
||||||
|
// clamped on load.
|
||||||
|
let s = Settings {
|
||||||
|
replay_move_interval_secs: 5.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MAX_SECS);
|
||||||
|
|
||||||
|
let s2 = Settings {
|
||||||
|
replay_move_interval_secs: -1.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(s2.replay_move_interval_secs, REPLAY_MOVE_INTERVAL_MIN_SECS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjust_replay_move_interval_clamps_and_rounds() {
|
||||||
|
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
|
||||||
|
// Step down to 0.40.
|
||||||
|
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
|
||||||
|
// Big positive jump clamps to MAX.
|
||||||
|
assert!(
|
||||||
|
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
|
||||||
|
);
|
||||||
|
// Big negative jump clamps to MIN.
|
||||||
|
assert!(
|
||||||
|
(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!(
|
||||||
|
(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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,54 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
extract_leaderboard_body(resp).await
|
extract_leaderboard_body(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a winning replay to `POST /api/replays`. Mirrors the
|
||||||
|
/// `push` auth flow: 401 triggers a token refresh and one retry.
|
||||||
|
/// Non-success statuses are surfaced as the relevant `SyncError`
|
||||||
|
/// variant so the engine's push-on-win system can downgrade
|
||||||
|
/// network/auth failures into a quiet log without aborting the
|
||||||
|
/// game flow.
|
||||||
|
async fn push_replay(&self, replay: &Replay) -> Result<(), 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 check_replay_status(resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
check_replay_status(resp.status())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_replay_status(status: reqwest::StatusCode) -> Result<(), SyncError> {
|
||||||
|
if status.is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|
|| status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
{
|
||||||
|
Err(SyncError::Auth(format!("server returned {status}")))
|
||||||
|
} else {
|
||||||
|
Err(SyncError::Network(format!("server returned {status}")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,15 @@ 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 }
|
||||||
|
|
||||||
[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 |
@@ -0,0 +1,252 @@
|
|||||||
|
<?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="7_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/7_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">7</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">7</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,-27.131116)"
|
||||||
|
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,269.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,63.369684)"
|
||||||
|
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,-27.304844)"
|
||||||
|
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,269.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,63.195956)"
|
||||||
|
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><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-38.055702,18.622356)"
|
||||||
|
id="layer1-1-4-8-6"><path
|
||||||
|
id="cl-9-8-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></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,260 @@
|
|||||||
|
<?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="8_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/8_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">8</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">8</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,-27.131116)"
|
||||||
|
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,269.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,63.369684)"
|
||||||
|
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,-27.304844)"
|
||||||
|
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,269.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,63.195956)"
|
||||||
|
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><g
|
||||||
|
transform="matrix(2.5125778,0,0,2.5125778,-38.055702,18.622356)"
|
||||||
|
id="layer1-1-4-8-6"><path
|
||||||
|
id="cl-9-8-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,204.43127,226.5922)"
|
||||||
|
id="layer1-1-4-8-6-8"><path
|
||||||
|
id="cl-9-8-8-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></svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,254 @@
|
|||||||
|
<?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="9_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/9_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">9</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">9</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><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.576544,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 C 72.099053,54.117756 60.2824,57.642902 60.2824,67.96206 c 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.630694,0 10.849314,-5.685963 10.849314,-10.84931 0,-10.319158 -11.816657,-13.844304 -18.444836,-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.077528,217.97589 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.992095 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" /></svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,258 @@
|
|||||||
|
<?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="A_of_clubs.svg"
|
||||||
|
inkscape:export-filename="/home/byron/art/cards/final/PNGs/A_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><filter
|
||||||
|
color-interpolation-filters="sRGB"
|
||||||
|
inkscape:collect="always"
|
||||||
|
id="filter3834"
|
||||||
|
x="-0.13934441"
|
||||||
|
width="1.2786888"
|
||||||
|
y="-0.16242018"
|
||||||
|
height="1.3248404"><feGaussianBlur
|
||||||
|
inkscape:collect="always"
|
||||||
|
stdDeviation="9.5105772"
|
||||||
|
id="feGaussianBlur3836" /></filter><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><filter
|
||||||
|
color-interpolation-filters="sRGB"
|
||||||
|
inkscape:collect="always"
|
||||||
|
id="filter3834-6"
|
||||||
|
x="-0.13934441"
|
||||||
|
width="1.2786888"
|
||||||
|
y="-0.16242018"
|
||||||
|
height="1.3248404"><feGaussianBlur
|
||||||
|
inkscape:collect="always"
|
||||||
|
stdDeviation="9.5105772"
|
||||||
|
id="feGaussianBlur3836-6" /></filter><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><filter
|
||||||
|
color-interpolation-filters="sRGB"
|
||||||
|
inkscape:collect="always"
|
||||||
|
id="filter3834-7"
|
||||||
|
x="-0.13934441"
|
||||||
|
width="1.2786888"
|
||||||
|
y="-0.16242018"
|
||||||
|
height="1.3248404"><feGaussianBlur
|
||||||
|
inkscape:collect="always"
|
||||||
|
stdDeviation="9.5105772"
|
||||||
|
id="feGaussianBlur3836-0" /></filter></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="188.71531"
|
||||||
|
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="6.7105455"
|
||||||
|
y="27.548409"
|
||||||
|
id="text3788"
|
||||||
|
sodipodi:linespacing="125%"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790"
|
||||||
|
x="6.7105455"
|
||||||
|
y="27.548409"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">A</tspan></text>
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
transform="matrix(0.20614599,0,0,0.20614599,8.8705463,16.512759)"
|
||||||
|
id="g3804"><g
|
||||||
|
id="layer1-1"
|
||||||
|
transform="matrix(28.969925,0,0,28.969925,-1031.5368,-187.37665)"><path
|
||||||
|
style="fill:url(#radialGradient3760);fill-opacity:1"
|
||||||
|
inkscape:connector-curvature="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"
|
||||||
|
id="cl" /></g><path
|
||||||
|
transform="matrix(1.1091261,0,0,1.2071687,-37.349149,-111.34227)"
|
||||||
|
sodipodi:nodetypes="cscsc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path3762"
|
||||||
|
d="m 117.3013,604.26609 c 0,0 -8.06755,-94.94997 22.85715,-122.85714 34.76052,-31.36871 140,-11.42857 140,-11.42857 0,0 -71.5404,24.83762 -100,48.57143 -27.21033,22.69199 -62.85715,85.71428 -62.85715,85.71428 z"
|
||||||
|
style="fill:url(#radialGradient3792);fill-opacity:1;stroke:none;filter:url(#filter3834)" /><path
|
||||||
|
transform="matrix(1.1091261,0,0,1.2071687,117.2523,-332.26545)"
|
||||||
|
sodipodi:nodetypes="cscsc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path3762-6"
|
||||||
|
d="m 117.3013,604.26609 c 0,0 -8.06755,-94.94997 22.85715,-122.85714 34.76052,-31.36871 140,-11.42857 140,-11.42857 0,0 -71.5404,24.83762 -100,48.57143 -27.21033,22.69199 -62.85715,85.71428 -62.85715,85.71428 z"
|
||||||
|
style="fill:url(#radialGradient3855);fill-opacity:1;stroke:none;filter:url(#filter3834-6)" /><path
|
||||||
|
transform="matrix(1.1420384,0.7029084,-0.84188482,1.367838,729.37187,-305.07466)"
|
||||||
|
sodipodi:nodetypes="cscsc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path3762-7"
|
||||||
|
d="m 117.3013,604.26609 c 0,0 -8.06755,-94.94997 22.85715,-122.85714 34.76052,-31.36871 140,-11.42857 140,-11.42857 0,0 -71.5404,24.83762 -100,48.57143 -27.21033,22.69199 -62.85715,85.71428 -62.85715,85.71428 z"
|
||||||
|
style="fill:url(#radialGradient3916);fill-opacity:1;stroke:none;filter:url(#filter3834-7)" /><path
|
||||||
|
id="rect3015"
|
||||||
|
d="m 28.355532,122.02522 0,734.28125 667.156248,0 0,-734.28125 -667.156248,0 z m 334.281258,97.625 c 91.68979,0 131.37499,74.17213 131.37499,118.84375 0,76.30678 -68.8125,131.34375 -68.8125,131.34375 76.42266,-59.5332 212.65625,-18.88573 212.65625,100.09375 0,59.5332 -48.64211,125.09375 -125.09375,125.09375 -91.68982,0 -131.34374,-100.09375 -131.34374,-100.09375 0,0 -11.65322,114.11662 56.28124,175.15625 l -150.12499,0 c 67.93447,-61.0686 56.3125,-175.15625 56.3125,-175.15625 0,0 -39.65394,100.09375 -131.34375,100.09375 -76.42266,0 -125.093758,-65.53158 -125.093758,-125.09375 0,-118.97948 136.233598,-159.62695 212.656258,-100.09375 0,0 -68.8125,-55.03697 -68.8125,-131.34375 0,-44.64265 39.65394,-118.84375 131.34375,-118.84375 z"
|
||||||
|
style="fill:#fffeff;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||||
|
inkscape:connector-curvature="0" /></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><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="-160.46396"
|
||||||
|
y="-214.4666"
|
||||||
|
id="text3788-8"
|
||||||
|
sodipodi:linespacing="125%"
|
||||||
|
transform="scale(-1,-1)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3790-7"
|
||||||
|
x="-160.46396"
|
||||||
|
y="-214.4666"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Arial;-inkscape-font-specification:Arial">A</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></svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 1.1 MiB |