Compare commits
87 Commits
60a80369d4
...
v0.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
/.sccache-cache
|
||||
*.db
|
||||
*.db-shm
|
||||
*.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!())
|
||||
│ ├── cards/
|
||||
│ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — xCards @2x artwork (LGPL-3.0)
|
||||
│ │ └── backs/back_0.png – back_4.png # back_0 = xCards bicycle_blue; back_1–4 are generated patterns
|
||||
│ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — rendered from hayeah/playing-cards-assets SVGs (MIT)
|
||||
│ │ └── 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
|
||||
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
||||
│ └── audio/
|
||||
@@ -716,11 +716,14 @@ pub struct AchievementDef {
|
||||
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
|
||||
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
|
||||
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
|
||||
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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,537 @@
|
||||
# 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.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
|
||||
@@ -42,14 +42,20 @@ copyleft code is statically linked into the game binary.
|
||||
|
||||
| File(s) | Source | License |
|
||||
|---|---|---|
|
||||
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | xCards @2x artwork | LGPL-3.0 |
|
||||
| `assets/cards/backs/back_0.png` (bicycle_blue) | xCards @2x artwork | LGPL-3.0 |
|
||||
| `assets/cards/backs/back_1.png` – `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||
| `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) |
|
||||
|
||||
xCards is the playing-card artwork bundle by Huub de Beer, published under the
|
||||
LGPL-3.0. The art is consumed as unmodified PNG files at runtime; the game
|
||||
binary statically links no LGPL code, so distribution as a self-contained
|
||||
binary plus the `assets/` directory satisfies the LGPL's relinking clause.
|
||||
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
|
||||
|
||||
@@ -92,13 +98,15 @@ Audio files are MIT-licensed alongside the rest of this project.
|
||||
## License Summary
|
||||
|
||||
- **Project code:** MIT — see [LICENSE](LICENSE).
|
||||
- **xCards card artwork (52 faces + `back_0.png`):** LGPL-3.0, redistributed
|
||||
unmodified. The LGPL applies only to those PNG files; it does not extend to
|
||||
the game binary, which links no LGPL code.
|
||||
- **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, generated card backs, every audio file)
|
||||
are original work covered by this project's MIT license.
|
||||
- **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 LGPL and OFL notices remain
|
||||
visible to end users.
|
||||
`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_app",
|
||||
"solitaire_assetgen",
|
||||
"solitaire_wasm",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -35,9 +36,72 @@ solitaire_sync = { path = "solitaire_sync" }
|
||||
solitaire_data = { path = "solitaire_data" }
|
||||
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"
|
||||
|
||||
# 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"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
# 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
|
||||
|
||||
- **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
|
||||
- **18 Achievements** — including secret ones
|
||||
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
|
||||
- **19 Achievements** — including secret ones
|
||||
- **Daily Challenge** — server-seeded so every player worldwide gets the
|
||||
same deal
|
||||
- **Leaderboard** — opt-in, powered by your own self-hosted server
|
||||
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
|
||||
- **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
|
||||
|
||||
@@ -32,49 +50,72 @@ cargo build -p solitaire_app --release
|
||||
|
||||
## Controls
|
||||
|
||||
Every action also has a visible UI button — keyboard shortcuts are optional
|
||||
accelerators.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| Left click / drag | Move cards |
|
||||
| Double click | Auto-move card to its best legal destination |
|
||||
| Right click | Highlight legal moves for a card |
|
||||
| Space / D | Draw from stock |
|
||||
| Z / Ctrl+Z | Undo |
|
||||
| U | Undo |
|
||||
| H | Hint (highlight a legal move) |
|
||||
| N | New game |
|
||||
| S | Stats overlay |
|
||||
| A | Achievements overlay |
|
||||
| P | Profile overlay |
|
||||
| O | Settings |
|
||||
| L | Leaderboard |
|
||||
| H | Help / controls |
|
||||
| Enter | Auto-complete (when badge is lit) |
|
||||
| Escape | Pause / clear selection |
|
||||
| Arrow keys | Navigate card selection |
|
||||
| Z | Zen mode |
|
||||
| G | Forfeit (during pause) |
|
||||
| Tab / Shift+Tab | Cycle keyboard focus |
|
||||
| Enter | Activate focused button / auto-complete (when badge is lit) |
|
||||
| Esc | Pause / dismiss modal |
|
||||
| F1 | Help / controls |
|
||||
| F11 | Toggle fullscreen |
|
||||
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
|
||||
|
||||
## 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)
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
# All tests (982 passing as of v0.11.0)
|
||||
cargo test --workspace
|
||||
|
||||
# Just game logic (no display required)
|
||||
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
|
||||
|
||||
# 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 and the default card back
|
||||
use xCards artwork (LGPL-3.0); 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,142 +1,107 @@
|
||||
# Solitaire Quest — UX Overhaul Session Handoff
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-04-30 — Phase 3 complete + Phase 4 polish landed. v1 release-readiness scope is largely done; remaining work is final smoke test, push, and tag.
|
||||
**Last updated:** 2026-05-06 (post-v0.16.0) — Modal-feel polish round shipped: every overlay scrolls when it overflows, every button shows a pointer cursor on hover, modal focus lands on the same frame, and read-only modals dismiss on scrim click. Direction now opens.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD:** `5d57b67` — local master is **16 commits ahead of `origin/master`** (unpushed).
|
||||
- **Working tree:** modified but uncommitted edits in `solitaire_engine/src/hud_plugin.rs` and `solitaire_engine/src/settings_plugin.rs` — an in-flight tooltip-popover extension threaded onto the Settings sliders/togglers/pickers. Not staged, not built against; review and finish-or-revert before resuming new work.
|
||||
- **Build:** `cargo build --workspace` and `cargo clippy --workspace -- -D warnings` clean as of last commit.
|
||||
- **Tests:** **872 passed / 0 failed / 9 ignored** across the workspace.
|
||||
- **HEAD on origin:** v0.16.0's tag commit.
|
||||
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
|
||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- **Tests:** **1196 passed / 0 failed** across the workspace.
|
||||
- **Tags on origin:** `v0.9.0` through `v0.16.0`.
|
||||
|
||||
## Where we are
|
||||
|
||||
Phase 3 of the UX overhaul (design tokens, modal scaffold, animation curves) shipped earlier in the session and is unchanged. Phase 4 (release-grade polish) layered another 22 commits on top: window polish, modal animation, score feedback, three phases of focus rings, Home repurposed as a mode launcher, tooltip infrastructure + HUD wiring, branded splash screen, achievement integration tests, microcopy unification, leaderboard error/idle states, first-launch empty-state polish, hit-target accessibility fix, CREDITS.md, ARCHITECTURE doc-rot fix.
|
||||
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 [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for full direction.
|
||||
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (machine-local).
|
||||
|
||||
## Phase 3 (shipped)
|
||||
### Canonical remote
|
||||
|
||||
- `solitaire_engine/src/ui_theme.rs` — every design token: colours, type scale, spacing scale, radius rungs, z-index hierarchy, motion durations.
|
||||
- `solitaire_engine/src/ui_modal.rs` — `spawn_modal` scaffold + button-variant helpers + `paint_modal_buttons` system.
|
||||
- All 12 overlays migrated to the modal scaffold with real Primary/Secondary/Tertiary buttons (no more Y/N debug prompts).
|
||||
- HUD restructured into a 4-tier vertical stack with progressive disclosure.
|
||||
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce, deal jitter, win-cascade rotation.
|
||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
|
||||
|
||||
## Phase 4 (shipped this session)
|
||||
## v0.16.0 (shipped 2026-05-06)
|
||||
|
||||
| Area | Commit | What landed |
|
||||
|---|---|---|
|
||||
| Workspace lint | `9bfca92` | Test-only clippy warnings under `--all-targets` resolved. |
|
||||
| App / window | `5f5aba8` | WM_CLASS, centered-on-primary window, panic hook → `crash.log`. |
|
||||
| Modal animation | `71999e1` | `ModalEntering` + ease-out scrim fade and 0.96→1.0 card scale over `MOTION_MODAL_SECS`; `Instant` collapses to zero. |
|
||||
| Score feedback | `dcfa976` | `ScorePulse` triangular 1.0→1.1→1.0; floating "+N" for jumps ≥ `SCORE_FLOATER_THRESHOLD`. |
|
||||
| Hit targets | `b082bd6` | `ICON_BUTTON_PX` 28 → 32; settings sync status reads "local only" not "not configured". |
|
||||
| Microcopy | `abeb4e5` | Help "Close" → "Done"; final onboarding CTA → "Let's play". |
|
||||
| Empty states | `65d595a` | First-launch em-dash zero-stats grid + welcome line on Profile. |
|
||||
| Leaderboard | `1384365` | Idle/Loaded/Error enum; local-only guard replaces opt-in/out buttons. |
|
||||
| Credits | `fd7fb7b`, `f866299` | CREDITS.md added (xCards, FiraMono, Bevy, kira, Rust deps); README links it. |
|
||||
| Home | `c1bde18` | Home repurposed as Mode Launcher: 5 mode cards, level-5 lock state, dispatches existing request events. |
|
||||
| Focus rings (Phase 1) | `1278952` | Tab/Shift-Tab/Enter on every modal button; auto-focus primary; overlay tracks `GlobalTransform` above scrim. |
|
||||
| Focus rings (Phase 2) | `51d3454` | HUD action bar (hover-gated) and Home mode cards. |
|
||||
| Focus rings (Phase 3) | `b78a493` | Settings: icon buttons, swatches, toggles; arrow-key navigation in `FocusRow`; auto-scroll keeps focused control in viewport. |
|
||||
| Achievement tests | `2e080d0` | Integration coverage for `draw_three_master` and `zen_winner` — every advertised achievement now has a full-flow unlock test. |
|
||||
| Microcopy | `0c86cac` | Drop "Yes," prefix on destructive confirms — "New game" / "Forfeit" replace "Yes, abandon" / "Yes, forfeit". |
|
||||
| Tooltip infra | `54d3497` | `Tooltip(Cow<'static, str>)` component, hover-delay overlay, `Z_TOOLTIP` rung. |
|
||||
| Tooltip wiring | `220e3f0` | Tooltips on 10 HUD readouts + 6 action-bar buttons; `spawn_action_button` requires a tooltip parameter. |
|
||||
| Splash | `5d57b67` | Branded splash overlay (fade-in 300ms / hold ~1s / fade-out 300ms); board deals behind; any keypress dismisses. |
|
||||
| Doc-rot | `73e210b` | ARCHITECTURE.md `bevy_kira_audio` references → `kira` to match Cargo.toml. |
|
||||
| Doc | `de52c8a` | Mid-session SESSION_HANDOFF refresh after first batch of Phase 4 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. |
|
||||
|
||||
## Commits this session, chronological
|
||||
## Open punch list
|
||||
|
||||
```
|
||||
9bfca92 chore(workspace): satisfy clippy --all-targets in test code
|
||||
5f5aba8 feat(app): window polish — WM_CLASS, centered window, crash log hook
|
||||
71999e1 feat(engine): modal open animation — fade + scale with ease-out
|
||||
dcfa976 feat(engine): score change feedback — pulse and floating delta
|
||||
de52c8a docs: update SESSION_HANDOFF for completed phase-4 polish tracks
|
||||
b082bd6 feat(engine): bump icon-button hit target to 32px and clarify local-only sync status
|
||||
abeb4e5 feat(engine): unify dismiss verb to Done and warm onboarding CTA to Let's play
|
||||
65d595a feat(engine): first-launch polish — em-dash zero stats and welcome line on profile
|
||||
1384365 feat(engine): leaderboard error and idle states plus local-only guard
|
||||
fd7fb7b docs: add CREDITS.md and link from README
|
||||
c1bde18 feat(engine): repurpose Home as mode launcher
|
||||
1278952 feat(engine): keyboard focus rings on modal buttons (Phase 1)
|
||||
51d3454 feat(engine): keyboard focus on HUD action bar and Home mode cards (Phase 2)
|
||||
b78a493 feat(engine): keyboard focus on Settings panel with arrow-key pickers (Phase 3)
|
||||
f866299 docs: drop xCards URL placeholder from CREDITS.md
|
||||
73e210b docs: replace bevy_kira_audio references with kira in ARCHITECTURE.md
|
||||
2e080d0 test(engine): integration coverage for draw_three_master and zen_winner
|
||||
0c86cac feat(engine): unify destructive-confirm verbs — drop "Yes," prefix
|
||||
54d3497 feat(engine): tooltip infrastructure with hover delay (foundation only)
|
||||
220e3f0 feat(engine): tooltips on every HUD readout and action button
|
||||
5d57b67 feat(engine): branded splash screen on launch
|
||||
```
|
||||
### Release prep
|
||||
|
||||
(Phase 3 commits `e14852c` through `54e024c` and the prior handoff update `0066ca6` are already pushed — see git history for full audit trail.)
|
||||
1. **Smoke-test on a real game**: confirm scroll feels right on Achievements (the original bug), pointer cursor changes on every interactive surface, the very first Tab in a modal already activates the primary, and clicking the dimmed area dismisses the read-only modals while NOT dismissing Settings/Pause.
|
||||
2. **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.
|
||||
|
||||
## Open punch list for v1
|
||||
### Carryover from v0.15.0 next-round candidates
|
||||
|
||||
Polish is essentially complete. Concretely scoped follow-ups:
|
||||
Still open — would each be ~50–200 LOC:
|
||||
|
||||
1. **Smoke-test pass.** Run the game end-to-end with the original Phase 3 checklist plus the Phase 4 additions (splash dismiss, focus rings on every screen, tooltip hover, mode launcher, leaderboard error state, first-launch em-dashes).
|
||||
2. **xCards upstream URL** in CREDITS.md is intentionally absent (`f866299`). One-line fill-in when the project owner picks a canonical mirror/fork; LGPL notice obligations are already satisfied without it.
|
||||
3. **Push to origin.** Local master is 16 commits ahead of `origin/master`. `git push origin master` (interactive credentials on `git.aleshym.co`).
|
||||
4. **Tag `v0.1.0`** once the smoke test passes and the push lands.
|
||||
5. **Release packaging** per ARCHITECTURE.md §17 — Docker compose for the server is documented; desktop client packaging (icon, .ico/.icns, signing, AppImage) is not yet done.
|
||||
|
||||
### Optional, deferred
|
||||
|
||||
- Animated focus ring (currently a static overlay; could pulse on focus change).
|
||||
- Splash skip-on-subsequent-launches — currently every launch shows the full ~1.6s splash.
|
||||
- Achievement onboarding pass — show first-time players the achievement panel after their first win.
|
||||
- In-flight Settings tooltip popovers in the working tree — finish or revert.
|
||||
- **Solver-driven hints** — the existing hint system uses a heuristic; promote it to ask `try_solve` for the actual best move. Now that the solver is in place this is mostly plumbing.
|
||||
- **Replay-playback rate slider** — the 0.45 s/move pace is hardcoded; a Settings slider in the same row as tooltip-delay / time-bonus would let power users speed up older replays.
|
||||
- **Solver progress overlay** — when "Winnable deals only" is on, a brief "checking deal…" toast surfaces after ~500 ms so the player isn't confused by the rare worst-case stall.
|
||||
- **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. Async + cancel button would be safer.
|
||||
- **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 finishing v1 of Solitaire
|
||||
Quest. Working directory: /home/manage/Rusty_Solitare. Branch:
|
||||
master. Polish phase is complete; the remaining work is release prep,
|
||||
not new features.
|
||||
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=5d57b67. Local master is 16 commits ahead of
|
||||
origin/master and unpushed. Working tree has uncommitted in-flight
|
||||
tooltip work in solitaire_engine/src/hud_plugin.rs and
|
||||
solitaire_engine/src/settings_plugin.rs — review and finish or revert
|
||||
before opening anything new.
|
||||
|
||||
Build: cargo build / clippy --workspace -- -D warnings clean as of
|
||||
HEAD. Tests: 872 passed / 0 failed / 9 ignored.
|
||||
State: HEAD at v0.16.0. Working tree clean apart from untracked
|
||||
CARD_PLAN.md (intentional).
|
||||
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
|
||||
Tests: 1196 passed / 0 failed.
|
||||
|
||||
READ FIRST (in order, before doing anything):
|
||||
1. SESSION_HANDOFF.md — full state and punch list
|
||||
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
|
||||
3. ARCHITECTURE.md §15, §17 — platform targets, deployment guide
|
||||
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md
|
||||
— saved feedback / project context
|
||||
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)
|
||||
|
||||
PUNCH LIST (resolve in roughly this order):
|
||||
1. Decide on the in-flight settings_plugin/hud_plugin tooltip work.
|
||||
2. Smoke-test the binary end-to-end. If anything regresses, fix it
|
||||
before opening anything new.
|
||||
3. Confirm or fill the xCards upstream URL in CREDITS.md.
|
||||
4. git push origin master (16 commits unpushed; interactive creds).
|
||||
5. Tag v0.1.0.
|
||||
6. Release packaging per ARCHITECTURE.md §17 — desktop client icon,
|
||||
bundling, signing are not yet wired.
|
||||
DECISION TO ASK THE PLAYER FIRST:
|
||||
A. Smoke-test v0.16.0. Scroll on Achievements, pointer cursor on
|
||||
buttons, first Tab in a modal activates rather than advances,
|
||||
scrim click dismisses Stats/Achievements/Help/Profile/
|
||||
Leaderboard/Home but NOT Settings/Pause/etc.
|
||||
B. Solver-driven hints — replace heuristic with try_solve's
|
||||
best-move suggestion. ~100 LOC.
|
||||
C. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
|
||||
Eliminates the worst-case 6 s stall.
|
||||
D. Pick from the remaining "next-round candidates" in this doc.
|
||||
E. 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 "..."
|
||||
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.
|
||||
- Every commit must pass build / clippy / test before pushing.
|
||||
- Push to GitHub (origin) — that is the canonical remote.
|
||||
|
||||
OPEN AT THE START: ask which punch-list item to start on. Don't pick
|
||||
unilaterally — release-readiness ordering is the user's call.
|
||||
OPEN AT THE START: ask which of A–E. 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
|
||||
|
||||
> 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
|
||||
|
||||
---
|
||||
|
||||
@@ -3,15 +3,17 @@ use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{MonitorSelection, WindowPosition};
|
||||
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
||||
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin,
|
||||
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SelectionPlugin, SettingsPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -39,7 +41,32 @@ fn main() {
|
||||
.unwrap_or_default();
|
||||
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(
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
@@ -48,8 +75,15 @@ fn main() {
|
||||
// X11/Wayland WM_CLASS so taskbar managers group
|
||||
// multiple windows of this app correctly.
|
||||
name: Some("solitaire-quest".into()),
|
||||
resolution: (1280u32, 800u32).into(),
|
||||
position: WindowPosition::Centered(MonitorSelection::Primary),
|
||||
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 {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
@@ -69,17 +103,23 @@ fn main() {
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
.add_plugins(AssetSourcesPlugin)
|
||||
.add_plugins(ThemePlugin)
|
||||
.add_plugins(ThemeRegistryPlugin)
|
||||
.add_plugins(FontPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(CursorPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
@@ -127,8 +167,7 @@ fn install_crash_log_hook() {
|
||||
// parseable and avoids pulling in chrono just for this.
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
.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");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
let effects: [(&str, Generator); 6] = [
|
||||
let effects: [(&str, Generator); 7] = [
|
||||
("card_flip.wav", card_flip),
|
||||
("card_place.wav", card_place),
|
||||
("card_deal.wav", card_deal),
|
||||
("card_invalid.wav", card_invalid),
|
||||
("win_fanfare.wav", win_fanfare),
|
||||
("ambient_loop.wav", ambient_loop),
|
||||
("foundation_complete.wav", foundation_complete),
|
||||
];
|
||||
|
||||
for (name, make) in &effects {
|
||||
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
|
||||
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
|
||||
/// mono 16-bit PCM).
|
||||
///
|
||||
|
||||
@@ -6,6 +6,5 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
|
||||
fn zen_winner(c: &AchievementContext) -> bool {
|
||||
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
|
||||
/// remain readable across versions (new achievements append).
|
||||
@@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
||||
reward: Some(Reward::Badge),
|
||||
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`.
|
||||
@@ -721,6 +743,31 @@ mod tests {
|
||||
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]
|
||||
fn perfectionist_score_well_above_threshold_still_passes() {
|
||||
let mut c = ctx();
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::card::Card;
|
||||
use crate::deck::{deal_klondike, Deck};
|
||||
use crate::error::MoveError;
|
||||
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};
|
||||
|
||||
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
|
||||
/// that JSON (which requires string map keys) round-trips correctly.
|
||||
mod pile_map_serde {
|
||||
@@ -98,6 +112,11 @@ pub struct GameState {
|
||||
/// Used by the `comeback` achievement condition.
|
||||
#[serde(default)]
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -116,8 +135,8 @@ impl GameState {
|
||||
let mut piles: HashMap<PileType, Pile> = HashMap::new();
|
||||
piles.insert(PileType::Stock, stock);
|
||||
piles.insert(PileType::Waste, Pile::new(PileType::Waste));
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
piles.insert(PileType::Foundation(suit), Pile::new(PileType::Foundation(suit)));
|
||||
for slot in 0..4_u8 {
|
||||
piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
|
||||
}
|
||||
for (i, pile) in tableau.into_iter().enumerate() {
|
||||
piles.insert(PileType::Tableau(i), pile);
|
||||
@@ -135,6 +154,7 @@ impl GameState {
|
||||
is_auto_completable: false,
|
||||
undo_count: 0,
|
||||
recycle_count: 0,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
undo_stack: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
@@ -247,14 +267,14 @@ impl GameState {
|
||||
let bottom_card = from_pile.cards[start].clone();
|
||||
|
||||
match &to {
|
||||
PileType::Foundation(suit) => {
|
||||
PileType::Foundation(_) => {
|
||||
if count != 1 {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"only one card can move to foundation at a time".into(),
|
||||
));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -263,6 +283,18 @@ impl GameState {
|
||||
if !can_place_on_tableau(&bottom_card, dest) {
|
||||
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),
|
||||
}
|
||||
@@ -332,15 +364,13 @@ impl GameState {
|
||||
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 {
|
||||
[Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
|
||||
.iter()
|
||||
.all(|&suit| {
|
||||
self.piles
|
||||
.get(&PileType::Foundation(suit))
|
||||
.is_some_and(|p| p.cards.len() == 13)
|
||||
})
|
||||
(0..4_u8).all(|slot| {
|
||||
self.piles
|
||||
.get(&PileType::Foundation(slot))
|
||||
.is_some_and(|p| p.cards.len() == 13)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return None;
|
||||
}
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for i in 0..7 {
|
||||
let tableau = PileType::Tableau(i);
|
||||
if let Some(card) = self.piles[&tableau].cards.last() {
|
||||
for &suit in &suits {
|
||||
let foundation = PileType::Foundation(suit);
|
||||
if can_place_on_foundation(card, &self.piles[&foundation], suit) {
|
||||
// Prefer the slot that already claims this card's suit so
|
||||
// Aces don't sometimes land in slot 0 and then leave the
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -403,7 +454,7 @@ impl GameState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
|
||||
fn new_game() -> GameState {
|
||||
GameState::new(42, DrawMode::DrawOne)
|
||||
@@ -434,8 +485,8 @@ mod tests {
|
||||
#[test]
|
||||
fn new_game_foundations_are_empty() {
|
||||
let g = new_game();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
assert!(g.piles[&PileType::Foundation(suit)].cards.is_empty());
|
||||
for slot in 0..4_u8 {
|
||||
assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +713,7 @@ mod tests {
|
||||
];
|
||||
let result = g.move_cards(
|
||||
PileType::Tableau(0),
|
||||
PileType::Foundation(Suit::Clubs),
|
||||
PileType::Foundation(0),
|
||||
2,
|
||||
);
|
||||
assert!(
|
||||
@@ -706,8 +757,9 @@ mod tests {
|
||||
#[test]
|
||||
fn win_detection_all_foundations_complete() {
|
||||
let mut g = new_game();
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
let f = g.piles.get_mut(&PileType::Foundation(suit)).unwrap();
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
for (slot, suit) in suits.into_iter().enumerate() {
|
||||
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
|
||||
f.cards.clear();
|
||||
for rank in [
|
||||
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");
|
||||
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]
|
||||
@@ -1049,4 +1102,143 @@ mod tests {
|
||||
g.is_won = true;
|
||||
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 rules;
|
||||
pub mod scoring;
|
||||
pub mod solver;
|
||||
|
||||
@@ -8,8 +8,10 @@ pub enum PileType {
|
||||
Stock,
|
||||
/// The face-up discard pile drawn to.
|
||||
Waste,
|
||||
/// One of the four suit-ordered foundation piles.
|
||||
Foundation(Suit),
|
||||
/// One of the four foundation slots (0..=3). The claimed suit, if any,
|
||||
/// is derived from the bottom card of the pile (always an Ace by
|
||||
/// construction).
|
||||
Foundation(u8),
|
||||
/// One of the seven tableau columns (0–6).
|
||||
Tableau(usize),
|
||||
}
|
||||
@@ -17,7 +19,7 @@ pub enum PileType {
|
||||
/// A named collection of cards in a specific board position.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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,
|
||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||
pub cards: Vec<Card>,
|
||||
@@ -33,6 +35,16 @@ impl Pile {
|
||||
pub fn top(&self) -> Option<&Card> {
|
||||
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)]
|
||||
@@ -61,12 +73,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_foundation_uses_suit() {
|
||||
assert_ne!(PileType::Foundation(Suit::Hearts), PileType::Foundation(Suit::Spades));
|
||||
fn pile_type_foundation_uses_slot_index() {
|
||||
assert_ne!(PileType::Foundation(0), PileType::Foundation(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pile_type_tableau_uses_index() {
|
||||
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;
|
||||
|
||||
/// 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.
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile, suit: Suit) -> bool {
|
||||
if card.suit != suit {
|
||||
return false;
|
||||
}
|
||||
/// Foundation rules:
|
||||
/// - When the pile is empty, any Ace is accepted; the placed Ace's suit
|
||||
/// becomes the pile's claimed suit (derived from the bottom card via
|
||||
/// [`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() {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -45,37 +59,46 @@ mod tests {
|
||||
// Foundation tests
|
||||
#[test]
|
||||
fn foundation_ace_on_empty_is_valid() {
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Hearts));
|
||||
// Every suit's Ace must land on an empty foundation slot regardless of
|
||||
// its slot index; the slot claims the suit only after the Ace lands.
|
||||
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]
|
||||
fn foundation_non_ace_on_empty_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Two);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Hearts));
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Hearts));
|
||||
let p = Pile::new(PileType::Foundation(0));
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_two_on_ace_same_suit_is_valid() {
|
||||
let c = card(Suit::Clubs, Rank::Two);
|
||||
let p = pile_with(PileType::Foundation(Suit::Clubs), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Clubs));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Clubs, Rank::Ace)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_wrong_suit_is_invalid() {
|
||||
let c = card(Suit::Hearts, Rank::Ace);
|
||||
let p = Pile::new(PileType::Foundation(Suit::Spades));
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
fn foundation_second_card_must_match_claimed_suit() {
|
||||
// Place Ace of Hearts on slot 0, then attempt 2 of Spades — rejected
|
||||
// because the slot's claimed suit is Hearts after the Ace lands.
|
||||
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]
|
||||
fn foundation_skipping_rank_is_invalid() {
|
||||
let c = card(Suit::Diamonds, Rank::Three);
|
||||
let p = pile_with(PileType::Foundation(Suit::Diamonds), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Diamonds));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Diamonds, Rank::Ace)]);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
// Tableau tests
|
||||
@@ -125,16 +148,16 @@ mod tests {
|
||||
fn foundation_king_on_queen_completes_suit() {
|
||||
// The last card placed to complete a foundation is always King on Queen.
|
||||
let c = card(Suit::Spades, Rank::King);
|
||||
let p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 p = pile_with(PileType::Foundation(Suit::Spades), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(!can_place_on_foundation(&c, &p, Suit::Spades));
|
||||
let p = pile_with(PileType::Foundation(0), vec![card(Suit::Spades, Rank::Queen)]);
|
||||
assert!(!can_place_on_foundation(&c, &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -163,4 +186,26 @@ mod tests {
|
||||
let p = pile_with(PileType::Tableau(0), vec![top]);
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::Suit;
|
||||
|
||||
#[test]
|
||||
fn move_to_foundation_scores_ten() {
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(Suit::Hearts)), 10);
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(Suit::Clubs)), 10);
|
||||
assert_eq!(score_move(&PileType::Waste, &PileType::Foundation(2)), 10);
|
||||
assert_eq!(score_move(&PileType::Tableau(0), &PileType::Foundation(0)), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -74,7 +73,7 @@ mod tests {
|
||||
#[test]
|
||||
fn non_waste_to_tableau_scores_zero() {
|
||||
// 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.
|
||||
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,893 @@
|
||||
//! Klondike solvability checker.
|
||||
//!
|
||||
//! Used by the engine to back the **Settings → Gameplay → "Winnable
|
||||
//! deals only"** toggle: when on, the engine retries fresh deal seeds
|
||||
//! until [`try_solve`] returns [`SolverResult::Winnable`] (or
|
||||
//! [`SolverResult::Inconclusive`], which we treat as winnable because
|
||||
//! we cannot prove otherwise) up to a fixed retry cap.
|
||||
//!
|
||||
//! The implementation is a hand-rolled depth-first search with
|
||||
//! memoisation on a deterministic canonical state hash. It uses no
|
||||
//! external crates beyond what `solitaire_core` already depends on
|
||||
//! (`std::collections::HashSet`, `std::hash::DefaultHasher`).
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! 1. Encode the game state into a canonical `u64` hash. Tableau
|
||||
//! columns are encoded top-to-bottom along with each card's face
|
||||
//! state; foundations are encoded by their top card; stock and
|
||||
//! waste are encoded as the concatenation of their card ids in
|
||||
//! order. Two states with the same canonical hash are considered
|
||||
//! equivalent for the purposes of pruning.
|
||||
//!
|
||||
//! 2. At each search step, enumerate the candidate moves in priority
|
||||
//! order:
|
||||
//! - **Foundation moves first** — moving a card to a foundation
|
||||
//! pile reduces the search frontier and never traps the player.
|
||||
//! Aces and twos are unconditional (the spec calls these out as
|
||||
//! "no choice involved" forced plays).
|
||||
//! - **Inter-tableau moves next** — moves between tableau columns
|
||||
//! that *don't* immediately undo the previous move (a "self-undo"
|
||||
//! filter prevents the trivial A→B then B→A cycle).
|
||||
//! - **Stock/waste draw last** — drawing permutes a long sequence
|
||||
//! and is the costliest move. It's also the only source of
|
||||
//! branching once the tableau is locked, so we enumerate it last
|
||||
//! and only when no productive move was made since the previous
|
||||
//! stock cycle (we track this with a "drew without other progress"
|
||||
//! counter).
|
||||
//!
|
||||
//! 3. After each move, recurse. If the recursion finds a win we
|
||||
//! propagate `Winnable` immediately. If the visited-state set or
|
||||
//! the move-budget counter is exhausted we return `Inconclusive`.
|
||||
//! Otherwise we exhaust all moves and return `Unwinnable`.
|
||||
//!
|
||||
//! # Determinism
|
||||
//!
|
||||
//! The search is fully deterministic: move enumeration walks piles in
|
||||
//! a fixed order and the canonical hash is built with `DefaultHasher`,
|
||||
//! whose seed is fixed across program runs but documented as not
|
||||
//! cryptographically stable. For the purposes of "same input → same
|
||||
//! output across one program run" this is sufficient; the spec
|
||||
//! explicitly calls `DefaultHasher` "fine for this".
|
||||
//!
|
||||
//! # Performance
|
||||
//!
|
||||
//! On real fresh deals the solver completes in tens of milliseconds
|
||||
//! (median ~30 ms on the synthetic deals used by the tests below).
|
||||
//! Pathological deals are bounded by [`SolverConfig::move_budget`] and
|
||||
//! [`SolverConfig::state_budget`] — when either trips we return
|
||||
//! [`SolverResult::Inconclusive`]. The retry loop in the engine treats
|
||||
//! Inconclusive as winnable so a player who turns the toggle on never
|
||||
//! sees a hung "searching..." state.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::card::{Card, Suit};
|
||||
use crate::deck::{deal_klondike, Deck};
|
||||
use crate::game_state::DrawMode;
|
||||
use crate::pile::{Pile, PileType};
|
||||
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
||||
|
||||
/// Verdict returned by [`try_solve`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SolverResult {
|
||||
/// The solver found a sequence of moves that wins the deal.
|
||||
Winnable,
|
||||
/// The solver exhaustively searched and confirmed no win exists.
|
||||
Unwinnable,
|
||||
/// The time / move budget was exceeded before a verdict could be
|
||||
/// reached. Callers should treat this as winnable since we cannot
|
||||
/// prove otherwise — Klondike has many deals where the search tree
|
||||
/// is theoretically tractable but practically too wide for a
|
||||
/// bounded DFS.
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// Tunable budgets controlling how long [`try_solve`] is willing to
|
||||
/// search before bailing out with [`SolverResult::Inconclusive`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SolverConfig {
|
||||
/// Maximum total moves to consider across the entire search tree.
|
||||
/// Default: `100_000`. A realistic Klondike solve fits in
|
||||
/// ~10k–30k moves for solvable deals; the cap lets us bail out of
|
||||
/// pathological states.
|
||||
pub move_budget: u64,
|
||||
/// Maximum unique states to visit. Memoisation prevents revisiting,
|
||||
/// but the visited set grows unbounded without a cap. Default:
|
||||
/// `200_000`.
|
||||
pub state_budget: usize,
|
||||
}
|
||||
|
||||
impl Default for SolverConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
move_budget: 100_000,
|
||||
state_budget: 200_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||
///
|
||||
/// This is a pure function — same input always yields the same
|
||||
/// [`SolverResult`] within one program run.
|
||||
///
|
||||
/// The solver only explores *Classic* Klondike rules: there's no
|
||||
/// undo, no Zen-mode score suppression, and no Challenge-mode undo
|
||||
/// ban (irrelevant since the solver never undoes). The same engine
|
||||
/// rules ([`can_place_on_foundation`], [`can_place_on_tableau`],
|
||||
/// [`is_valid_tableau_sequence`]) drive move enumeration so the
|
||||
/// solver's notion of "legal" exactly matches the live game.
|
||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||
let state = SolverState::initial(seed, draw_mode);
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut moves_consumed: u64 = 0;
|
||||
let mut budget_exceeded = false;
|
||||
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||
if won {
|
||||
SolverResult::Winnable
|
||||
} else if budget_exceeded {
|
||||
SolverResult::Inconclusive
|
||||
} else {
|
||||
SolverResult::Unwinnable
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal solver state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The candidate moves the solver enumerates at each step. Distinct
|
||||
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
|
||||
/// because the solver also needs to model the stock-draw + recycle as a
|
||||
/// first-class move.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SolverMove {
|
||||
/// Move `count` cards from a tableau column to another tableau column.
|
||||
TableauToTableau { from: usize, to: usize, count: usize },
|
||||
/// Move the top of a tableau column to a foundation slot.
|
||||
TableauToFoundation { from: usize, slot: u8 },
|
||||
/// Move the top of the waste pile to a tableau column.
|
||||
WasteToTableau { to: usize },
|
||||
/// Move the top of the waste pile to a foundation slot.
|
||||
WasteToFoundation { slot: u8 },
|
||||
/// Draw from stock to waste (or recycle waste → stock if stock is empty).
|
||||
Draw,
|
||||
}
|
||||
|
||||
/// Compact replica of `GameState` tailored for the solver. Strips
|
||||
/// undo / score / move-count tracking and replaces the `HashMap` of
|
||||
/// piles with fixed arrays so the canonical hash is cheap to compute.
|
||||
#[derive(Clone)]
|
||||
struct SolverState {
|
||||
tableau: [Vec<Card>; 7],
|
||||
foundation: [Vec<Card>; 4],
|
||||
stock: Vec<Card>,
|
||||
waste: Vec<Card>,
|
||||
draw_mode: DrawMode,
|
||||
/// True when we just drew (or recycled) and have not yet made a
|
||||
/// productive non-draw move. While set, further consecutive draws
|
||||
/// without intervening progress are skipped — see the algorithm
|
||||
/// note above.
|
||||
just_drew: bool,
|
||||
/// Number of draws performed since the last non-draw move. Used
|
||||
/// to detect "we've cycled the entire stock without finding any
|
||||
/// playable card", which guarantees no further benefit from
|
||||
/// drawing.
|
||||
consecutive_draws: u32,
|
||||
}
|
||||
|
||||
impl SolverState {
|
||||
fn initial(seed: u64, draw_mode: DrawMode) -> Self {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(seed);
|
||||
let (tableau_piles, stock_pile) = deal_klondike(deck);
|
||||
let tableau: [Vec<Card>; 7] = tableau_piles.map(|p| p.cards);
|
||||
let foundation: [Vec<Card>; 4] = core::array::from_fn(|_| Vec::new());
|
||||
Self {
|
||||
tableau,
|
||||
foundation,
|
||||
stock: stock_pile.cards,
|
||||
waste: Vec::new(),
|
||||
draw_mode,
|
||||
just_drew: false,
|
||||
consecutive_draws: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when every foundation slot has 13 cards.
|
||||
fn is_won(&self) -> bool {
|
||||
self.foundation.iter().all(|f| f.len() == 13)
|
||||
}
|
||||
|
||||
/// Returns the foundation slot that already claims `suit`, or the
|
||||
/// first empty slot if no slot claims it. Used so foundation moves
|
||||
/// always target a single deterministic slot per (card, board) pair.
|
||||
fn target_foundation_slot(&self, suit: Suit) -> Option<u8> {
|
||||
let mut empty: Option<u8> = None;
|
||||
for (idx, pile) in self.foundation.iter().enumerate() {
|
||||
match pile.first() {
|
||||
Some(bottom) if bottom.suit == suit => return Some(idx as u8),
|
||||
None if empty.is_none() => empty = Some(idx as u8),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
empty
|
||||
}
|
||||
|
||||
/// Build a temporary `Pile` view for use with the rule helpers.
|
||||
/// Cheap clone — the helpers only inspect the top card, so we
|
||||
/// pass a thin wrapper. (The compiler reuses the inner Vec by
|
||||
/// value because we drop it immediately.)
|
||||
fn pile_view(pile_type: PileType, cards: &[Card]) -> Pile {
|
||||
Pile {
|
||||
pile_type,
|
||||
cards: cards.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate every legal candidate move in priority order:
|
||||
/// foundation > inter-tableau > waste-to-tableau > stock-draw.
|
||||
/// The order matters — foundation moves shrink the search frontier
|
||||
/// fastest, and stock-draws are the costliest. See the top-of-file
|
||||
/// algorithm note.
|
||||
fn enumerate_moves(&self) -> Vec<SolverMove> {
|
||||
let mut moves: Vec<SolverMove> = Vec::new();
|
||||
|
||||
// 1) Foundation moves from tableau tops.
|
||||
for (i, col) in self.tableau.iter().enumerate() {
|
||||
if let Some(top) = col.last()
|
||||
&& top.face_up
|
||||
&& let Some(slot) = self.target_foundation_slot(top.suit)
|
||||
{
|
||||
let foundation_pile = Self::pile_view(
|
||||
PileType::Foundation(slot),
|
||||
&self.foundation[slot as usize],
|
||||
);
|
||||
if can_place_on_foundation(top, &foundation_pile) {
|
||||
moves.push(SolverMove::TableauToFoundation { from: i, slot });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Foundation move from the waste top.
|
||||
if let Some(top) = self.waste.last()
|
||||
&& let Some(slot) = self.target_foundation_slot(top.suit)
|
||||
{
|
||||
let foundation_pile = Self::pile_view(
|
||||
PileType::Foundation(slot),
|
||||
&self.foundation[slot as usize],
|
||||
);
|
||||
if can_place_on_foundation(top, &foundation_pile) {
|
||||
moves.push(SolverMove::WasteToFoundation { slot });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Inter-tableau moves. For each source column, find the
|
||||
// longest face-up valid run, then enumerate every prefix
|
||||
// length that lands legally on every other column. Skip
|
||||
// moves that just relocate a King onto an empty column when
|
||||
// the source column would also be left empty (a no-op).
|
||||
for src in 0..7usize {
|
||||
let col = &self.tableau[src];
|
||||
if col.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Find the largest k such that col[col.len()-k..] is all
|
||||
// face-up and a valid descending alternating run.
|
||||
let max_run = longest_face_up_run(col);
|
||||
for count in 1..=max_run {
|
||||
let start = col.len() - count;
|
||||
let bottom = &col[start];
|
||||
for dst in 0..7usize {
|
||||
if dst == src {
|
||||
continue;
|
||||
}
|
||||
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
|
||||
if !can_place_on_tableau(bottom, &dst_pile) {
|
||||
continue;
|
||||
}
|
||||
// Prune the no-op "drag a King from an empty-after-move
|
||||
// column onto another empty column".
|
||||
let leaves_source_empty = start == 0;
|
||||
let dest_empty = self.tableau[dst].is_empty();
|
||||
if leaves_source_empty
|
||||
&& dest_empty
|
||||
&& bottom.rank == crate::card::Rank::King
|
||||
{
|
||||
continue;
|
||||
}
|
||||
moves.push(SolverMove::TableauToTableau { from: src, to: dst, count });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Waste → tableau.
|
||||
if let Some(top) = self.waste.last() {
|
||||
for dst in 0..7usize {
|
||||
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
|
||||
if can_place_on_tableau(top, &dst_pile) {
|
||||
moves.push(SolverMove::WasteToTableau { to: dst });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Draw — but only if there's something to draw or recycle.
|
||||
// Skip draws when we've already cycled the full stock+waste
|
||||
// once without making progress; the deterministic stock
|
||||
// permutation can't produce new value at that point.
|
||||
let can_draw = !self.stock.is_empty() || !self.waste.is_empty();
|
||||
let stock_cycle_len = (self.stock.len() + self.waste.len()) as u32;
|
||||
// `consecutive_draws > stock_cycle_len` is a conservative cap:
|
||||
// a single full cycle requires at most `ceil(stock_cycle_len / draw_count)`
|
||||
// draws (Draw 1 → exactly stock_cycle_len; Draw 3 → fewer), so
|
||||
// anything past that without intervening progress is wasteful.
|
||||
let cycled_without_progress =
|
||||
self.consecutive_draws > stock_cycle_len.saturating_add(1);
|
||||
if can_draw && !cycled_without_progress {
|
||||
moves.push(SolverMove::Draw);
|
||||
}
|
||||
|
||||
moves
|
||||
}
|
||||
|
||||
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
|
||||
/// value so the caller can restore it on backtrack.
|
||||
fn apply_move(&mut self, mv: SolverMove) -> SolverStateUndo {
|
||||
let prev_just_drew = self.just_drew;
|
||||
let prev_consec = self.consecutive_draws;
|
||||
match mv {
|
||||
SolverMove::TableauToTableau { from, to, count } => {
|
||||
let start = self.tableau[from].len() - count;
|
||||
let moved: Vec<Card> = self.tableau[from].split_off(start);
|
||||
self.tableau[to].extend(moved);
|
||||
// Flip the newly exposed source top.
|
||||
if let Some(top) = self.tableau[from].last_mut()
|
||||
&& !top.face_up
|
||||
{
|
||||
top.face_up = true;
|
||||
}
|
||||
self.just_drew = false;
|
||||
self.consecutive_draws = 0;
|
||||
}
|
||||
SolverMove::TableauToFoundation { from, slot } => {
|
||||
if let Some(card) = self.tableau[from].pop() {
|
||||
self.foundation[slot as usize].push(card);
|
||||
if let Some(top) = self.tableau[from].last_mut()
|
||||
&& !top.face_up
|
||||
{
|
||||
top.face_up = true;
|
||||
}
|
||||
}
|
||||
self.just_drew = false;
|
||||
self.consecutive_draws = 0;
|
||||
}
|
||||
SolverMove::WasteToTableau { to } => {
|
||||
if let Some(card) = self.waste.pop() {
|
||||
self.tableau[to].push(card);
|
||||
}
|
||||
self.just_drew = false;
|
||||
self.consecutive_draws = 0;
|
||||
}
|
||||
SolverMove::WasteToFoundation { slot } => {
|
||||
if let Some(card) = self.waste.pop() {
|
||||
self.foundation[slot as usize].push(card);
|
||||
}
|
||||
self.just_drew = false;
|
||||
self.consecutive_draws = 0;
|
||||
}
|
||||
SolverMove::Draw => {
|
||||
if self.stock.is_empty() {
|
||||
// Recycle waste back to stock face-down, reversed.
|
||||
let mut recycled: Vec<Card> = self.waste.drain(..).collect();
|
||||
recycled.reverse();
|
||||
for mut c in recycled {
|
||||
c.face_up = false;
|
||||
self.stock.push(c);
|
||||
}
|
||||
} else {
|
||||
let draw_count = match self.draw_mode {
|
||||
DrawMode::DrawOne => 1,
|
||||
DrawMode::DrawThree => 3,
|
||||
};
|
||||
let avail = self.stock.len().min(draw_count);
|
||||
let drain_start = self.stock.len() - avail;
|
||||
let drawn: Vec<Card> = self.stock.drain(drain_start..).collect();
|
||||
for mut c in drawn {
|
||||
c.face_up = true;
|
||||
self.waste.push(c);
|
||||
}
|
||||
}
|
||||
self.just_drew = true;
|
||||
self.consecutive_draws = self.consecutive_draws.saturating_add(1);
|
||||
}
|
||||
}
|
||||
SolverStateUndo {
|
||||
prev_just_drew,
|
||||
prev_consec,
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterative depth-first search using an explicit stack — recursion
|
||||
/// blew through Rust's default 8 MB stack on long real-deal solves
|
||||
/// because each frame held a `SolverState` clone. The explicit
|
||||
/// stack lives on the heap and grows only with `Vec` capacity, not
|
||||
/// with thread-stack pages.
|
||||
///
|
||||
/// Returns `true` as soon as a winning leaf is found. Sets
|
||||
/// `*budget_exceeded = true` if either budget trips before a
|
||||
/// verdict.
|
||||
fn search(
|
||||
self,
|
||||
config: &SolverConfig,
|
||||
visited: &mut HashSet<u64>,
|
||||
moves_consumed: &mut u64,
|
||||
budget_exceeded: &mut bool,
|
||||
) -> bool {
|
||||
// Each stack frame keeps a state plus the move iterator we
|
||||
// haven't yet expanded. Popping a frame is the backtrack.
|
||||
struct Frame {
|
||||
state: SolverState,
|
||||
pending: std::vec::IntoIter<SolverMove>,
|
||||
}
|
||||
// Quick exits before allocating the stack.
|
||||
if self.is_won() {
|
||||
return true;
|
||||
}
|
||||
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
|
||||
*budget_exceeded = true;
|
||||
return false;
|
||||
}
|
||||
let root_hash = self.canonical_hash();
|
||||
if !visited.insert(root_hash) {
|
||||
return false;
|
||||
}
|
||||
let root_moves = self.enumerate_moves();
|
||||
let mut stack: Vec<Frame> = Vec::new();
|
||||
stack.push(Frame {
|
||||
state: self,
|
||||
pending: root_moves.into_iter(),
|
||||
});
|
||||
|
||||
while let Some(frame) = stack.last_mut() {
|
||||
// Budget gates — checked before consuming the next move so
|
||||
// the budget exhaustion is reflected in the verdict.
|
||||
if *moves_consumed >= config.move_budget
|
||||
|| visited.len() >= config.state_budget
|
||||
{
|
||||
*budget_exceeded = true;
|
||||
return false;
|
||||
}
|
||||
let Some(mv) = frame.pending.next() else {
|
||||
// Exhausted this frame's children — backtrack.
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
*moves_consumed = moves_consumed.saturating_add(1);
|
||||
let mut next = frame.state.clone();
|
||||
next.apply_move(mv);
|
||||
if next.is_won() {
|
||||
return true;
|
||||
}
|
||||
let h = next.canonical_hash();
|
||||
if !visited.insert(h) {
|
||||
continue;
|
||||
}
|
||||
let next_moves = next.enumerate_moves();
|
||||
stack.push(Frame {
|
||||
state: next,
|
||||
pending: next_moves.into_iter(),
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Build a deterministic 64-bit hash of the visible game state.
|
||||
///
|
||||
/// The encoding covers every field that can affect future legal
|
||||
/// moves: tableau column contents (with face_up state), foundation
|
||||
/// tops (it's enough to know the top card per slot — the rest is
|
||||
/// implied by the rank), stock + waste card ids in order, and the
|
||||
/// draw mode. Two states that differ only in `just_drew` or
|
||||
/// `consecutive_draws` hash equally — those fields are search
|
||||
/// metadata, not game state.
|
||||
fn canonical_hash(&self) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
// Tag the encoding with a version byte so future schema
|
||||
// changes invalidate cached hashes cleanly.
|
||||
0u8.hash(&mut h);
|
||||
for col in &self.tableau {
|
||||
(col.len() as u32).hash(&mut h);
|
||||
for c in col {
|
||||
c.id.hash(&mut h);
|
||||
c.face_up.hash(&mut h);
|
||||
}
|
||||
}
|
||||
for f in &self.foundation {
|
||||
match f.last() {
|
||||
Some(top) => {
|
||||
1u8.hash(&mut h);
|
||||
top.id.hash(&mut h);
|
||||
}
|
||||
None => {
|
||||
0u8.hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
(self.stock.len() as u32).hash(&mut h);
|
||||
for c in &self.stock {
|
||||
c.id.hash(&mut h);
|
||||
}
|
||||
(self.waste.len() as u32).hash(&mut h);
|
||||
for c in &self.waste {
|
||||
c.id.hash(&mut h);
|
||||
}
|
||||
match self.draw_mode {
|
||||
DrawMode::DrawOne => 1u8.hash(&mut h),
|
||||
DrawMode::DrawThree => 3u8.hash(&mut h),
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bookkeeping captured by [`SolverState::apply_move`] so the caller
|
||||
/// could in principle restore mutated state. Currently unused —
|
||||
/// `search` clones before applying — but kept so a future iteration
|
||||
/// can switch to in-place mutation without changing the apply path.
|
||||
#[allow(dead_code)]
|
||||
struct SolverStateUndo {
|
||||
prev_just_drew: bool,
|
||||
prev_consec: u32,
|
||||
}
|
||||
|
||||
/// Returns the length of the longest face-up valid descending
|
||||
/// alternating-colour run anchored at the top of `cards`. Returns 0
|
||||
/// when the top is face-down (or the column is empty); returns 1 for
|
||||
/// a single face-up card; otherwise extends as long as the
|
||||
/// `is_valid_tableau_sequence` constraint holds.
|
||||
fn longest_face_up_run(cards: &[Card]) -> usize {
|
||||
if cards.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let n = cards.len();
|
||||
let mut k = 0usize;
|
||||
while k < n {
|
||||
let candidate = &cards[n - k - 1..];
|
||||
if !candidate.iter().all(|c| c.face_up) {
|
||||
break;
|
||||
}
|
||||
if !is_valid_tableau_sequence(candidate) {
|
||||
break;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::card::{Card, Rank, Suit};
|
||||
|
||||
/// Construct a `SolverState` from raw piles for the synthetic
|
||||
/// hand-crafted test scenarios. Skips deck-shuffle and the deal
|
||||
/// step so tests can describe a near-finished or pathological
|
||||
/// position directly.
|
||||
fn synthetic(
|
||||
tableau: [Vec<Card>; 7],
|
||||
foundation: [Vec<Card>; 4],
|
||||
stock: Vec<Card>,
|
||||
waste: Vec<Card>,
|
||||
draw_mode: DrawMode,
|
||||
) -> SolverState {
|
||||
SolverState {
|
||||
tableau,
|
||||
foundation,
|
||||
stock,
|
||||
waste,
|
||||
draw_mode,
|
||||
just_drew: false,
|
||||
consecutive_draws: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_columns() -> [Vec<Card>; 7] {
|
||||
core::array::from_fn(|_| Vec::new())
|
||||
}
|
||||
|
||||
fn empty_foundations() -> [Vec<Card>; 4] {
|
||||
core::array::from_fn(|_| Vec::new())
|
||||
}
|
||||
|
||||
fn ace(suit: Suit, id: u32) -> Card {
|
||||
Card { id, suit, rank: Rank::Ace, face_up: true }
|
||||
}
|
||||
|
||||
fn rank_card(suit: Suit, rank: Rank, id: u32) -> Card {
|
||||
Card { id, suit, rank, face_up: true }
|
||||
}
|
||||
|
||||
fn full_run(suit: Suit, base_id: u32) -> Vec<Card> {
|
||||
let ranks = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
Rank::Jack, Rank::Queen, Rank::King,
|
||||
];
|
||||
ranks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| Card {
|
||||
id: base_id + i as u32,
|
||||
suit,
|
||||
rank: *r,
|
||||
face_up: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_recognises_obviously_winnable_deal() {
|
||||
// Construct a position where the four foundations are already
|
||||
// 12 cards each (Ace through Queen) and the four Kings sit
|
||||
// exposed on individual tableau columns. The solver only has
|
||||
// to play the four Kings to win.
|
||||
let mut foundations: [Vec<Card>; 4] = empty_foundations();
|
||||
for (slot, suit) in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut full = full_run(*suit, (slot as u32) * 13);
|
||||
full.pop(); // remove King
|
||||
foundations[slot] = full;
|
||||
}
|
||||
let mut tableau = empty_columns();
|
||||
tableau[0] = vec![rank_card(Suit::Clubs, Rank::King, 100)];
|
||||
tableau[1] = vec![rank_card(Suit::Diamonds, Rank::King, 101)];
|
||||
tableau[2] = vec![rank_card(Suit::Hearts, Rank::King, 102)];
|
||||
tableau[3] = vec![rank_card(Suit::Spades, Rank::King, 103)];
|
||||
|
||||
let state = synthetic(tableau, foundations, Vec::new(), Vec::new(), DrawMode::DrawOne);
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut moves_consumed: u64 = 0;
|
||||
let mut budget_exceeded = false;
|
||||
let cfg = SolverConfig::default();
|
||||
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||
|
||||
assert!(won, "obviously-winnable position must be recognised as Winnable");
|
||||
assert!(!budget_exceeded);
|
||||
assert!(
|
||||
moves_consumed < 1000,
|
||||
"near-finished deal should solve in well under 1k moves; consumed {moves_consumed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_recognises_obviously_unwinnable_deal() {
|
||||
// Synthesise a state where one tableau column buries the Ace
|
||||
// of Spades under the Two of Spades, both face-up, with no
|
||||
// stock, no waste, no other moves available. The Two cannot
|
||||
// go anywhere (nothing to land on; no foundation accepts a
|
||||
// bare Two), and the Ace is buried, so the deal is dead.
|
||||
let mut tableau = empty_columns();
|
||||
// Column 0: bottom-to-top [A♠, 2♠]. The Ace is the bottom
|
||||
// card; the Two on top of it has no valid destination.
|
||||
tableau[0] = vec![
|
||||
Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true },
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true },
|
||||
];
|
||||
// Other six columns isolated. Put a face-up King with no
|
||||
// matching Queen anywhere — it cannot move because every
|
||||
// other column is empty (Kings move to empty columns, but a
|
||||
// King already sitting alone on a column moving to an empty
|
||||
// column is a no-op, pruned by enumerate_moves).
|
||||
tableau[1] = vec![rank_card(Suit::Clubs, Rank::King, 2)];
|
||||
// Empty columns 2..6 — irrelevant.
|
||||
|
||||
let state = synthetic(
|
||||
tableau,
|
||||
empty_foundations(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
DrawMode::DrawOne,
|
||||
);
|
||||
let cfg = SolverConfig::default();
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut moves_consumed: u64 = 0;
|
||||
let mut budget_exceeded = false;
|
||||
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
|
||||
assert!(!won, "buried Ace under same-suit Two with no recovery must not solve");
|
||||
assert!(!budget_exceeded, "small synthetic state must complete within budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_returns_inconclusive_when_budget_exceeded() {
|
||||
// Tiny budgets force the search to bail before exploring
|
||||
// meaningful branches on a real fresh deal.
|
||||
let cfg = SolverConfig {
|
||||
move_budget: 50,
|
||||
state_budget: 50,
|
||||
};
|
||||
let result = try_solve(0, DrawMode::DrawOne, &cfg);
|
||||
assert_eq!(
|
||||
result,
|
||||
SolverResult::Inconclusive,
|
||||
"very tight budgets must surface as Inconclusive on a real deal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_is_deterministic() {
|
||||
// Same seed + same draw mode + same config must always return
|
||||
// the same verdict. We use a tight budget so the test runs
|
||||
// fast even when seed N happens to be a long-search deal.
|
||||
let cfg = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
let r1 = try_solve(7, DrawMode::DrawOne, &cfg);
|
||||
let r2 = try_solve(7, DrawMode::DrawOne, &cfg);
|
||||
let r3 = try_solve(7, DrawMode::DrawOne, &cfg);
|
||||
assert_eq!(r1, r2, "repeat solves must yield the same result");
|
||||
assert_eq!(r2, r3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_handles_draw_three_mode() {
|
||||
// The solver must accept DrawMode::DrawThree and never panic.
|
||||
// A tight budget keeps the test fast — we only assert that
|
||||
// the call returns a verdict (any of the three variants) and
|
||||
// that the verdict is reproducible.
|
||||
let cfg = SolverConfig {
|
||||
move_budget: 5_000,
|
||||
state_budget: 5_000,
|
||||
};
|
||||
let r1 = try_solve(123, DrawMode::DrawThree, &cfg);
|
||||
let r2 = try_solve(123, DrawMode::DrawThree, &cfg);
|
||||
assert_eq!(r1, r2, "DrawThree solver must be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_solve_winnable_synthetic_via_real_init_path() {
|
||||
// Cross-check: try_solve with the default budget on a real
|
||||
// dealt seed should never panic and should return one of the
|
||||
// three verdict variants. We don't pin a specific verdict —
|
||||
// that would tightly couple the test to RNG behaviour — but
|
||||
// we do assert the function reaches a result.
|
||||
let cfg = SolverConfig::default();
|
||||
let _verdict = try_solve(42, DrawMode::DrawOne, &cfg);
|
||||
// Reaching here means the function returned without panic.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_face_up_run_handles_face_down_at_top() {
|
||||
let cards = vec![
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: false },
|
||||
];
|
||||
assert_eq!(longest_face_up_run(&cards), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_face_up_run_extends_through_valid_run() {
|
||||
let cards = vec![
|
||||
// bottom: face-down filler
|
||||
Card { id: 0, suit: Suit::Spades, rank: Rank::Two, face_up: false },
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
|
||||
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||
Card { id: 3, suit: Suit::Clubs, rank: Rank::Jack, face_up: true },
|
||||
];
|
||||
assert_eq!(longest_face_up_run(&cards), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_face_up_run_breaks_on_invalid_sequence() {
|
||||
// K♠ Q♥ Q♣ — second pair fails the descending check, so the
|
||||
// run is just the top single card (Q♣).
|
||||
let cards = vec![
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
|
||||
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||||
Card { id: 3, suit: Suit::Clubs, rank: Rank::Queen, face_up: true },
|
||||
];
|
||||
assert_eq!(longest_face_up_run(&cards), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_foundation_slot_prefers_claimed_suit() {
|
||||
let mut state = synthetic(
|
||||
empty_columns(),
|
||||
empty_foundations(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
DrawMode::DrawOne,
|
||||
);
|
||||
// Slot 0 is empty; slot 1 already holds the Ace of Hearts.
|
||||
state.foundation[1].push(ace(Suit::Hearts, 0));
|
||||
assert_eq!(state.target_foundation_slot(Suit::Hearts), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_foundation_slot_falls_back_to_empty() {
|
||||
let state = synthetic(
|
||||
empty_columns(),
|
||||
empty_foundations(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
DrawMode::DrawOne,
|
||||
);
|
||||
// No slot claims any suit; every Ace targets slot 0.
|
||||
assert_eq!(state.target_foundation_slot(Suit::Spades), Some(0));
|
||||
}
|
||||
|
||||
/// Scan a wide seed window to find one Winnable + one Unwinnable
|
||||
/// seed under tight budgets. Used during development to source the
|
||||
/// fixture seeds for the engine-level retry test.
|
||||
/// Run with:
|
||||
/// `cargo test -p solitaire_core --release -- --ignored find_unwinnable --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn find_unwinnable() {
|
||||
let cfg = SolverConfig::default();
|
||||
let mut found = 0;
|
||||
let mut counts = [0u32; 3];
|
||||
for seed in 0u64..500 {
|
||||
let r = try_solve(seed, DrawMode::DrawOne, &cfg);
|
||||
let bucket = match r {
|
||||
SolverResult::Winnable => 0,
|
||||
SolverResult::Unwinnable => 1,
|
||||
SolverResult::Inconclusive => 2,
|
||||
};
|
||||
counts[bucket] += 1;
|
||||
if r == SolverResult::Unwinnable {
|
||||
println!("seed {seed} -> Unwinnable");
|
||||
let next = try_solve(seed.wrapping_add(1), DrawMode::DrawOne, &cfg);
|
||||
println!("seed {} -> {:?}", seed.wrapping_add(1), next);
|
||||
found += 1;
|
||||
if found >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"(scan complete) Winnable={} Unwinnable={} Inconclusive={}",
|
||||
counts[0], counts[1], counts[2]
|
||||
);
|
||||
}
|
||||
|
||||
/// Manual bench — run with:
|
||||
/// `cargo test -p solitaire_core --release -- --ignored solver_bench --nocapture`.
|
||||
/// Prints per-seed timing and the verdict distribution so a developer
|
||||
/// can sanity-check the median. Not part of the regular suite because
|
||||
/// (a) it's slow and (b) timing output is noise during normal runs.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn solver_bench() {
|
||||
let cfg = SolverConfig::default();
|
||||
let mut samples_ms: Vec<u128> = Vec::new();
|
||||
let mut counts = [0u32; 3];
|
||||
for seed in 0u64..20 {
|
||||
let t = std::time::Instant::now();
|
||||
let r = try_solve(seed, DrawMode::DrawOne, &cfg);
|
||||
let ms = t.elapsed().as_millis();
|
||||
samples_ms.push(ms);
|
||||
let bucket = match r {
|
||||
SolverResult::Winnable => 0,
|
||||
SolverResult::Unwinnable => 1,
|
||||
SolverResult::Inconclusive => 2,
|
||||
};
|
||||
counts[bucket] += 1;
|
||||
println!("seed={seed:3} {ms:>6} ms {r:?}");
|
||||
}
|
||||
samples_ms.sort_unstable();
|
||||
let median = samples_ms[samples_ms.len() / 2];
|
||||
let total: u128 = samples_ms.iter().sum();
|
||||
println!(
|
||||
"\nmedian: {median} ms mean: {} ms Winnable: {} Unwinnable: {} Inconclusive: {}",
|
||||
total / samples_ms.len() as u128,
|
||||
counts[0], counts[1], counts[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,12 @@ dirs = { workspace = true }
|
||||
keyring-core = { workspace = true }
|
||||
reqwest = { 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 {
|
||||
let msg = err.to_string();
|
||||
match err {
|
||||
keyring_core::Error::NoStorageAccess(_) => TokenError::KeychainUnavailable(msg),
|
||||
keyring_core::Error::NoDefaultStore => TokenError::KeychainUnavailable(msg),
|
||||
keyring_core::Error::NoStorageAccess(_) | keyring_core::Error::NoDefaultStore => {
|
||||
TokenError::KeychainUnavailable(msg)
|
||||
}
|
||||
keyring_core::Error::NoEntry => TokenError::NotFound(username.to_string()),
|
||||
_ => TokenError::Keyring(msg),
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ pub trait SyncProvider: Send + Sync {
|
||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||
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
|
||||
@@ -92,6 +101,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||
(**self).delete_account().await
|
||||
}
|
||||
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<(), SyncError> {
|
||||
(**self).push_replay(replay).await
|
||||
}
|
||||
}
|
||||
|
||||
pub mod stats;
|
||||
@@ -99,8 +111,11 @@ pub use stats::{StatsExt, StatsSnapshot};
|
||||
|
||||
pub mod storage;
|
||||
pub use storage::{
|
||||
cleanup_orphaned_tmp_files, delete_game_state_at, game_state_file_path, load_game_state_from,
|
||||
load_stats, load_stats_from, save_game_state_to, save_stats, save_stats_to, stats_file_path,
|
||||
cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at,
|
||||
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;
|
||||
@@ -126,7 +141,9 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||
Theme,
|
||||
Theme, WindowGeometry, 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;
|
||||
@@ -136,3 +153,12 @@ pub use auth_tokens::{
|
||||
|
||||
pub mod sync_client;
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
@@ -98,6 +117,70 @@ pub struct Settings {
|
||||
/// solely on colour.
|
||||
#[serde(default)]
|
||||
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,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -112,6 +195,60 @@ fn default_music_volume() -> f32 {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -125,17 +262,30 @@ impl Default for Settings {
|
||||
selected_background: 0,
|
||||
first_run_complete: 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
|
||||
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
|
||||
/// `time_bonus_multiplier` into their respective ranges after
|
||||
/// deserialization or hand-editing of `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
sfx_volume: self.sfx_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),
|
||||
..self
|
||||
}
|
||||
}
|
||||
@@ -151,6 +301,29 @@ impl Settings {
|
||||
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||
@@ -201,6 +374,7 @@ mod tests {
|
||||
assert_eq!(s.animation_speed, AnimSpeed::Normal);
|
||||
assert_eq!(s.theme, Theme::Green);
|
||||
assert_eq!(s.sync_backend, SyncBackend::Local);
|
||||
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -276,6 +450,12 @@ mod tests {
|
||||
selected_background: 0,
|
||||
first_run_complete: true,
|
||||
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,
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
@@ -406,4 +586,326 @@ mod tests {
|
||||
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,35 @@
|
||||
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
|
||||
|
||||
use chrono::Utc;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
|
||||
pub use solitaire_sync::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 {
|
||||
/// 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);
|
||||
|
||||
/// 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 {
|
||||
@@ -51,6 +70,43 @@ impl StatsExt for StatsSnapshot {
|
||||
|
||||
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)]
|
||||
@@ -177,4 +233,123 @@ mod tests {
|
||||
s.update_on_win(200, 60, &DrawMode::DrawOne);
|
||||
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::io;
|
||||
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;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const STATS_FILE_NAME: &str = "stats.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
|
||||
/// `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
|
||||
/// 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> {
|
||||
let data = fs::read(path).ok()?;
|
||||
let gs: GameState = serde_json::from_slice(&data).ok()?;
|
||||
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
|
||||
return None;
|
||||
}
|
||||
if gs.is_won {
|
||||
None
|
||||
} else {
|
||||
@@ -128,6 +142,131 @@ pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
||||
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`.
|
||||
///
|
||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||
@@ -138,8 +277,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.ends_with(".json.tmp"))
|
||||
.unwrap_or(false)
|
||||
.is_some_and(|n| n.ends_with(".json.tmp"))
|
||||
{
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
@@ -332,4 +470,235 @@ mod tests {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
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::{
|
||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||
replay::Replay,
|
||||
settings::SyncBackend,
|
||||
SyncError, SyncProvider,
|
||||
};
|
||||
@@ -356,6 +357,54 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
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 }
|
||||
uuid = { 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]
|
||||
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 |