Compare commits

..

122 Commits

Author SHA1 Message Date
funman300 aa2a021712 docs: cut v0.19.0 — punch-list close + Wayland + animation polish
Promotes [Unreleased] to [0.19.0]. The release closes v0.18.0's
punch list (async H-key hint, persistent replay share URLs),
expands desktop platform fit (Wayland session support +
monitor-aware default window size), polishes the win-celebration
and double-click animation paths, and clears two test-flake
contributors. The Rusty Pixel pixel-art card theme arc was
prototyped and reverted in the same window — the engine plumbing
(pixel_art ThemeMeta field, PNG manifest face support, second
embedded:// theme channel) was fully reverted and is not part of
this release.

SESSION_HANDOFF.md refreshed to reflect the v0.19.0 ship:
v0.18.0 punch-list items B and D marked shipped; new Open punch
list documents the Rusty Pixel arc as historical, calls out the
desktop-packaging follow-through (app icon next), the
pull_failure_sets_error_status flake (next-round candidate),
and a settings-UI item for the smart-default-size opt-out.
Resume prompt refreshed with the post-v0.19.0 A-D decision menu.

Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1170 passing / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:06:21 -07:00
funman300 6037596cc0 fix(engine): double-click move animation no longer plays twice
A successful double-click was rendering the slide-to-destination
animation twice — once from the first press's MoveRequestEvent
landing, and again from the release's StateChangedEvent racing the
in-flight CardAnim and replacing it from the mid-animation
position.

The frame trace:

  Frame N (second press):
    handle_double_click → MoveRequestEvent (queued)
    start_drag           → DragState set, drag.committed = false
                            (start_drag never mutates Transform; the
                             card is still visually in place)
    handle_move          → applies the move, fires StateChangedEvent
    sync_cards_on_change → cur ≠ target, inserts CardAnim slide
                            (animation #1 starts)

  Frames N+1, N+2, …:
    follow_drag idles (drag uncommitted, cursor not moving)
    CardAnim animates the card from old to new pile

  Frame N+K (release):
    end_drag             → drag.committed = false branch:
                            drag.clear() + StateChangedEvent  ← CULPRIT
    sync_cards_on_change → sees the card mid-CardAnim
                            (cur ≠ target), replaces CardAnim
                            with a fresh one starting at the
                            current mid-position (animation #2
                            visibly restarts the slide)

The fix is one line: drop the StateChangedEvent write in the
uncommitted-drag branch of end_drag. The defensive resync was
never needed there — start_drag only mutates the DragState
resource on press, never card transforms, so an uncommitted drag
has no visual side effect to undo. The committed-drag branch (line
762) keeps its StateChangedEvent write since snap-back from a
real drag does need a resync.

Existing tests pass unchanged. The bug only manifested in the
specific timing of double-click → quick-release before
animation-complete; an integration test would require driving
mouse press/release across several frames with a dispatched
GameMutation pass between, which is heavier than the fix
warrants.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:00:05 -07:00
funman300 d7ffb16df5 fix(engine): single-card double-click with no destination now plays the reject animation
handle_double_click had a coverage gap. The flow was:

  - Priority 1: try moving the single top card to its best
    destination (foundation, then tableau).
  - Priority 2: if Priority 1 failed AND the player clicked the
    base of a multi-card stack, try moving the whole stack.

`MoveRejectedEvent` was only fired inside the Priority 2 else-branch
— so a double-click on a single card with no legal destination
fell through both priorities silently: no card_invalid.wav, no
shake animation on the source pile, the player got zero feedback
that the click was acknowledged.

The fix collapses both priorities' failure paths into one
unconditional `MoveRejectedEvent` write at the end of the
double-click branch. Single-card miss now plays the same feedback
as multi-card-stack miss. The early `return` on each successful
move keeps the rejection branch from firing on the success path.

Pre-fix, a player double-clicking the 7♠ buried under a 6♥ on
column 5 (no foundation slot for 7s; no tableau column accepting
black 7) saw nothing happen. Post-fix, the source pile shakes
and the invalid-move sound plays, exactly like a drag-and-drop
rejection.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:54:28 -07:00
funman300 b57db017d3 feat(app): Wayland support + monitor-relative default window size
Two related platform-fit fixes for desktop launch:

1. Wayland session compatibility. The workspace Cargo.toml's
   Bevy feature list previously enabled only `x11`, leaving
   winit-on-Wayland to fall through to XWayland — the game
   rendered inside an X11 frame stitched into the Wayland
   compositor instead of as a native Wayland client. Adding
   the `wayland` feature lets winit prefer Wayland when
   WAYLAND_DISPLAY is set on the session, falling back to X11
   when it isn't. Costs a few hundred KB of binary for the
   libwayland-client bindings; comment in Cargo.toml explains
   the trade.

2. Smart default window sizing. The fallback window size for
   first launches (no saved geometry) was a fixed 1280x800. On
   a 4K monitor that's a comparatively tiny window in one
   corner; the game's cards then occupy a small physical area
   even though the screen has plenty of room. New
   `apply_smart_default_window_size` Update system queries
   `Monitor` (with the `PrimaryMonitor` marker) and resizes the
   primary window to ~70% of the monitor's *logical* size on
   the first frame. Logical size already factors in the OS's
   HiDPI scale factor, so:

   - 1920x1080 / 1.0 scale → 1344x756 target
   - 2560x1440 / 1.0 scale → 1792x1008 target
   - 3840x2160 / 1.0 scale → 2688x1512 target
   - 2880x1800 / 2.0 scale (Retina) → 1008x630 target
                  (same physical size as 1080p)

   Clamped to the existing 800x600 minimum so old systems
   don't get sub-minimum windows. Skipped entirely when saved
   geometry was applied — the player's chosen size always
   wins. Uses `Local<bool>` for one-shot semantics; the early-
   exit per tick costs nothing once `*applied` is true.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:49:52 -07:00
funman300 0b3140ad6d Revert "feat(engine): theme thumbnails accept PNG faces alongside SVG"
This reverts commit de4751115f.
2026-05-06 19:38:13 -07:00
funman300 e41def8c89 Revert "feat(engine): per-theme nearest-sampling opt-in for pixel-art themes"
This reverts commit 17e3112502.
2026-05-06 19:38:13 -07:00
funman300 aad8bb9c83 Revert "feat(engine): bundle Rusty Pixel as a built-in theme"
This reverts commit 21ec03b157.
2026-05-06 19:38:13 -07:00
funman300 55c235b55f fix(engine): drop duplicate "You Win" toast — WinSummary modal owns the celebration
The post-win UI was firing TWO celebration surfaces on every
GameWonEvent:

  - animation_plugin::handle_win_cascade spawned a 4-second toast:
    "You Win!  Score: {score}  Time: {m}:{ss}"
  - win_summary_plugin spawned the proper "You Won!" modal with
    score breakdown, time bonus, achievements unlocked, XP earned,
    and a Play Again button

Both rendered on top of each other — in screenshots the toast
banner was partially clipped behind the modal card, peeking out
on either side. The toast predates the WinSummary modal; the
modal carries strictly more information so the toast is dead
weight.

handle_win_cascade keeps the cards-fly-off animation
(MotionCurve::Expressive cascade with per-card rotation drift) —
that's the visual celebration, distinct from the textual
celebration the modal owns. The system still gates on the same
GameWonEvent message reader; it just doesn't write a toast
afterward. WIN_TOAST_SECS const removed (no remaining callers).

Workspace: 1172 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:35:04 -07:00
funman300 21ec03b157 feat(engine): bundle Rusty Pixel as a built-in theme
The pixel-art card theme generated via Claude Design (53 PNGs at
256x384, ~340 KB total) now ships embedded in the binary alongside
the existing default SVG theme. Players see the new theme in the
picker out of the box without needing to drop files into
~/.local/share/solitaire_quest/themes/.

solitaire_engine/assets/themes/rusty-pixel/:
  - 53 PNGs (52 face cards + 1 back) at 256x384
  - theme.ron declaring meta.id = "rusty-pixel",
    card_aspect = (2, 3), pixel_art = true

assets/sources.rs:
  - New constants RUSTY_PIXEL_THEME_MANIFEST_URL,
    RUSTY_PIXEL_THEME_MANIFEST_PATH,
    RUSTY_PIXEL_THEME_MANIFEST_BYTES.
  - New embed_rusty_pixel_png! macro mirroring embed_default_svg!.
  - New RUSTY_PIXEL_THEME_PNGS table — 53 entries, one per file.
  - New rusty_pixel_theme_png_bytes(filename) lookup helper
    mirroring default_theme_svg_bytes for the thumbnail cache.
  - New populate_embedded_rusty_pixel_theme(app) registers the
    manifest + every PNG into Bevy's EmbeddedAssetRegistry.
  - AssetSourcesPlugin::build now calls both populate functions
    so the picker has both themes loadable from the binary alone.

theme/registry.rs:
  - New rusty_pixel_entry() returns the bundled metadata.
  - build_registry now inserts default + rusty-pixel ahead of the
    user-dir scan, and filters user themes whose id collides with
    a bundled built-in. Bundled wins on collision because it's
    guaranteed complete; the user's overriding copy may be partial
    or stale.
  - Updated existing tests for the new len()=2-instead-of-1 baseline.
  - New test user_theme_id_collision_with_bundled_is_dropped pins
    the dedup contract.

theme/plugin.rs:
  - load_initial_theme + react_to_settings_theme_change now both
    consult a new manifest_url_for(theme_id) helper that routes
    bundled built-ins through embedded:// and unknown ids through
    themes://. Drops the previous hard-coded "default →
    DEFAULT_THEME_MANIFEST_URL else themes://" branch.
  - read_theme_preview_bytes also checks the rusty-pixel embed
    table before falling through to the user-dir filesystem read,
    so the picker chip's thumbnail works on a fresh install where
    the user-dir doesn't exist.

Workspace: 1172 passing tests / 0 failing, was 1171 (+1 net from
the new collision test). cargo clippy --workspace --all-targets
-- -D warnings clean. Binary grows by ~340 KB (the 53 bundled
PNGs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:28:53 -07:00
funman300 17e3112502 feat(engine): per-theme nearest-sampling opt-in for pixel-art themes
Bevy's default sprite sampler is bilinear (Linear), which mushes
pixel-art card faces at non-integer scales. The rusty-pixel theme
ships 256x384 source PNGs that get displayed at ~150-200px wide on
typical desktop windows — an aggressive downscale where bilinear
visibly blurs the pixel grid.

Globally flipping ImagePlugin to default_nearest() would also affect
the SVG-rasterised default theme, where bilinear's smoothing is
actually desired (the SVG rasteriser produces a high-res 512x768
pixmap that the GPU has to downscale at draw time).

The fix is a per-theme opt-in:

  - ThemeMeta gains pixel_art: bool with #[serde(default)] for
    backwards compat. Older manifests load with `false`, preserving
    SVG-default behaviour.
  - sync_card_image_set_with_active_theme inspects theme.meta.pixel_art
    after a theme finishes loading. When true, walks every face +
    back Handle<Image> in the active CardTheme and rewrites its
    sampler to ImageSampler::Descriptor(ImageSamplerDescriptor::nearest()).
    The Modified asset event triggers a GPU re-upload with the new
    sampler descriptor.
  - The 12 ThemeMeta struct literals across the engine
    (settings_plugin, card_plugin, theme/{plugin,mod,manifest,
    importer,registry}) all gain `pixel_art: false` to match the
    new field.

The deployed rusty-pixel theme.ron at
~/.local/share/solitaire_quest/themes/rusty-pixel/ now sets
pixel_art: true, so the player's switch-to-pixel-art chip flips to
nearest sampling on the spot.

Workspace: 1171 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:21:53 -07:00
funman300 de4751115f feat(engine): theme thumbnails accept PNG faces alongside SVG
The theme picker chip's thumbnail loader hardcoded `.svg`
filenames (`spades_ace.svg`, `back.svg`) — a holdover from when
every shipped theme was vector-art. Raster-art user themes (e.g.
the v0.19 pixel-art theme generated via Claude Design and dropped
into ~/.local/share/solitaire_quest/themes/rusty-pixel/) had real
PNGs in their directory but the picker rendered placeholders
because it never tried the PNG sibling.

The fix is scoped to the thumbnail-cache pipeline. In-game card
rendering already worked via Bevy's standard PNG asset loader on
manifest-declared face/back paths — only the picker's small
preview chip was affected.

Changes in solitaire_engine/src/theme/plugin.rs:

  - PREVIEW_FACE_FILENAME / PREVIEW_BACK_FILENAME (with embedded
    `.svg` suffix) replaced by PREVIEW_FACE_BASENAME /
    PREVIEW_BACK_BASENAME ("spades_ace" / "back"). The function
    appends the extension itself.
  - read_theme_preview_svg_bytes -> read_theme_preview_bytes
    returns ThemePreviewBytes::{Svg, Png}. For "default" the
    embedded table stays SVG-only. For user themes the function
    tries `<basename>.svg` first (matching the bundled
    convention) and falls back to `<basename>.png` second.
  - rasterize_preview_to_handle gains a Png branch that calls a
    new decode_png_for_thumbnail helper (Bevy's
    Image::from_buffer with ImageType::Format(ImageFormat::Png)).
    PNGs decode at native dimensions; the picker chip's UI
    layout scales them at draw time. SVGs continue to rasterise
    at the fixed 100x140 thumbnail size as before.
  - generate_thumbnail_pair_for is unchanged in shape; just
    threads the new enum through.

Tests:

  - read_default_theme_preview_returns_some_for_canonical_files
    updated to match the new function signature and assert on
    the Svg variant explicitly.
  - New png_only_user_theme_generates_real_thumbnails creates a
    temp theme dir, writes a 2x3 PNG (encoded at runtime via the
    `image` dev-dep so the bytes are guaranteed valid), and
    asserts both ace + back yield non-default Handle<Image>.
    Cleans up the temp dir afterward.

solitaire_engine/Cargo.toml: image = "0.25" added as a
dev-dependency for the test's runtime PNG encoding. Already a
transitive Bevy dep so the build graph is unchanged.

Workspace: 1171 passing tests / 0 failing, was 1170 (+1 new).
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:13:52 -07:00
funman300 9ff48ace5b docs: refresh handoff + populate CHANGELOG [Unreleased] for v0.19.0
Three commits sit on top of v0.18.0 — async H-key hint
(3e11e9e), persistent replay share URLs (42d90b1), and the
auto-save flake fix (91b7605). [Unreleased] now describes them
as Changed / Fixed bullets ready to promote to a [0.19.0]
section whenever the next cut feels right. SESSION_HANDOFF.md
marks v0.18.0 punch-list items B and D as shipped, preserves C
(desktop packaging) as still gated on artwork + signing certs,
and refreshes the resume prompt's A–D menu around the
v0.19.0-cut decision. The previous handoff's
`-c user.name=...` workflow note is replaced with a pointer to
the system git config (which is now correct on this machine via
the v0.18.0 push session's `gh auth setup-git`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:17:07 -07:00
funman300 91b7605b9f fix(engine): clear PendingRestoredGame in test_app + harden auto-save flake
auto_save_writes_after_30_seconds intermittently failed under
heavy parallel cargo-test load. Two contributing factors, both
fixable in test fixtures alone:

  1. GamePlugin::build() reads dirs::data_dir()/.../game_state.json
     before per-test resource overrides apply. If a real
     game_state.json exists on the dev machine, it's loaded into
     PendingRestoredGame, and auto_save_game_state's pending guard
     (`pending.0.is_some()`) silently skips the save. test_app now
     resets PendingRestoredGame(None) after plugin build so the
     production save state can't leak into per-test world state.

  2. Time::delta_secs() on the first MinimalPlugins frame can be
     0.0 (nominal) or, under cargo-test parallelism, large enough
     to consume the 0.1 s pre-seeded margin past the threshold.
     The test now re-arms AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS +
     1.0) every iteration in a 16-frame bounded loop, breaking
     the moment the file appears. Robust against first-frame Time
     variance with no behaviour-contract change.

No production-code change. Verified: 3 back-to-back single-test
runs all pass. Full workspace test suite: 1170 passing / 0 failing.
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:16:32 -07:00
funman300 42d90b199c feat(data,engine): persist replay share URL alongside the replay
The v0.18.0 share-link affordance lived in an in-memory
LastSharedReplayUrl resource that was wiped on quit; the player had
to re-open Stats and re-share within the same session of the win.
The Stats overlay's Prev/Next selector also surfaced older replays
that had no share link at all even when those wins had been
uploaded successfully.

This bundles the URL with the replay it belongs to:

- Replay (solitaire_data) gains share_url: Option<String> with
  #[serde(default)]. No REPLAY_SCHEMA_VERSION bump — older
  replays.json files load unchanged with share_url == None on
  every entry. Replay::new() defaults the field to None.
- poll_replay_upload_result (sync_plugin) writes the resolved URL
  into ReplayHistoryResource::0.replays[0].share_url and persists
  the updated history via save_replay_history_to. The
  cancel-on-replace contract in push_replay_on_win guarantees
  replays[0] is the win whose URL the task is carrying — at most
  one upload is ever in flight, and it's always the most recent
  win.
- handle_copy_share_link_button (stats_plugin) reads from
  history.0.replays[selected.0].share_url instead of
  LastSharedReplayUrl, so the Prev/Next selector's currently-
  displayed replay drives the clipboard contents. Each historical
  win keeps its own URL.
- LastSharedReplayUrl resource removed entirely — its only role
  was bridging the upload-poll system to the Copy button, and
  that channel is now the share_url field on the replay record.

Tests:

- solitaire_data: replay_loads_when_share_url_field_is_absent
  pins backwards-compat — a pre-v0.19.0 Replay JSON without the
  field deserialises with share_url == None.
- solitaire_engine sync_plugin: upload_result_writes_share_url_into_replay_and_persists
  drives a pre-resolved AsyncComputeTaskPool task into
  PendingReplayUpload, pumps update() until the poll system
  resolves it, and asserts both the in-memory replays[0]
  carries the URL and a fresh load_replay_history_from(path)
  picks it up.

Workspace: 1170 passing tests / 0 failing, was 1168 (+2 net).
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:10:16 -07:00
funman300 3e11e9e79a feat(engine): H-key hint runs on AsyncComputeTaskPool
Closes the last solver-on-main-thread hot path. The synchronous
v0.17.0 hint flow called solitaire_core::solver::try_solve_from_state
inline on every H press; median latency was ~2 ms but pathological
positions hit the SolverConfig::default() cap at ~120 ms — a visible
input stall on the same frame the player presses H.

Mirrors the d489e7a PendingNewGameSeed pattern. New module
pending_hint.rs holds:

  - PendingHintTask resource carrying an Option<HintTask> with
    handle: Task<HintTaskOutput> plus move_count_at_spawn for
    staleness detection.
  - HintTaskOutput enum: SolverMove { from, to } when the verdict
    is Winnable + a first_move; NeedsHeuristic when the solver
    returns Unwinnable or Inconclusive.
  - poll_pending_hint_task system: polls the task each frame and
    surfaces the result via the now-public emit_hint_visuals (or
    runs find_heuristic_hint on the live state for the
    NeedsHeuristic branch). Discards the result when
    GameState.move_count has advanced past move_count_at_spawn.
  - drop_pending_hint_on_state_change system: any
    StateChangedEvent drops the in-flight task. Cooperatively
    cancels via Bevy's Task Drop at the next await point.
  - PendingHintTask::spawn implements cancel-on-replace — a fresh
    H press while a previous task is in flight overwrites the
    handle, dropping the prior task.

input_plugin changes:

  - handle_keyboard_hint becomes a thin spawn point. Snapshots
    the live state, asks the solver via PendingHintTask::spawn,
    returns. No card-entity query, no event writers for the
    hint visual / toast — the polling system owns those.
  - emit_hint_visuals promoted to pub so pending_hint can call it.
  - find_heuristic_hint extracted as a pub helper for the
    NeedsHeuristic poll path.
  - InputPlugin registers PendingHintTask + the two new systems.
    drop-on-state-change is chained .before() poll so a move
    applied this frame cancels any in-flight task before its
    result can be surfaced.

Tests:

  - input_plugin: pressing_h_spawns_pending_hint_task (1) — pins
    the H-key wiring at one-frame granularity.
  - pending_hint: winnable_solver_emits_hint_after_async_completes,
    state_change_drops_in_flight_task,
    second_spawn_drops_first_in_flight_task (3) — drives the
    AsyncComputeTaskPool with a wall-clock-bounded loop mirroring
    the winnable_seed_search_* template.
  - Removed two now-stale synchronous tests
    (hint_uses_solver_when_winnable,
    hint_falls_back_to_heuristic_when_solver_inconclusive) — the
    behaviours they pinned now live in pending_hint::tests at the
    correct layer.

Workspace: 1168 passing tests / 0 failing, was 1166 (net +2:
removed 2 stale, added 4 new). cargo clippy --workspace
--all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:01:51 -07:00
funman300 bfcd05fbb5 docs: cut v0.18.0 — launch-experience round + async winnable seeds
CHANGELOG.md gains a [0.18.0] section synthesising the 24 commits
since v0.17.0: the Restore prompt + auto-show Home picker launch
flow, MSSC-style picker (header chips, draw-mode chips, picture
tiles with FiraMono-covered glyphs, Today's Event callout), the
last solver hot path moving onto AsyncComputeTaskPool with
cancel-on-replace, "Won before" HUD chip, "Copy share link" Stats
button via arboard, the N-key flow finally routing through the
real Confirm/Cancel modal, Esc-on-modal layering fixes, and the
unified-3.0 Claude rule set adoption.

SESSION_HANDOFF.md (root) refreshed to reflect HEAD at
v0.17.0-24-gc497c31, the carryover punch list trimmed (items B
and C shipped, A partially shipped, D unchanged), and a new
Process notes section describing the test-discipline prune and
the smaller-port template the async hint work should follow.

Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1166 passing / 0 failing (one flake on
auto_save_writes_after_30_seconds reproduced clean on re-run;
passes in isolation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 17:20:10 -07:00
funman300 c497c3193c fix(engine): freeze game timers while the Home picker is up
The HUD's elapsed-time counter ticked from the moment the default
Classic deal landed at startup, even though the auto-show Home
picker was still up — so the player saw "0:11" before they had
chosen a mode. Time Attack had the same issue when M was pressed
mid-session: the 10-minute countdown burned while the player browsed
modes.

`tick_elapsed_time` and `advance_time_attack` now also gate on the
absence of `HomeScreen`, mirroring their existing `PausedResource`
check. The Home modal already covers input via its scrim, so this
purely freezes the timer without coupling to the pause-overlay
ownership of `PausedResource`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:29:42 +00:00
funman300 9aa0dd23b1 fix(engine): Esc dismisses the topmost modal when Profile stacks on Home
Clicking the new Home header chip opens Profile on top of Home.
Pressing Esc then closed Home (because handle_home_cancel_button
fired on Esc with no awareness of layered modals) and left Profile
orphaned over the game — the player had to press P afterwards just
to dismiss what they meant to dismiss in the first place.

Two changes restore the standard "Esc closes the topmost modal"
contract:

- profile_plugin: split P/button (toggle) from Esc (close-only).
  Esc only fires when Profile is currently open.
- home_plugin: handle_home_cancel_button now skips its Esc branch
  when any other ModalScrim exists, deferring to whichever modal
  is on top. Click on the explicit Cancel button is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:15:18 +00:00
funman300 d065d49fe7 fix(engine): TimeAttack tile glyph swaps to → (FiraMono ships sideways
triangles inconsistently)

Quat: ▶ (U+25B6) rendered as tofu even though ▲ (U+25B2) from the
same Geometric Shapes block works. FiraMono evidently ships the
up/down triangles but not the left/right siblings.

Swapped to U+2192 (RIGHTWARDS ARROW) from the Arrows block, which
is part of every dev-oriented monospace font's core coverage. Reads
as "go / fast-forward" for the timed mode and is visually distinct
from the other 4 tile glyphs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:06:40 +00:00
funman300 c30b04ec72 fix(engine): Home tile glyphs picked from FiraMono's actual coverage
The bundled face is FiraMono-Medium (assets/fonts/main.ttf), and its
glyph table covers card suits (U+2660-2666) plus basic Geometric
Shapes (U+25xx) but not Dingbats / Misc Symbols. The previous round
of "BMP fallbacks" still picked from blocks FiraMono doesn't cover,
so 4 of 5 tiles continued to render as tofu.

Re-picked from ranges FiraMono actually has:
- Daily: U+25C6 (BLACK DIAMOND)
- Zen:   U+25CB (WHITE CIRCLE) — Zen enso
- Challenge: U+25B2 (BLACK UP-POINTING TRIANGLE) — climbing
- TimeAttack: U+25B6 (BLACK RIGHT-POINTING TRIANGLE) — play / FF
- Classic keeps U+2663 (BLACK CLUB SUIT)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:00:26 +00:00
funman300 40d6e0ab17 fix(engine): Home tile glyphs render + modal fits any viewport
Two regressions Quat caught in screenshot review of the picture-tile
rework:

1. Tofu boxes for 4 of 5 tiles. The earlier emoji picks (calendar,
   cherry-blossom, lightning, stopwatch) live in Unicode planes that
   most Linux desktop fonts don't cover, so they rendered as
   missing-glyph rectangles. Swapped to BMP / Dingbats codepoints
   that the system-default font fallback always has:
   - Daily: \u{2605} (BLACK STAR)
   - Zen:   \u{2740} (WHITE FLORETTE)
   - Challenge: \u{2726} (BLACK FOUR-POINTED STAR)
   - TimeAttack: \u{231A} (WATCH, Misc Symbols / Unicode 1.1)
   Classic keeps its club (\u{2663}) — already rendered correctly.

2. Cancel button pushed off the bottom of the viewport. The 3-row
   tile grid alone is ~540 px; on the 800x600 minimum window the
   modal exceeded the screen. Wrapped chips + draw row + grid in a
   `HomeScrollable` Node with `max_height: 70vh` and `Overflow::scroll_y()`,
   adding a `scroll_home_panel` system to drive `ScrollPosition` from
   `MouseWheel`. Mirrors the existing Settings / Leaderboard /
   Achievements scrollable pattern. Cancel sits outside the scroll
   so it's always reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:52:44 +00:00
funman300 9fe650fa20 feat(engine): Home picker — 2-column picture tiles with Unicode glyphs
Phase B step 2 of the MSSC-inspired Home rework. Mode cards become a
wrapping 2-up grid with a centred Unicode-glyph centrepiece per tile,
standing in for real per-mode artwork until that lands.

- HomeMode::glyph() returns the placeholder codepoint for each mode:
  ♣ Classic, calendar Daily, cherry-blossom Zen, lightning Challenge,
  stopwatch TimeAttack. Cherry-blossom is used over lotus-position
  because the latter renders inconsistently across desktop fonts.
- The mode-card loop is wrapped in a FlexWrap::Wrap row container.
  Tiles set `width: 48%` + `min_height: 180px`; the 5-mode grid
  wraps to a third row of one tile, mirroring the half-cell asymmetry
  in MSSC's screenshot.
- The glyph paints in ACCENT_PRIMARY when the mode is unlocked and
  TEXT_DISABLED when locked, so the gate reads at a glance.
- When real art lands, swap the Text node for an Image node — the
  rest of the tile layout, focus order, click handling, and chip
  rendering are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:45:30 +00:00
funman300 b73d246b4c feat(engine): Today's Event callout on the Home Daily card
Phase B step 1 of the MSSC-inspired Home rework — surfaces today's
daily-challenge metadata on the Daily card so the picker reads as
"there's something fresh waiting" rather than a generic mode label.

- Date line "Today, May 6" pulled from DailyChallengeResource. Reads
  in STATE_INFO blue while the run is still open.
- Server-fetched goal (when SyncPlugin is wired) appears underneath
  as "Goal: Win in under 5 minutes", matching the toast that already
  fires when the player presses C.
- Once the player has recorded today's completion, the date flips
  to "Today, May 6 \u{2022} Done" in ACCENT_PRIMARY so the picker
  reads as a reward state rather than a TODO.

Headless tests omit DailyChallengePlugin, so HomeContext.daily_today
defaults to None and the card falls back to its baseline layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:28:59 +00:00
funman300 ae40a1db7a feat(engine): MSSC-style Home picker — header chips, score chips, draw mode
Phase A of the Microsoft-Solitaire-Collection-inspired launch picker
rework. Three additive changes inside the Home modal, no core / asset
work:

- Player-stats header strip showing Level / XP / Lifetime Score using
  a compact formatter (1.2M / 12.3K / 1,234). The whole strip is a
  Button — click fires ToggleProfileRequestEvent so Profile opens on
  top of Home; closing it returns to the picker.
- Draw-mode chip row above the mode cards lets the player flip
  Draw 1 / Draw 3 from the picker itself rather than diving into
  Settings. Active chip uses ACCENT_PRIMARY background; the click
  persists settings.json and respawns the modal so the active state
  repaints cleanly.
- Per-mode score/streak chip on each card — "Best 12,345" for
  Classic / Zen / Challenge, "Streak N" for Daily. Hidden on a 0
  best so a fresh profile doesn't read "Best 0" everywhere.

`HomeContext` bundle pulls live data from ProgressResource /
StatsResource / SettingsResource with safe defaults so headless
tests under MinimalPlugins still build cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:16:01 +00:00
funman300 b7c3a4996f fix(engine): Restore-prompt resolution suppresses Home auto-show
Resolving the Welcome-back / Restore prompt (either Continue or New
game) cleared `PendingRestoredGame` and despawned the modal, but the
launch-time Home auto-show then fired the next frame and stacked
itself over the player's chosen path — clicking "New game" would deal
a fresh game AND immediately pop the mode picker on top.

`LaunchHomeShown` becomes pub so `handle_restore_prompt` can flip it
to `true` after either resolution; `M` still re-opens the picker on
demand. Headless tests already pre-set the flag to true via
`HomePlugin::headless()`, so they're unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:44:31 +00:00
funman300 d48b9489db feat(engine): Esc dismisses Home / accepts default on Restore prompt
Home and Restore-prompt previously ignored Esc, which after the last
fix meant Esc just did nothing on those screens. Now both honor the
"Esc closes the modal" convention every other modal already follows.

- Home: Esc behaves like the Cancel button — despawns the modal so
  the player keeps the underlying default deal.
- Restore: Esc maps to Continue rather than New Game; a reflexive
  dismiss press preserves the saved game, matching how the primary
  action already advertises the Enter accelerator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:36:09 +00:00
funman300 08b006ff30 fix(engine): Esc on a modal no longer also opens Pause underneath
A single Esc press while the Confirm New Game / Restore / Home /
Onboarding / Settings modals were open would both close the modal
(via its own input handler) and spawn the Pause overlay on top in
the same frame, dumping the player on a screen they didn't ask for.

toggle_pause now skips when any non-Pause `ModalScrim` is in the
world. The HUD-button path is gated too — clicking Pause while
another modal is up is almost always an accident.

The four modal queries are bundled into a `PauseModalQueries`
SystemParam to stay under Bevy's 16-parameter cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:20:39 +00:00
funman300 17e0737a10 feat(engine): Enter dismisses Win Summary and starts a fresh deal
The post-win modal's "Play Again" was click-only — keyboard-only
players had to reach for the mouse to leave the celebration screen,
and the button advertised no accelerator the way every other modal
button does.

- handle_win_summary_keyboard reads Enter while WinSummaryOverlay is
  in the world; despawns the overlay and writes the same
  NewGameRequestEvent the click handler takes.
- The button label gains a trailing return-key glyph so the keyboard
  path is discoverable on first sight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:13:26 +00:00
funman300 dd63261999 feat(engine): auto-show Home / mode picker on launch
The Home (mode picker) was only reachable via M during gameplay, so
players who hadn't discovered the hotkey never saw the Daily / Zen /
Challenge / Time Attack entry points after the splash cleared.

- HomePlugin gains an `auto_show_on_launch` flag (default true) and a
  matching `headless()` test constructor that disables it.
- spawn_home_on_launch flips a one-shot LaunchHomeShown flag once the
  splash has cleared, gated on RestorePromptScreen / PendingRestoredGame
  so the Welcome-back flow still takes precedence on machines with a
  saved game.
- App entry uses HomePlugin::default(); both headless test fixtures
  switch to HomePlugin::headless() so per-test worlds start clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:57:25 +00:00
funman300 93660c2217 feat(engine): N keypress now opens the real Confirm/Cancel modal
Previously a first N press during an active game showed a "Press N
again" toast and started a 3-second countdown — a UI-first violation
since the only continuation was another keystroke. The HUD New Game
button already routed through `ConfirmNewGameScreen` with real Cancel
/ New game buttons; this change makes keyboard N do the same.

- handle_keyboard_core fires NewGameRequestEvent::default() directly;
  handle_new_game's existing active-game check spawns the modal.
- Shift+N keeps the keyboard power-user bypass (confirmed: true).
- N is suppressed while the confirm modal or restore prompt is open
  so those modals' own input handlers can process N (cancel /
  start-new-game) without us re-firing the same frame they close.
- KeyboardConfirmState, NEW_GAME_CONFIRM_WINDOW, NewGameConfirmEvent,
  and the "Press N again" toast handler are removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:57:14 +00:00
funman300 56e2e6f151 feat(engine): empty-state copy + onboarding hints across panels
- Leaderboard empty state: replace single muted line with a two-tier
  "Be the first on the leaderboard." headline + body invite.
- Achievements panel: surface a first-launch hint above the grid until
  the player unlocks anything, so the greyed-out rows aren't context-free.
- Volume hotkeys ([/]): emit an InfoToastEvent with the new percentage so
  off-panel adjustments give visible feedback (previously silent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:16:37 +00:00
funman300 cc635328be fix(engine): popover rows stay visible regardless of action-bar fade
Quat: opening Modes / Menu showed a solid dark-purple block in the
top-right with no readable content. Cause: the auto-fade system on
the top-level action bar was fading the popover rows too — they
share the `ActionButton` marker so `paint_action_buttons` can still
paint hover/press, but `apply_action_fade` matched the same marker
and dropped their alpha to whatever the cursor-position-based
fade happened to be (typically 0 because the cursor was inside the
opened popover, well below the top reveal zone). The popover
container stayed at full opacity (its background is `BG_ELEVATED`,
not driven by the fade), so what the player saw was the empty
rounded box with no labels.

Fix: new `PopoverRow` marker on the rows in `spawn_modes_popover`
and `spawn_menu_popover` (both share the same row-spawn shape).
`apply_action_fade` excludes `PopoverRow` via `Without<PopoverRow>`.
Hover / press paint still applies — the popover rows just opt out
of the cursor-position auto-fade since they only render when the
player has explicitly opened the dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:54:34 +00:00
funman300 a4bc063497 fix(engine): Settings rows use full-width layout to prevent overlap
Quat reported the volume UI overlapped with adjacent UI elements in
the Settings panel. The five slider/toggle row helpers
(volume_row × 2, tooltip_delay_row, time_bonus_multiplier_row,
replay_move_interval_row, toggle_row) all used the same flex pattern:

    Node {
        flex_direction: Row,
        align_items: Center,
        column_gap: VAL_SPACE_2,
    }

with no width constraint and no justify_content. Result: every
child packed against the left edge with 8 px gaps. As the value text
varied in width (e.g. "0.80" → "1.00", or "Instant" vs "1.5 s") the
+/− buttons shifted sideways frame to frame, and on narrow windows
the row's natural width could exceed the modal interior, pushing
elements past the right edge or visually merging with neighbours.

Restructured all five helpers to a label-spacer-cluster layout:

    [Label]                      [Value] [-] [+]
    └────── flex-grow=1 ──────┘  └─ cluster ─┘

with `width: Val::Percent(100.0)` on the row so it spans the body
width. The flex-grow spacer absorbs all slack horizontal space; the
controls cluster (value + buttons) sits flush against the right
edge regardless of value-text length. Existing tests still pass —
no behaviour change, just stable layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:45:16 +00:00
funman300 540869c851 feat(engine): "Copy share link" Stats button — clipboards the replay URL
Quat: replay sharing as the next punch-list item.

End-to-end:

1. Player wins a game on a server-backed sync backend.
2. `sync_plugin::push_replay_on_win` spawns the upload task on
   `AsyncComputeTaskPool` and stores the handle in the new
   `PendingReplayUpload` resource. The previous in-flight task (if
   any) is dropped — the most recent win is the one whose share link
   the player will care about.
3. `poll_replay_upload_result` harvests the task on the main thread
   each frame; on success writes `<server>/replays/<id>` to
   `LastSharedReplayUrl`. `UnsupportedPlatform` (LocalOnlyProvider)
   is silently absorbed; real network/auth errors warn-log.
4. The Stats overlay's action bar gains a "Copy share link" button.
   Click writes `LastSharedReplayUrl` to the OS clipboard via
   `arboard` and surfaces a "Copied: <url>" toast.

Trait change: `SyncProvider::push_replay` now returns `Result<String,
SyncError>` (the share URL) instead of `Result<(), SyncError>`. The
default (`UnsupportedPlatform`) is unchanged for non-server backends;
`SolitaireServerClient` parses the response body's `id` field and
composes `<base_url>/replays/<id>`. Both call paths (initial + 401
retry) go through the new `share_url_from_response` helper so the
parse logic isn't duplicated.

New deps:
- `arboard` (~10 KB, cross-platform clipboard) added to workspace +
  `solitaire_engine`. `default-features = false` keeps the X11/Wayland
  binary-feature deps off the dependency graph; arboard handles the
  fallback. Approved per the ASK BEFORE rule.

Persistence: the URL is in-memory only — the player must share within
the session of the win. A future revision can persist it alongside
the replay history file if cross-session sharing is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:32:57 +00:00
funman300 bdac754b26 feat(engine): "Won before" HUD indicator on rematched seeds
When the current deal's (seed, draw_mode, mode) triple matches an
entry in the rolling ReplayHistory, the HUD's tier-2 context row
now shows "✓ Won before" in the success-green colour. Cleared when
the active game itself is won (the on-screen victory cue is enough)
and on fresh deals the player hasn't beaten before.

The indicator answers a question the rolling-history feature
implicitly raised: when a new game starts on a seed the player has
already conquered, surface that fact so they know they can try for
a faster / higher-scoring win on the same layout. Seed re-rolls in
"Winnable deals only" + system-time seeds make this a natural pace
for the indicator to fire — usually empty, occasionally lit.

Implementation: new `HudWonPreviously` marker spawned in tier-2
alongside Mode / Challenge / DrawCycle. Driven by a separate
`update_won_previously` system rather than threading the marker
through `update_hud`'s ten-way query disambiguation. Reads the
existing `ReplayHistoryResource` from `stats_plugin`; gracefully
no-ops in headless tests that don't load StatsPlugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:23:16 +00:00
funman300 f863d85c35 fix(engine): preserve saved game while restore prompt is unanswered
Quat reported the restore prompt didn't appear and noticed their
save file ended up with move_count 0 — diagnosed as a destructive
overwrite. The flow:

1. Player exits with moves; game_state.json has move_count > 0.
2. Player relaunches. Plugin build sees moves > 0, holds the saved
   game in `PendingRestoredGame`, seeds `GameStateResource` with a
   fresh deal so the board doesn't show the half-played game until
   the player picks Continue.
3. The restore prompt should appear. (Why it didn't on Quat's run
   is still TBD — needs a fresh test.)
4. Player exits. `save_game_state_on_exit` writes
   `GameStateResource` (the fresh-deal placeholder) to disk,
   overwriting the meaningful saved game with move_count 0.

Both `save_game_state_on_exit` and `auto_save_game_state` now check
`PendingRestoredGame`: if it still holds an unanswered saved game,
they save THAT (or skip entirely in the auto-save path). The real
saved game on disk is preserved across launches no matter how many
times the player exits without answering the prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:15:31 +00:00
funman300 3c7a0eb4fb feat(engine): restore prompt on launch — Continue or start fresh
Previously the engine silently restored any saved in-progress game
from `game_state.json` on startup. Players who launched expecting a
fresh deal got dropped back into a half-played game with no signal
that a save had been picked up; players who wanted to continue had
no clear acknowledgement either way.

Now: when launching with a saved game that has at least one move
and isn't already won, the engine holds the saved state in a new
`PendingRestoredGame` resource and seeds `GameStateResource` with
a fresh deal. Once the splash overlay finishes, a modal appears:

    Welcome back
    You have an in-progress game. Continue where you left off, or
    start a new one?
    [New game]   [Continue]

- Continue (Enter / C / click) — swaps the saved game into
  `GameStateResource` and fires `StateChangedEvent`. Card sprites
  resync to the restored layout.
- New game (N / click) — drops the saved state, fires
  `NewGameRequestEvent { confirmed: true }`. The existing
  `handle_new_game` flow then deletes `game_state.json` and deals.

Save files with `move_count == 0` (a fresh deal that was never
played) skip the prompt and load directly — there's nothing
meaningful to "continue" there. Won games skip too (the existing
flow already deletes their save file on win).

The spawn system gates on `SplashRoot` being absent so the modal
doesn't pop up over the brand splash on first launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:57:49 +00:00
funman300 d489e7a31b feat(engine): solver-vetted seed selection on AsyncComputeTaskPool
"Winnable deals only" used to call `choose_winnable_seed` inline on
the main thread inside `handle_new_game`. Each rejected attempt costs
~120 ms (`SolverConfig::default()` budget); the loop caps at
`SOLVER_DEAL_RETRY_CAP` = 50, so a pathological run could stall the
UI for ~6 s on a New Game click. Quat flagged this as the highest-
impact UX regression left in the engine.

Reorganised so the solver runs on `AsyncComputeTaskPool`:

- New `PendingNewGameSeed` resource holds an `Option<PendingSeedTask>`
  carrying the in-flight `Task<u64>` plus the request's `mode` and
  `confirmed` flags so the polling system can replay them on a
  synthetic `NewGameRequestEvent` once the task resolves.
- `handle_new_game` now writes to that resource (and `continue`s)
  for the winnable-only / Classic / random-seed branch, instead of
  calling `choose_winnable_seed` synchronously.
- `poll_pending_new_game_seed` runs `.before(GameMutation)` so the
  synthetic event lands in the same frame's `handle_new_game` —
  the player sees no extra-frame visual lag once the solver
  completes.
- Cancel-on-replace: when a fresh `NewGameRequestEvent` arrives
  while a previous task is in flight, `pending_seed.inner = None`
  drops the old task (Bevy's `Task` Drop cancels cooperatively at
  the next await point) before processing the new request.

Two tests:

- `winnable_seed_search_runs_async_and_completes_eventually` —
  spawns the task, drives `app.update()` in a wall-clock-bounded
  loop with `std::thread::yield_now()` so the shared
  `AsyncComputeTaskPool` gets a chance to schedule between polls.
- `winnable_seed_search_drops_in_flight_task_on_new_request` —
  fires a winnable-only request, then before the task can complete
  fires an explicit-seed request that bypasses the solver entirely.
  Asserts the explicit seed wins, verifying the cancel-on-replace
  contract.

Existing solver tests pass unchanged: explicit-seed paths skip the
new branch and run synchronously like before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:49:19 +00:00
funman300 f2f30c8002 docs: adopt unified-3.0 Claude rule set + trim duplications
Adopts the four-file rule set the player added to the working tree:

- CLAUDE.md grows from a 114-line pointer doc to the 571-line
  `unified-3.0` rulebook: hard global constraints (§2), engine
  rules (§3), asset rules (§4), code standards (§5), build +
  verification (§6), git workflow (§7), the change-control
  ASK BEFORE list (§8), and the Context Injection System (§14).
- CLAUDE_SPEC.md — formal architecture spec: crate dependency
  graph with forbidden_deps, data ownership map, state-machine
  invariants ("52 cards always exist", "no duplicate IDs",
  "all cards belong to exactly one pile"), sync merge contract,
  server contract, validation checklist.
- CLAUDE_WORKFLOW.md — two-agent Builder/Guardian pipeline with
  hard-fail patterns that auto-reject (core uses IO/Bevy/network,
  GameState mutated outside GameLogicSystem, blocking async on
  main thread, duplicate logic, merge altered incorrectly).
- CLAUDE_PROMPT_PACK.md — task-type templates.

Three duplicate rule passages removed:

- CLAUDE_SPEC.md §0 dropped no_panics_in_core / core_is_pure /
  event_driven_engine — already canonical in CLAUDE.md §2.1, §2.3,
  §3.1. Kept single_source_of_truth and sync_is_additive (those
  describe data flow, not in CLAUDE.md).
- CLAUDE_SPEC.md §11 Prohibited Patterns now references CLAUDE.md
  §11 instead of restating the same five forbidden items.
- ARCHITECTURE.md Design Principles dropped the pure-core /
  no-panics / UI-first bullets — those are enforcement constraints
  living in CLAUDE.md §2.1, §2.3, §3.3; this file describes the
  design that motivates them. Kept the offline-first, one-language,
  and plugin-based-Bevy bullets (those are descriptive, not
  enforcement).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:42:24 +00:00
funman300 a49a340a30 chore: prune low-value tests per CLAUDE_SPEC.md §10 + WORKFLOW §8
The Quat-flagged "≥3 tests per feature" inflation produced 43 tests
that don't earn their existence — default-value, serde-derive
round-trips on plain structs, single-field clamp tests, near-
duplicates, and trivial constant-equals-itself tests. None pin a
behaviour contract or a regression on a real bug.

Removed across `solitaire_data` and `solitaire_core`:

  settings.rs   −22  default-value, round-trip, legacy-format,
                     and per-field sanitized clamp tests. Adjust
                     and load-error tests retained — those exercise
                     real method logic.
  progress.rs    −1  generic round-trip on plain struct.
  challenge.rs   −1  challenge_count() returns CHALLENGE_SEEDS.len()
                     literally — testing it asserts the implementation
                     against itself.
  game_state.rs  −3  undo_count starts at 0, GameMode default is
                     Classic, time_attack score starts at 0 — all
                     default-value tests on freshly-constructed state.
  card.rs        −5  rank_value_ace + rank_value_king subsumed by
                     rank_values_are_sequential; suit_red + suit_black
                     consolidated into one complementarity test;
                     card_face_up_field_reflects_construction was
                     testing the struct literal.

Workspace: 1208 → 1165 passing tests (−43). clippy --workspace
--all-targets clean.

Future work: brief sub-agents for tests that pin a behaviour
contract or regression on a real bug, not a count of N. See
`feedback_test_discipline.md` in auto-memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:42:05 +00:00
funman300 27cdf78ce0 docs: cut v0.17.0 — solver-driven hints + replay-rate slider
Two follow-up commits on top of v0.16.0:
- 87275bf: H-key hint asks the v0.15.0 solver for the actual best
  first move, with the existing heuristic kept as fallback.
- 53e3b81: Settings → Gameplay slider tunes replay playback rate
  (0.10–1.00 s, default 0.45 s) read per frame from SettingsResource.

Adds the [0.17.0] CHANGELOG section, folds the post-v0.16.0
provisional table into a v0.17.0 shipped table in SESSION_HANDOFF,
prunes the now-stale "Cut v0.17.0" item from the punch list, and
re-letters the resume-prompt decision options A–D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:11:08 +00:00
funman300 faa6c5efc4 docs: reconcile SESSION_HANDOFF with actually-shipped state
The post-v0.16.0 table marked the replay-rate slider as `(pending)`
but 53e3b81 already shipped it. Resume prompt said "HEAD at v0.16.0
/ 1196 tests" while the same doc above said HEAD was post-v0.16.0
with two follow-ups and 1208 tests.

Updates the slider row to reference 53e3b81, refreshes the resume
prompt's HEAD/test counts, and rewrites the "DECISION TO ASK THE
PLAYER FIRST" list — drops the smoke-test and "solver hints" bullets
(both already covered) and pulls forward the actual open items
(cut v0.17.0, solver-on-AsyncComputeTaskPool, won-previously,
replay sharing, packaging).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:05:03 +00:00
funman300 487b99bbc9 docs: SESSION_HANDOFF refresh — solver hints + replay slider, async deferred
Documents the two follow-ups landed on top of v0.16.0 (solver-driven
hints in 87275bf, replay-rate slider in this commit's parent) and
notes that an async-solver attempt was rolled back when a sub-agent
was interrupted leaving 3 failing tests. Async-solver is still
worth doing but needs smaller scoping next round.

Also records the process note raised this session: agent briefs had
been mandating ≥3 tests per feature, which produced low-value
coverage on trivial settings fields (Default trait arithmetic,
serde derive round-trips, stdlib clamp). Future briefs should ask
only for tests that pin behaviour contracts or regressions on real
bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:00:59 +00:00
funman300 53e3b816cf feat(settings,engine): replay-playback rate slider in Settings → Gameplay
The replay overlay's per-move tick rate has been hardcoded at
REPLAY_MOVE_INTERVAL_SECS = 0.45 s/move since the in-engine
playback shipped. Power users want to scrub faster through older
wins. Adds a Settings slider that tunes the interval 0.10–1.00 s in
0.05 s steps; default 0.45 s preserves existing feel.

Settings.replay_move_interval_secs uses #[serde(default)] so legacy
files load to 0.45. sanitized() clamps out-of-range values.
tick_replay_playback now reads SettingsResource per frame and falls
back to the constant when the resource is absent (test fixtures).
The slider takes effect on the very next playback tick — no need to
restart playback.

Mirrors the existing tooltip-delay slider exactly: SettingsButton::
ReplayMoveIntervalUp/Down variants, the same `slider_row` pattern,
the same per-tick repaint system shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:00:59 +00:00
funman300 87275bf340 feat(core,engine): solver-driven hints with heuristic fallback
The H-key hint now asks the v0.15.0 Klondike solver for the actual
best first move from the current game state instead of the existing
heuristic. The heuristic stays as the fallback path so hints still
work when the solver bails Inconclusive on the player's budget.

solitaire_core::solver gains a path-recording variant. The internal
DFS already enumerated moves on each frame; recording the root_move
on the stack frame is +16 bytes and one unwrap_or per expansion —
the new-game retry loop sees no measurable slowdown.

New public API (additive — try_solve unchanged):

  pub struct SolverMove { source, dest, count }
  pub struct SolveOutcome { result: SolverResult, first_move: Option<SolverMove> }
  pub fn try_solve_with_first_move(seed, draw_mode, &cfg) -> SolveOutcome
  pub fn try_solve_from_state(&GameState, &cfg) -> SolveOutcome

The internal solver-move enum was renamed InternalMove so the public
SolverMove can use engine-friendly (source, dest, count) types
instead of the compact internal form.

Engine wiring: handle_keyboard_hint calls try_solve_from_state on
the live GameStateResource. On Winnable + first_move, the hint
surfaces that exact move (no cycling — a single, optimal hint).
Unwinnable or Inconclusive falls through to the existing all_hints
cycling heuristic so hints remain useful in deals the solver gives
up on.

A new HintSolverConfig resource lets tests inject tight budgets to
force the fallback path; production uses SolverConfig::default()
and median solve time stays at 2 ms per H press.

Six new tests pin the contract: 4 in solitaire_core (Winnable
returns first_move, Unwinnable returns None, deterministic, seed
and state forms agree); 2 in solitaire_engine (hint uses solver
when Winnable, falls back to heuristic when Inconclusive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:10:02 +00:00
funman300 56647d7f0d docs: CHANGELOG + SESSION_HANDOFF refresh for v0.16.0
CHANGELOG gains a [0.16.0] section covering the modal-feel polish
round: per-modal Overflow::scroll_y on Achievements / Help / Stats /
Profile / Leaderboard, pointer cursor on hover for every Button,
same-frame focus on modal open (attach + auto_focus moved to
PostUpdate), and click-outside-to-dismiss for the six read-only
modals via a new ScrimDismissible marker.

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1196.

SESSION_HANDOFF rewritten for the post-v0.16.0 state. Punch list
collapsed to two release-prep items (smoke-test, desktop packaging)
plus the carryover from v0.15.0's next-round candidates that didn't
ship this round (solver-driven hints, replay-rate slider, solver
progress overlay, async solver, "won previously" indicator, replay
sharing). Resume prompt asks A–E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:52:08 +00:00
funman300 cbf2483028 feat(engine): opt Profile / Leaderboard / Home into scrim-click dismiss
Follow-up to a54201e. The previous commit added ScrimDismissible to
Stats, Achievements, and Help; this one extends the same one-line
opt-in to the remaining three read-only modals so the click-outside-
to-close gesture is consistent across every informational surface.

Each modal now has the same shape: capture the scrim from
spawn_modal, attach ScrimDismissible after the build closure
returns. Three lines per file plus the import; no behaviour change
to the modal content itself.

Settings, Onboarding, Pause, Forfeit confirm, ConfirmNewGame, and
the win/game-over modals continue to opt OUT — all carry unsaved
or destructive state where an accidental scrim click would lose
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:47:02 +00:00
funman300 a54201e97b feat(engine): click-outside-to-dismiss for read-only modals
Adds a ScrimDismissible marker to ui_modal that opts a modal into
the standard "click outside the card to close" gesture. The new
dismiss_modal_on_scrim_click system fires on a left-mouse press
whose cursor falls on the scrim and outside every ModalCard, then
despawns the topmost dismissible scrim — Bevy's hierarchy despawn
cascades to the card and its children.

Marker design is opt-in per modal so destructive / state-mutating
modals (Settings saves on close, Onboarding requires explicit
acknowledgement, Pause / Forfeit / ConfirmNewGame need confirmed
intent) don't lose work to an accidental scrim click. Three
read-only modals opt in this round:

- Stats — informational; press S or click outside to dismiss.
- Achievements — read-only list.
- Help — keyboard reference.

Profile, Leaderboard, and Home will opt in the same way in a
follow-up; they were left out to keep this commit's scope tight.

The hit-test path uses each ModalCard's UiGlobalTransform +
ComputedNode bounding box so stacked modals close cleanly: the
topmost dismissible scrim is the only candidate per click. Tests
spawn synthetic ComputedNodes (with bevy::sprite::BorderRect for
the resolved-border slots Bevy's UI module re-exports) so the
geometry hit-tests deterministically without running the full UI
layout pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:58 +00:00
funman300 48e412177c fix(engine): focus arrives on the same frame a modal opens
Previously when a click-handler in Update spawned a modal,
attach_focusable_to_modal_buttons and auto_focus_on_modal_open ran
in the same Update — but with no ordering edge to the click handler
the deferred Commands wouldn't materialise in time, so attach saw
no entities, FocusedButton stayed empty, and the very next Tab/Enter
press wasted itself moving focus from None to the primary instead
of activating it.

Moves attach_focusable_to_modal_buttons + auto_focus_on_modal_open
from Update to PostUpdate. The schedule boundary itself supplies
the sync point: every modal spawned anywhere in Update is
materialised before PostUpdate runs, attach can find the new
ModalButtons, and FocusedButton is populated before app.update()
returns. handle_focus_keys stays in Update so it observes input on
the frame it occurs, reading FocusedButton written by the previous
tick's PostUpdate.

Two new tests pin the contract:
- primary_button_is_focused_on_modal_spawn_same_frame uses a
  production-shaped spawner system (no chain edge to UiFocusPlugin)
  and asserts FocusedButton.0 is Some after a single update —
  fails without the fix, passes with it.
- first_tab_after_modal_open_advances_to_secondary guards against a
  regression where focus arrives but the very first Tab moves from
  None to primary instead of from primary to secondary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:19 +00:00
funman300 cd54ce1bb0 feat(engine): pointer cursor on hover over interactive buttons
Previously the cursor stayed the default arrow over every clickable
UI element (modal buttons, HUD action bar, mode-launcher cards,
settings toggles). Adds the standard "this is clickable" hand
affordance: while not dragging a card, hovering any entity with
Interaction::Hovered (or Pressed — keeps the pointer through a
click-and-hold) sets the window cursor to SystemCursorIcon::Pointer.

The new branch sits between the existing drag handlers in
update_cursor_icon: Grabbing wins when actively dragging, then
Pointer when a button is hovered, then Grab when a draggable card
is hovered, then Default. Card-drag affordance unchanged.

A pure pick_cursor_icon(is_dragging, any_button_hovered,
any_card_hovered) helper makes the priority logic unit-testable
without standing up a full Window + Camera fixture; four new tests
pin every branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:04 +00:00
funman300 7a3032b74c fix(engine): scroll the modals whose content overflows the viewport
Smoke-test report: the Achievements list isn't scrollable. With 19
achievements the panel overflows the modal at the 800x600 minimum
window and the bottom rows are clipped. The same problem applies to
several other modals whose content has grown over the v0.13–v0.15
rounds.

Mirrors the existing SettingsPanelScrollable pattern from
settings_plugin: each modal's body Node gets Overflow::scroll_y()
plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the
leaderboard's variable-length ranking section), a marker component
so the scroll system can find it, and a sibling system that routes
MouseWheel events into the body's ScrollPosition.

Five modals fixed:
- Achievements: 19 rows clearly overflow; AchievementsScrollable +
  scroll_achievements_panel.
- Help: ~28 reference rows overflow at 800x600; HelpScrollable +
  scroll_help_panel.
- Stats: 8-cell primary grid + per-mode bests + progression +
  weekly goals + unlocks + Time Attack readout + replay caption is
  enough content to overflow once the player has any progress;
  StatsScrollable + scroll_stats_panel.
- Profile: Sync + Progression + 14-day calendar + up to 18
  unlocked achievements + Stats summary overflows once a few
  achievements unlock; ProfileScrollable + scroll_profile_panel.
- Leaderboard: 10-row cap is at the edge of overflow on 800x600
  with long display names; LeaderboardScrollable +
  scroll_leaderboard_panel (max_height = 50vh — the ranking section
  is the only variable-length part).

Home modal NOT scrolled — five mode cards plus a Cancel button
were sized to fit at 800x600 by design and adding scroll there
would clutter the launcher.

Five new tests pin the contract: each modal's body has the
scrollable marker, a non-default max_height, and Overflow::scroll_y.

Defer-list (small UX nits surfaced during the sweep, not fixed
here):
- Modal close-on-click-outside is missing across the board; would
  need Interaction on ModalScrim in ui_modal.
- ModalButton hover doesn't set a pointer cursor.
- Tab focus on modal open is initialised on the next frame instead
  of the same frame; first Tab press selects rather than focus
  already being on the primary.

These are bigger touches than the scroll fix and don't fit a
30-LOC budget; surfacing for a follow-up round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:30:04 +00:00
funman300 89699a8a86 docs: SESSION_HANDOFF refresh for post-v0.15.0 (follow-up)
The previous v0.15.0 doc commit only landed CHANGELOG — the
SESSION_HANDOFF write silently no-op'd due to a Write tool param
mix-up. This commit lands the matching handoff refresh:

- Status block updated to v0.15.0 / HEAD / 1178 tests
- New v0.15.0 changelog table covering the seven feature commits
  (Bevy trim, replay playback core + overlay + Stats wiring,
  rolling replay history, Cinephile achievement, solver + toggle)
- Open punch list collapsed to two release-prep items (smoke-test,
  desktop packaging) and six fresh next-round candidates
  (solver-driven hints — now unblocked, replay-rate slider, solver
  progress overlay, async solver, "won previously" indicator,
  replay sharing)
- Resume prompt asks A–E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:08:46 +00:00
funman300 70165da103 docs: CHANGELOG + SESSION_HANDOFF refresh for v0.15.0
CHANGELOG gains a [0.15.0] section covering 7 commits since
v0.14.0: Bevy default-features trim (51 transitive crates dropped),
in-engine replay playback core + overlay banner + Stats button
wiring, rolling replay history (last 8 wins) with selector UI,
"Cinephile" achievement (#19), and the Klondike solver + "Winnable
deals only" toggle.

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1178.

SESSION_HANDOFF rewritten for the post-v0.15.0 state. Open punch
list collapsed to two release-prep items (smoke-test, desktop
packaging) and six fresh next-round candidates: solver-driven
hints (now unblocked), playback-rate slider, solver progress
overlay, solver-on-async-compute, per-deal "won previously"
indicator, replay sharing. Resume prompt asks A–E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:07:15 +00:00
funman300 8a5fa8751c feat(core,engine): Klondike solver and "Winnable deals only" toggle
Closes Quat investigation #1. Today some Klondike deals are
unwinnable from the start and the player has no signal that the
deal they were given is solvable. A new Settings → Gameplay toggle
"Winnable deals only" (default off) makes the engine retry seeds
at deal-time until the solver returns Winnable, up to a cap.

Solver

solitaire_core::solver is a hand-rolled iterative-DFS solver with
memoisation on a 64-bit canonical state hash. Move enumeration is
priority-ordered: foundation moves first (zero choice when an Ace
or rank-up exists), inter-tableau moves second, waste-to-tableau
third, stock-draw last. The draw is skipped when the cycle counter
shows we've recirculated the entire stock without progress —
Klondike's deterministic stock cycle means further draws can't
unlock anything new.

Two budget knobs (move_budget = 100k, state_budget = 200k by
default) cap pathological cases at Inconclusive; the caller treats
Inconclusive as "winnable" so the player isn't penalised for the
solver giving up. Median solve time is 2 ms; pathological
inconclusives top out near 120 ms.

Switched from recursive to iterative DFS after a real-deal solve
overflowed Rust's default 8 MB thread stack. Behaviour identical;
the change is invisible to callers.

Pure logic — solitaire_core has no Bevy or I/O. Same input always
yields the same SolverResult.

Settings

Settings.winnable_deals_only is a #[serde(default)] bool; legacy
files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry
loop. The Settings → Gameplay toggle reads as "Winnable deals only"
with a "(may take a moment when on)" caption.

Engine integration

handle_new_game's seed-selection path now branches on the toggle.
When on AND mode is Classic AND no specific seed was requested
(daily challenges, replays, and explicit-seed requests bypass the
solver), choose_winnable_seed walks seed N, N+1, N+2, … calling
try_solve until it finds Winnable or Inconclusive. If the cap is
hit without a verdict, the latest tried seed is used so the player
always gets a deal rather than spinning forever.

19 new tests (11 solver, 3 settings, 5 engine including the
choose_winnable_seed unit). Two ignored bench/scan helpers
(solver_bench, find_unwinnable) for ad-hoc profiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:02:22 +00:00
funman300 bf660df971 feat(core,engine): "Cinephile" achievement for completing a replay
Adds a 19th achievement: "Cinephile — Watch a saved replay all the
way through." Unlocks the first time ReplayPlaybackState transitions
Playing → Completed (i.e. the move list runs out without the player
pressing Stop). Discoverability nudge for the replay feature itself.

The achievement uses the existing event-driven unlock pattern
(condition closure returns false; an unlock system fires
AchievementUnlockedEvent on the right state transition) rather than
the standard condition-evaluation path, mirroring how other
non-stat-driven achievements work.

The unlock system distinguishes natural completion from Stop-button
abort by watching for the specific Playing → Completed transition;
Stop transitions Playing → Inactive directly without going through
Completed, so it doesn't fire the achievement. Already-unlocked
state is checked via AchievementsResource so the achievement can't
double-fire on subsequent replays.

README's "18 Achievements" → "19 Achievements". ARCHITECTURE.md §11
gains a Cinephile entry alongside the existing 18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:56 +00:00
funman300 13a8a012ee feat(data,engine): rolling replay history (last 8 wins)
Promotes replay storage from a single overwriting slot at
latest_replay.json to a rolling list of the most recent 8 wins at
replays.json so the player can revisit a memorable game even after
winning more recently.

Storage layer

solitaire_data::replay gains ReplayHistory (schema_version=1, Vec<Replay>
capped at REPLAY_HISTORY_CAP = 8) plus save_replay_history_to,
load_replay_history_from, append_replay_to_history, and
replay_history_path. append_replay_to_history inserts at the front,
drops the oldest when the cap is hit, and persists atomically via
the existing .tmp + rename pattern. The legacy single-slot helpers
are #[deprecated] but kept for one release as a migration safety
net via the new migrate_legacy_latest_replay helper.

Engine integration

game_plugin's record_replay_on_win now appends to the history
instead of overwriting latest_replay.json. On Startup, if a legacy
latest_replay.json exists but replays.json doesn't, the migration
helper seeds the new file from the legacy entry — so the player's
last v0.14.0 replay carries forward.

Stats UI

LatestReplayResource → ReplayHistoryResource holding the full
history. New SelectedReplayIndex resource (default 0 = most
recent) drives a Prev / Next / "Replay N / M" selector at the top
of the Stats overlay. ReplayPrevButton, ReplayNextButton, and
ReplaySelectorCaption marker components let the repaint system
update the caption as the selection changes. The Watch button
launches the selected replay rather than always the most recent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:37 +00:00
funman300 02ababa65f feat(engine): wire Stats Watch Replay button to in-engine playback
Promotes the Stats overlay's Watch Replay button from a stub
InfoToastEvent ("playback coming in a future build") to actually
starting in-engine playback via the new
replay_playback::start_replay_playback API. Pressing the button
when a replay exists resets the game to the recorded deal and the
ReplayOverlayPlugin's banner takes over.

The handler reads ReplayPlaybackState via Option<ResMut<...>> so
headless test fixtures that don't register ReplayPlaybackPlugin
keep compiling — they fall back to a descriptive "Replay ready"
toast. The "no replay yet" branch still surfaces the existing
"win a game first" toast.

Plugin registration in solitaire_app/src/main.rs picks up
ReplayPlaybackPlugin and ReplayOverlayPlugin alongside the existing
StatsPlugin. They run in any order — the playback plugin owns the
state resource, the overlay plugin spawns/despawns based on state
changes, and Stats's button handler dispatches into the playback
plugin's API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:48 +00:00
funman300 9c36b49729 feat(engine): replay-playback overlay banner with Stop button
Visible UI for the in-engine replay playback that just landed: a
thin top banner anchored to the window edge while
ReplayPlaybackState is Playing or Completed, surfacing the player's
current position in the move list and a way to abort.

Layout: full-width banner ~48 px tall with three children — a
"Replay" label in ACCENT_PRIMARY left-aligned, "Move N of M"
progress text centred, and a Tertiary Stop button right-aligned via
the existing spawn_modal_button helper so it gets focus rings and
hover/press states for free.

Z_REPLAY_OVERLAY = Z_DROP_OVERLAY + 5 (= 55) sits above HUD but
well below modal scrim (≥200), so Settings, Pause, and Help still
render on top of the overlay during a replay — the player can
adjust audio or pause mid-playback.

State-driven: the spawn system reacts to Changed<ReplayPlaybackState>
transitions, swapping the banner text to "Replay complete" when
state moves Playing → Completed and despawning entirely when state
returns to Inactive (either via the Stop button, completion linger
expiry, or external reset).

Five tests cover spawn-on-Playing, progress text, stop-button
clears state and despawns, despawn-on-Inactive, and Completed
banner text swap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:36 +00:00
funman300 8e90574437 feat(engine): in-engine replay playback core
Promotes the replay feature from disk-only to a real in-engine
playback path. A new ReplayPlaybackState resource models a three-
state machine (Inactive / Playing / Completed); start_replay_playback
resets the live game to the recorded deal via
GameState::new_with_mode(seed, draw_mode, mode) and a tick system
fires the canonical MoveRequestEvent / DrawRequestEvent for each
recorded move at REPLAY_MOVE_INTERVAL_SECS (0.45s).

The reset path bypasses NewGameRequestEvent because the existing
event always sources draw_mode from Settings — a Draw-1 replay
would silently coerce to Draw-3 (or vice versa) on a player whose
preference doesn't match the recording. Inserting GameStateResource
directly applies the recording's exact draw_mode and sidesteps the
abandon-current-game confirmation modal that would otherwise block
playback.

Recording suppression during playback is non-invasive: a sibling
system snapshots RecordingReplay's length on entry to playback and
truncates the buffer back to that mark every frame while is_playing
or is_completed. game_plugin's recording append paths are
untouched.

Completion lingers for REPLAY_COMPLETION_LINGER_SECS (5s) so the
overlay can show "Replay complete" before the auto-clear flips
state to Inactive.

Six new tests cover the state transitions, tick cadence, canonical
event firing, completion, stop-clears-state, and the
recording-suppression contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:16 +00:00
funman300 95fcdad5d2 chore: disable Bevy default features to drop unused audio stack
Closes Quat investigation #2. The project uses kira for audio
(cpal 0.17 + alsa 0.10), but Bevy's default feature set still pulled
bevy_audio → rodio → cpal 0.15 + alsa 0.9 + symphonia codecs — about
50 transitive crates the binary never executes.

Workspace Cargo.toml's bevy entry now declares default-features =
false plus an explicit allow-list of the features actually used
(default_app subset + default_platform desktop subset + common_api +
2D + UI rendering). The list is derived analytically from the leaves
of Bevy 0.18's 2d and ui meta-features; built cleanly on the first
try with no missing-symbol errors.

Features intentionally omitted vs Bevy default:
- bevy_audio (kira handles audio directly)
- bevy_animation (custom CardAnimation, not Bevy's)
- bevy_gilrs, bevy_gizmos, bevy_picking variants, bevy_post_process,
  scene, hdr, sysinfo_plugin (none used)
- webgl2, web, android-* (desktop-only; solitaire_wasm is Bevy-free
  and uses wasm-bindgen + solitaire_core directly)
- wayland (X11 chosen; Wayland can be added later if requested)

Dependency-tree size for solitaire_app drops from 628 unique crates
to 577 (-51). Verified gone: bevy_audio, rodio, cpal 0.15. The
remaining cpal 0.17 and symphonia 0.5 are pulled by kira, not Bevy.

solitaire_wasm needed no changes — it doesn't depend on bevy.

All 1134 tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:07:30 +00:00
funman300 d948fa862a docs: CHANGELOG + SESSION_HANDOFF refresh for v0.14.0
CHANGELOG gains a [0.14.0] section covering 18 commits since v0.13.0
across three threads: the v0.13.0-era UX candidates that missed the
v0.13.0 tag (theme thumbnails, daily-challenge calendar, Time Attack
auto-save, per-mode bests, time-bonus slider), Quat's three bug
fixes from a smoke-test round (multi-card lift validation, softlock
detection, deal-tween information leak), and the major new replay
pipeline (record → persist → upload → web viewer with a new
solitaire_wasm crate).

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1134.

SESSION_HANDOFF rewritten as the session 9 / post-v0.14.0 doc.
The session 8 changelog table is preserved alongside a new "v0.14.0
shipped" rollup. The next-round candidates list seeds six fresh
ideas (deferred Bevy audio trim, solver toggle, in-engine replay
playback, per-replay history, solver-driven hints, "won via
replay" achievement). Resume prompt asks A–F about smoke-test,
audio trim, solver toggle, in-engine playback, fresh UX, or
packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:44:03 +00:00
funman300 1fcd032b0a feat(web): card flight animations between piles
The replay viewer's renderer used to wipe and rebuild every card
from scratch on every step (`board.replaceChildren()`). Each step
was a discrete redraw — fine for correctness, abrupt for the eye.

Restructured to a persistent card-element model:

- `#board` is now a positioned context (relative) instead of a
  CSS grid. The dashed empty-pile placeholders are absolutely-
  positioned `.slot` elements painted once at bootstrap.
- Each card lives as a sibling of the slots, absolutely-positioned
  with `transform: translate(x, y)`. The CSS transition on
  `transform` (280 ms cubic-bezier) runs every move as a flight
  rather than a redraw.
- `cardEls: Map<id, HTMLElement>` persists across renders. Cards
  unchanged between steps don't re-create their DOM at all.
- Z-index is set per-render from the card's pile index so a card
  flying out from the bottom of a tableau passes behind the cards
  above it.
- Newly-spawned cards (rare — only on Restart) fade in at their
  target position via a `requestAnimationFrame` opacity flip;
  cards that disappear (also rare) fade out and despawn after the
  220 ms fade.
- `will-change: transform` lets the browser composite the
  animation, keeping it smooth on low-spec hardware.

Restart now drops every existing card before resetting so the
fresh deal looks like a new game, not a continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:01:02 +00:00
funman300 3081505a3d test(server): E2E coverage for replay upload → fetch path
Five new integration tests against the in-process Axum router +
in-memory SQLite, covering the full HTTP transport + database layer
that the web replay viewer depends on:

- `replay_upload_then_fetch_round_trips_payload` — register → POST →
  GET → assert the fetched JSON matches the upload byte-for-byte.
  Canonical "the web viewer can play back what the desktop client
  uploaded" coverage.
- `replay_fetch_unknown_id_returns_404` — exercises the
  `AppError::NotFound` mapping (not a 500).
- `replay_recent_lists_newest_first_with_username` — two uploads,
  asserts received_at DESC ordering and that the username join
  populates the `username` field.
- `replay_upload_without_auth_returns_401` — guards against the
  upload endpoint accidentally accepting anonymous inserts.
- `replay_upload_malformed_body_returns_400` — header projector
  rejects payloads missing required fields with 400, not 500.

Schema-correctness (round-trip, version gate, atomic write) is still
covered by `solitaire_data::replay`'s unit tests; this file is
strictly for the HTTP transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:58:54 +00:00
funman300 07b8ecd9b2 feat(server): web replay viewer (HTML/CSS + WASM bindings)
Wires the WASM module from the previous commit into a minimal web
viewer served at <server>/replays/<id>. Two new server routes:

- `GET /replays/:id`  — returns the same embedded HTML page for any
  id; the page itself reads the path from window.location in JS and
  fetches the replay JSON via /api/replays/:id.
- `/web/*` — ServeDir for the static assets (replay.css, replay.js,
  and the wasm-bindgen-generated pkg/).

Web layer:
- index.html — header, board, controls, status. Module script.
- replay.css — midnight-purple palette matching the desktop client,
  dark felt board, CSS-grid pile layout, tableau fan via per-card
  inline `top` offset.
- replay.js — fetches the replay, instantiates the wasm
  ReplayPlayer, drives state(), step(). Controls: Restart, Play/Pause
  toggle, Step. Auto-tick at 600 ms.
- pkg/ — generated by wasm-bindgen (committed so deployers don't
  have to install wasm-bindgen-cli + the wasm32 target).

`tower-http = "0.6"` added to solitaire_server with the `fs` feature
for ServeDir.

To regenerate pkg/ after a solitaire_wasm change:
    RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
      cargo build -p solitaire_wasm \
      --target wasm32-unknown-unknown --release
    wasm-bindgen --target web \
      --out-dir solitaire_server/web/pkg --no-typescript \
      target/wasm32-unknown-unknown/release/solitaire_wasm.wasm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:54:01 +00:00
funman300 5bed43ef32 feat(wasm): solitaire_wasm crate for browser-side replay re-execution
A new `cdylib + rlib` workspace member that wraps `solitaire_core::
GameState` for use from JavaScript. The web replay viewer fetches a
replay JSON, hands it to `ReplayPlayer::new`, and steps through
moves one at a time — same Rust rules engine the desktop client
uses, so the two implementations cannot drift.

The crate intentionally does NOT depend on `solitaire_data` (which
pulls dirs/keyring/reqwest, none wasm-friendly). Instead it defines
a minimal `Replay` mirror with the same serde shape; the JSON wire
format is the contract.

Public surface (#[wasm_bindgen]):
- `ReplayPlayer::new(json)` — parse + rebuild deal from seed/mode
- `state()` / `step()` — return JS-friendly StateSnapshot
- `total_steps()` / `step_idx()` / `is_finished()` — progress helpers

Native-callable mirror (`from_json`, `step_native`) lets unit tests
exercise the state machine without going through `serde_wasm_bindgen`,
which panics off-target. 3 tests cover construction, step advance,
and invalid-JSON handling.

`getrandom` needs the `wasm_js` feature on the wasm32 target;
configured via the cfg target dep table so non-wasm builds aren't
affected.

Build pipeline (executed from the repo root):
    rustup target add wasm32-unknown-unknown
    RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
      cargo build -p solitaire_wasm \
      --target wasm32-unknown-unknown --release
    wasm-bindgen --target web \
      --out-dir solitaire_server/web/pkg --no-typescript \
      target/wasm32-unknown-unknown/release/solitaire_wasm.wasm

The generated bindings land in solitaire_server/web/pkg/ and are
committed alongside the web UI (next commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:53:19 +00:00
funman300 23c9704887 feat(engine): upload winning replays to the sync server
`push_replay_on_win` listens for `GameWonEvent` and spawns a
fire-and-forget `AsyncComputeTaskPool` task that calls
`SyncProvider::push_replay`. The game loop never blocks on the
network round-trip; failures log a warning but never abort the win
flow because the replay is already persisted locally by
`game_plugin::record_replay_on_win`.

`UnsupportedPlatform` (LocalOnlyProvider) is silently absorbed in
the same way the existing `push_on_exit` path handles it — local
players don't see a server error every time they win.

Empty-recording guard mirrors `record_replay_on_win`: synthesised
win events from XP / streak / weekly-goal tests must not trigger an
upload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:52:36 +00:00
funman300 93182fa251 feat(server): replay upload + fetch endpoints
API surface for the web replay viewer to come:

- `POST /api/replays`        — auth required; persists the JSON body
  verbatim, mints a server-side UUID, returns `{id}`. Three columns
  (final_score, time_seconds, recorded_at) are projected out of the
  payload at insert time so list endpoints don't have to scan blobs.
- `GET  /api/replays/recent` — public; returns the N most-recent
  replays across users (limit defaults to 20, capped at 50). Joins
  the username so the feed reads as "AliceWon · 2:14 win".
- `GET  /api/replays/:id`    — public; returns the full replay JSON
  the desktop client uploaded.

Migration `002_replays.sql` adds the `replays` table with indexes
on `received_at DESC` (recent feed) and `user_id` (per-user views).

Schema-version compatibility is the playback side's responsibility,
matching the desktop's existing `schema_version` gate — the server
just stores and serves whatever JSON came in.

`AppError::NotFound` added so `GET /api/replays/:id` can return a
proper 404 instead of an internal-server-error.

`.sqlx` cache regenerated for the new `query!` invocations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:50:25 +00:00
funman300 89c51ab712 feat(settings): time-bonus multiplier slider in Settings → Gameplay
Cosmetic-only player setting (default 1.0, range 0.0-2.0, step 0.1)
that scales the time-bonus row shown in the win-summary modal's
score breakdown. Achievement thresholds, lifetime score totals, and
leaderboard submissions still use the raw values produced by
`solitaire_core::scoring`, so the multiplier never affects what gets
recorded — just what the player sees on the win screen.

- New `Settings::time_bonus_multiplier` field with `#[serde(default)]`
  + `sanitized()` clamp so older settings.json files load cleanly.
- New constants `TIME_BONUS_MULTIPLIER_{MIN,MAX,STEP}` re-exported
  through `solitaire_data::lib`.
- `settings_plugin` adds a slider row under the Gameplay header
  matching the existing tooltip-delay control.
- `win_summary_plugin` applies the multiplier when rendering the
  time-bonus row of the score breakdown; "Off" label when 0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:49:07 +00:00
funman300 3984231c9b feat(data,sync,engine): per-mode best score and fastest win
Lifetime stats now also track best score and fastest win per game
mode (Classic, Zen, Challenge), additive on top of the existing
all-modes-combined `best_single_score` and `fastest_win_seconds`.
Time Attack is intentionally excluded — its scoring model is
session-level (count of wins inside a 10-minute window) so a
per-game best wouldn't compose. Daily Challenge inherits Classic
scoring and contributes through the Classic row.

- `solitaire_sync::StatsSnapshot` gains six fields (`{mode}_best_score`,
  `{mode}_fastest_win_seconds` × {Classic, Zen, Challenge}). All are
  `#[serde(default)]` so older save files load cleanly to zeros.
- `solitaire_sync::merge` propagates the per-mode bests through the
  same max/min logic as the global counterparts.
- `solitaire_data::StatsExt::update_per_mode_bests` is the engine's
  entry point — called from `update_stats_on_win` alongside the
  existing `update_on_win`.
- Stats overlay grows a "Per-mode bests" section with three rows
  (Classic / Zen / Challenge) tagged with `PerModeBestsRow`. Empty
  rows render an em-dash, matching the first-launch zero-state
  treatment used by the primary cells.
- 3 new tests cover the rendering, the Classic-mode update path,
  and the Zen-mode update path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:46:32 +00:00
funman300 d9f36bf34a feat(engine): "Watch replay" affordance in Stats overlay
The Stats screen now shows the most recent winning replay's caption
("M:SS win on YYYY-MM-DD") and a Watch Replay button. Until the web
viewer is fully wired the click fires a toast pointing the player at
the upcoming `<server>/replays/<id>` URL; once the upload + page
ship the toast is replaced with an actual link.

- New resources `LatestReplayResource(Option<Replay>)` and
  `LatestReplayPath(Option<PathBuf>)` populated at plugin build time
  from the platform-default `latest_replay.json`. Headless mode
  disables I/O the same way `StatsResource` does.
- `refresh_latest_replay_on_win` re-loads from disk after every
  `GameWonEvent` so opening the modal a second time reflects the
  most recent victory rather than a stale snapshot.
- `format_replay_caption` is a pure helper exposed for both the
  Stats button label and (later) toast messaging.
- `WatchReplayButton` marker added to `solitaire_engine`'s public
  re-exports so the future web-side click integrations can match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:41:55 +00:00
funman300 57d1c58fdf feat(engine): record + persist winning replays on disk
- New `RecordingReplay` resource (in `game_plugin`): in-memory move
  buffer that accumulates atomic player inputs as they're applied to
  `GameState`. Cleared on every `NewGameRequestEvent` so a fresh deal
  starts from an empty list.
- `handle_move` and `handle_draw` push the corresponding `ReplayMove`
  on success only — invalid / rejected events never enter the buffer.
  `Undo` is intentionally not recorded; the replay represents the
  canonical path to victory, not the missteps that were rolled back.
- `record_replay_on_win` listens for `GameWonEvent`, freezes the
  buffer into a `Replay` (seed + draw_mode + mode + score + duration
  + today's date + the move list), and persists atomically to
  `<data_dir>/solitaire_quest/latest_replay.json` via the new
  `ReplayPath` resource.
- Empty-recording guard: synthesised win events from XP / streak /
  weekly-goal tests must not clobber the developer's real replay
  file. A real win always has at least one recorded move.
- 5 dedicated tests cover ordering, rejected-move skipping, undo
  skipping, new-game clearing, and the freeze→save round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:38:49 +00:00
funman300 42535f5109 feat(data): replay storage layer with atomic StockClick input
New `solitaire_data::replay` module:
- `Replay` struct: seed + draw_mode + mode + ordered move list +
  presentation metadata (time / score / date). Replays are
  reconstructed by rebuilding `GameState::new_with_mode` and applying
  the move list in order — a deterministic state machine driven by
  atomic player inputs, no per-step snapshots stored.
- `ReplayMove`: one variant per atomic player input. `Move {from, to,
  count}` covers card moves; `StockClick` covers every click on the
  stock (the engine resolves draw-vs-recycle deterministically from
  current state during both record and playback).
- Schema-versioned (`REPLAY_SCHEMA_VERSION = 2`); legacy files are
  rejected via the version gate so older replays just disappear from
  the UI rather than half-loading.
- Atomic save (.tmp -> rename), `dirs::data_dir()`-based path
  resolution. 5 round-trip / atomic / version-gate / corruption tests.

Sync trait extension:
- `SyncProvider::push_replay(&Replay)` — default returns
  `UnsupportedPlatform` so `LocalOnlyProvider` is silently no-op'd by
  the future push-on-win path. Mirrors the existing `pull` / `push`
  default-impl pattern.
- `SolitaireServerClient::push_replay` — `POST /api/replays`, same
  401-refresh-and-retry shape as `push`.

The wire format is the contract: `solitaire_wasm` (added in a later
commit) parses the JSON via its own minimal mirror struct so it can
compile to wasm32 without pulling the desktop client's transitive
deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:36:25 +00:00
funman300 d5e6f8026b docs: SESSION_HANDOFF refresh for session 8 (Quat smoke-test round)
Captures the three bug-fix commits (move validation, deal-tween leak,
softlock detection), notes that bug #3's "no end-game screen" was
downstream of the softlock-detection bug and is now resolved, and
records the two investigation findings (audio-stack feature trim,
solver-at-deal toggle) as deferred decisions for the player.

Updates HEAD/test counts (origin at 2716472, 1126 tests passing).
Cleans the next-round candidates list — calendar / thumbnails /
Time-Attack auto-save shipped between v0.13.0's doc commit and
session 8; replay is WIP in the working tree.

Resume prompt now offers six choices (A–F) covering finish-replay,
smoke-test, audio-feature trim, solver toggle, other UX, packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:44:33 +00:00
funman300 271647265c fix(engine): treat unplayable stock as softlock in has_legal_moves
Previous heuristic returned true whenever stock or waste held any
cards. Quat hit a state with 4 cards remaining and the stock kept
cycling but nothing was ever playable — the existing detection
counted "draw is available" as a legal move and the game just sat
there.

Replace the early return with a single pass over every card that
could ever be a move source: every Stock card, every Waste card, and
the face-up top of every Tableau column. For each, check whether it
can currently land on any Foundation or Tableau. Return true only if
*some* card anywhere can land *somewhere* — otherwise the player is
genuinely stuck no matter how many times they recycle the deck.

Tightened the existing fresh-game test name to reflect what it
actually proves (a fresh deal has playable moves, not "stock is
non-empty"). Added one new test reproducing Quat's exact case.

Reported by Quat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:55 +00:00
funman300 3eabc149a8 fix(engine): hide previous-game positions during new-game deal
Reported leak: when a new game starts, every card sprite tweens from
its previous-game Transform to its new dealt position. A careful
observer can track those origin points and deduce face-down cards in
the new layout — the tween's start frame literally renders the prior
game's geometry.

Fix: in handle_new_game, after replacing the GameState, snap every
existing card Transform to the stock pile's position before writing
StateChangedEvent. The downstream slide tween in card_plugin then
reads the stock position as its source, so all 52 cards animate out
from a single point — reads as "dealing from the deck" with no
information leak.

No layout reach in headless test contexts so the snap is gated on
Option<Res<LayoutResource>>.

Reported by Quat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:26:14 +00:00
funman300 f1aeb24157 fix(core): validate moved tableau stack forms a legal run
move_cards only checked that the *bottom* card of a moved stack landed
legally on the destination — the cards above the bottom went through
unverified. A player could lift an arbitrary selection from one column
and drop it on another whenever the bottom happened to match, even if
the upper cards didn't form a descending alternating-colour sequence.

Adds is_valid_tableau_sequence(&[Card]) -> bool to rules.rs (4 lines)
and one call site in move_cards's tableau-destination branch. One
focused test covering single-card / valid-run / same-colour /
rank-gap cases.

Reported by Quat: "stack 4 onto stack 2" was accepted when illegal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:26:14 +00:00
funman300 000143231b feat(engine): auto-save Time Attack sessions across launches
Classic, Zen, and Challenge already auto-saved correctly via the
existing game_state.json path — GameState carries mode and the
save/restore systems are mode-agnostic. Time Attack was the gap:
the per-deal GameState round-tripped fine, but the session-level
TimeAttackResource (10-minute countdown + accumulated wins)
defaulted on every launch, so closing mid-session reset the timer
and erased the win count.

Adds a sibling time_attack_session.json next to game_state.json,
atomic .tmp + rename via the existing save pattern. The new
TimeAttackSession struct carries remaining_secs, wins, and
saved_at_unix_secs (wall-clock anchor for stale-session detection).
load_time_attack_session_from_at takes an injectable now() so
tests can drive deterministic clock scenarios.

Load logic: if now_unix - saved_at_unix_secs > remaining_secs the
window expired in real time while the app was closed — return None
so the player isn't dropped into a session whose timer ran out
behind their back. Otherwise restore remaining_secs minus the
real-world elapsed delta. Handles clock-running-backwards (NTP
correction, VM clock drift) by clamping the elapsed delta at zero.

time_attack_plugin wires four new systems: load on Startup, clear
stale file when a fresh session starts (rare — only matters when
the previous session was abandoned + a new one started without
exit/relaunch), 30-second auto-save while a session is active,
delete file on natural expiry, and save on AppExit. The save file
is removed every time the session ends so a stale "session exists"
state can't pollute the next launch.

No GameState schema bump needed — the per-mode session lives in
its own file. stats / progress / achievements / settings unaffected.

8 new storage tests cover round-trip, expired-discard, time-decay,
atomic-write, missing-file, corrupt-file, delete idempotency, and
clock-backwards. 6 new plugin tests cover exit-persists,
exit-clears, auto-save-cadence, auto-save-noop-when-inactive,
new-session-clears-stale, and natural-expiry-clears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:06:35 +00:00
funman300 1a1047664b feat(engine): 14-day daily-challenge calendar in the Profile modal
The daily challenge already updated streak counters, but past
completions were invisible — the player had no in-game surface to
see streak length or the actual day-by-day record. Adds a 14-dot
horizontal calendar above the Profile modal's achievements section
with a "Current streak: N · Longest: M" caption.

Each dot represents a day in the trailing 14-day window ending
today. Today's dot gets a 2-px Balatro-yellow ring; completed days
fill STATE_SUCCESS; missed days fill BG_ELEVATED. Geometry: 14 ×
12 px + 13 × 6 px gap ≈ 246 px — fits comfortably inside the
modal's 360 px min_width even on the 800 px window minimum.

PlayerProgress gains two #[serde(default)] fields:
- daily_challenge_history: Vec<NaiveDate> capped at 365 entries
  (one year of history; older entries pushed off when the cap is
  hit). Sorted ascending, deduped on insert so same-day re-runs
  don't bloat the list.
- daily_challenge_longest_streak: u32, updated whenever streak
  exceeds the previous max.

Legacy progress.json files load to empty/0 via #[serde(default)].

solitaire_sync::merge unions histories from local + remote (sorted,
capped) and takes max(longest_streak), with a clamp to ensure
longest is never below the merged current streak — guards against
legacy payloads where longest=0 but current is mid-streak.

13 new tests across solitaire_sync (record_daily history append,
chronological order, dedupe, cap, longest update, merge union,
merge cap, max longest, clamp), solitaire_data (history append,
longest update, legacy deserialise), and solitaire_engine
(modal renders 14 dots, today marker on rightmost only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:05:54 +00:00
funman300 ba527de351 feat(engine): card-art thumbnails in the theme picker
Settings → Cosmetic's theme picker showed only the theme name. Now
each chip carries a small Ace-of-Spades + back preview pair so the
player can see what each theme looks like before switching.

A new ThemeThumbnailCache resource keys per-theme by id and stores
two Handle<Image>s (ace + back) rasterised at thumbnail resolution
via the existing rasterize_svg path. Generation runs once per
theme registration in theme_plugin; subsequent picker re-spawns
just look up the cached handles. Themes that lack one of the
preview SVGs (broken user theme) get a Handle::default() placeholder
rather than crashing — the placeholder renders as a transparent
rectangle the same size as the missing thumbnail.

The picker chip spawn loop in settings_plugin reads the cache and
renders the pair as two child sprites above the chip text. The
selected-theme chip's existing STATE_SUCCESS tint sits behind the
thumbnails; contrast stays readable.

Asset-source plumbing in assets/sources.rs and assets/mod.rs picks
up the new bytes-loading helper that the thumbnail generator uses
for embedded:// theme assets at startup time (before AssetServer is
fully initialised).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:41:20 +00:00
funman300 fe41b502ac docs: CHANGELOG + SESSION_HANDOFF refresh for v0.13.0
CHANGELOG gains a [0.13.0] section covering the third UX iteration
round on top of v0.12.0:
- Tooltip-delay slider, streak fire, score-breakdown reveal
- Card backs follow active theme
- Drag-with-keyboard
- Right-click radial menu
Plus two code-review fixes (Removed: sccache wiring, Fixed: bundled-
only font handling).

The bottom-of-file compare links thread the new tag into the
existing chain. Test count updated to 1053.

SESSION_HANDOFF gains a "Session 7 round 3" table summarising the
six commits and rolls the punch list forward — UX candidate list
exhausted again, fresh six-item list seeded for a future round
(daily-challenge calendar, theme-picker thumbnails, per-mode high
scores, in-progress auto-save for Zen/Time Attack, configurable
scoring weights, win replays). Resume prompt asks A/B/C/D about
push, smoke-test, next round, or packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:43:38 +00:00
funman300 b37f0cbec7 feat(engine): right-click radial menu for quick-drop without dragging
Power-user shortcut: hold right-click on a face-up card, a small
ring of icons appears around the cursor with one entry per legal
destination, release over an icon to fire MoveRequestEvent. Skips
the drag motion entirely while preserving the existing
RightClickHighlight tint on the actual pile markers.

A new RadialMenuPlugin owns the flow. RightClickRadialState is a
two-state enum (Idle / Active) carrying the source pile, lifted
cards, pre-computed legal destinations + their world anchors, the
ring centre, and the currently hovered icon index. Four chained
systems handle press → cursor track → release/cancel → redraw, in
that order so a single-tick test can't observe a half-state.

Mutual exclusion with the left-button mouse drag is implicit —
RadialMenuPlugin only listens to MouseButton::Right while the
existing drag pipeline only listens to Left. RightClickHighlight
co-exists at a lower z (50) than the radial overlay (Z_RADIAL_MENU
= 60), so the brief pile-marker tint reads as the same set of legal
destinations the radial offers.

Cancel paths: release the right button outside any icon, press Esc,
or press the left button. All three reset state to Idle without
dispatching a move.

Visual: a centre dot at the press location plus N icons at radius
80 px around it. For one destination the icon sits at 12 o'clock;
for N icons they spread evenly clockwise. Hovered icon scales to
1.15× and tints STATE_SUCCESS so the focused choice is unambiguous.

Twelve new tests pin the contract — five system-level (open on
press over face-up card, release over destination fires move event,
release in dead space cancels, Esc cancels, face-down doesn't
open), seven on the pure helpers (radial_anchor_for_index,
radial_hovered_index, legal_destinations_for_card). Tests inject
cursor positions through a RadialCursorOverride resource so they
work under MinimalPlugins where there's no PrimaryWindow or Camera.

help_plugin's controls reference gains a new "Mouse" section
covering double-click auto-move, right-click highlight, and the
new "Hold RMB" radial. Onboarding slide 3 is intentionally left
keyboard-only — the radial is a power-user discovery, not a
first-run teach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:40:48 +00:00
funman300 a0fc0d2605 feat(engine): keyboard-only drag-and-drop via Tab → Enter → arrows → Enter
Players can now complete an entire game without a mouse. Tab cycles
the keyboard cursor across draggable card stacks, Enter "lifts" the
focused stack into a destination-pick mode, arrow keys (or Tab)
cycle through the legal targets only, and Enter confirms the move.
Esc cancels — single-press in Lifted reverts to source-pick keeping
focus, second-press clears the source selection entirely.

A new KeyboardDragState resource models the two-mode flow without
touching SelectionState's existing source-pick contract:

  Idle                         (Tab/Enter/auto-move via SelectionState)
  Lifted {
      source_pile, count, cards,
      legal_destinations,      pre-computed at lift time via
      destination_index,       can_place_on_foundation/_tableau
  }

Mutual exclusion with mouse drag is sentinel-based: keyboard lift
sets DragState.active_touch_id = u64::MAX (KEYBOARD_DRAG_TOUCH_ID),
existing mouse handlers in input_plugin already short-circuit when
active_touch_id is Some, and the cleanup path only clears DragState
when the sentinel is present so the mouse path is never stomped.
Conversely keyboard input is suppressed when a real mouse/touch
drag is active.

The visual lift reuses the existing drag z-lift and shadow path so
the keyboard-lifted stack reads the same as a mouse-lifted one;
update_selection_highlight gains a green destination indicator on
the focused legal target while Lifted.

help_plugin's canonical hotkey list grows a "Keyboard drag"
section (Tab/Enter/Arrows/Esc/Space) and onboarding slide 3 picks
up a "Tab → Enter" entry so first-run players see the full path.

Seven new headless tests pin the contract: Tab cycles to first
draggable pile, Enter lifts the stack, arrow keys cycle only legal
destinations, Enter with destination fires MoveRequestEvent and
clears state, Esc reverts to source-pick, mouse-drag-active
suppresses keyboard input, double-Esc clears source selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:10:41 +00:00
funman300 7ed4f2cba9 feat(engine): card backs follow active theme
Themes already shipped a back.svg in their manifest but card_plugin
ignored it — face-down cards always rendered with the legacy
back_N.png picker, so swapping themes only swapped the faces. Now
the active theme's back rasterises alongside its faces and feeds
into the face-down sprite path; the legacy back_N.png picker remains
the fallback when a theme doesn't ship its own back (e.g. a
user-imported theme that only redefines faces).

theme/plugin.rs caches the active theme's back Handle<Image> in the
ActiveTheme resource on theme-load and theme-switch. card_plugin's
face-down branch reads ActiveTheme first; missing theme back →
legacy back_N.png path indexed by Settings.selected_card_back.

Settings → Cosmetic's card-back picker section gains a caption
("Active theme provides its own back") that surfaces when the
override is in effect, plus the swatch row dims to communicate the
read-only state. Settings file format unchanged — selected_card_back
still round-trips and only takes effect when the theme leaves the
back undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:08:17 +00:00
funman300 ddc8f27c82 feat(engine): UX iteration round — tooltip slider, streak fire, score breakdown
Three small UX improvements bundled because they share ui_theme token
edits.

Tooltip-delay slider in Settings → Gameplay
- Settings.tooltip_delay_secs (f32, #[serde(default)] = 0.5) tunable
  via "−" / "+" icon buttons next to a value readout. Range
  [TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS] = [0.0, 1.5] in
  TOOLTIP_DELAY_STEP_SECS (0.1) increments. "Instant" label when
  value is 0; "{n:.1} s" otherwise.
- ui_tooltip's hover-delay comparison reads from SettingsResource
  with MOTION_TOOLTIP_DELAY_SECS as the fallback when the resource
  is absent (test path). New tooltip_should_show(elapsed, delay)
  pure helper covers the boundary cases.
- adjust_tooltip_delay clamps; sanitized() carries the clamp through
  load. Five round-trip / default / legacy-deserialise tests.

Win-streak milestone fire animation
- New WinStreakMilestoneEvent { streak: u32 } fired from stats_plugin
  when win_streak_current crosses any of [3, 5, 10] (only the
  threshold crossing — not every subsequent win). HUD streak readout
  scale-pulses 1.0 → 1.20 → 1.0 over MOTION_STREAK_FLOURISH_SECS
  (0.6 s) on receipt; mirrors the foundation-flourish curve shape.
- Three threshold-crossing tests pin the firing contract.

Score-breakdown reveal on the win modal
- Win modal body replaces the single "Score: N" line with a
  per-component reveal: Base score, Time bonus (m:ss), No-undo
  bonus, Mode multiplier, separator, Total. Rows fade in over
  MOTION_SCORE_BREAKDOWN_FADE_SECS (0.12 s) staggered by
  MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s) so the math reads as
  it animates. Skipped rows: zero time bonus, undo-tainted no-undo
  bonus, multiplier == 1.0.
- Honours AnimSpeed::Instant: rows spawn fully visible, no stagger.
- New ScoreBreakdown::compute helper sources base from
  GameWonEvent.score, time bonus from
  solitaire_core::scoring::compute_time_bonus, no-undo from a +25
  constant when undo_count == 0, mode multiplier from GameMode (Zen
  zeros the total). 9 new tests cover the math and the reveal
  cadence.

Test count net: +25 across the workspace (1007 → 1031).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:34:53 +00:00
funman300 13dd44bd1b chore: remove project-level sccache rustc-wrapper config
Code-review feedback: sccache shouldn't be a per-project build
dependency. Cargo's incremental cache already covers what sccache
offers for a single project, and forcing rustc-wrapper = "sccache"
project-wide means every contributor has to install sccache or
prepend RUSTC_WRAPPER= to bypass the wrapper.

.cargo/config.toml only existed to wire sccache and pin SCCACHE_DIR
to a project-local cache. Removing the file entirely so plain
`cargo build` works without any extra setup. The .cargo directory
is empty after the deletion and removed too. .gitignore's
/.sccache-cache line is harmless cruft and stays — players who
already have a populated .sccache-cache directory keep it ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:34:20 +00:00
funman300 17f9b518f1 fix(engine): bundle fonts only and drop system-font fallback
Code-review feedback: the SVG rasteriser mixed three font-resolution
layers (load_system_fonts + bundled FiraMono + lenient resolver
appending CSS generics), which made card text rendering depend on
which fonts the host machine happened to have. The Bevy UI face
loaded separately at runtime via AssetServer. Picking option (a)
from the review and applying it consistently: bundle FiraMono via
include_bytes!() in BOTH layers, no system fallback anywhere.

solitaire_engine/src/font_plugin.rs now embeds main.ttf at compile
time and registers it with Assets<Font>. A parse failure aborts
with "bundled FiraMono failed to parse — binary is corrupt"; the
MinimalPlugins early-return stays as a "this plugin doesn't apply
in headless tests" check (consumers query Option<Res<FontResource>>
and degrade cleanly), not a production fallback.

solitaire_engine/src/assets/svg_loader.rs drops load_system_fonts
entirely, drops the lenient_font_resolver, and drops the five
set_*_family pins. The new bundled_font_resolver ignores the SVG's
font-family request and always returns the single bundled face —
the bundled card SVGs reference Arial / Bitstream Vera Sans by name
and we deliberately don't ship those, so routing every query to
FiraMono keeps rasterisation deterministic. shared_fontdb asserts
the embedded bytes parsed.

The two layers now embed the same path
(assets/fonts/main.ttf) independently, so they can't drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:33:54 +00:00
funman300 61d891fb76 docs: CHANGELOG + SESSION_HANDOFF refresh for v0.12.0
CHANGELOG gains a [0.12.0] section covering the second UX iteration
round on top of v0.11.0:
- Foundation completion flourish
- Drag-cancel return tween
- Focus ring breathing
- First-win achievement onboarding toast
- Mode Launcher digit shortcuts
- Card aspect-ratio fix (1.4 → 1.4523)
- Plus the README and CHANGELOG-add docs that rode along

The bottom-of-file compare links thread the new tag into the
existing chain (Unreleased → 0.12.0 → 0.11.0 → ...). Test count
updated to 1007.

SESSION_HANDOFF now distinguishes session 7 round 1 (v0.11.0,
morning) from round 2 (v0.12.0, afternoon) — keeping the audit
trail readable instead of conflating them. The release-prep punch
list collapses to the three tag/push/packaging items; the UX
iteration list opens with six fresh candidates for whoever picks
the next round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:08:46 +00:00
funman300 7dba772e67 feat(engine): digit shortcuts (1-5) launch modes from inside the Mode Launcher
Pressing M already opens the Home modal (which is the Mode Launcher
post-v0.11) and Tab cycles focus through the cards. The remaining
gap was direct keyboard activation of a specific mode — players had
to tab-and-enter or click. A new modal-scoped digit handler closes
that gap:

  1 → Classic (NewGameRequestEvent)
  2 → Daily Challenge (StartDailyChallengeRequestEvent)
  3 → Zen (StartZenRequestEvent, gated at level 5)
  4 → Challenge (StartChallengeRequestEvent, gated at level 5)
  5 → Time Attack (StartTimeAttackRequestEvent, gated at level 5)

handle_home_digit_keys runs only when HomeScreen exists and short-
circuits otherwise — the digit keys can't accidentally launch a
mode mid-game. Locked modes (level < CHALLENGE_UNLOCK_LEVEL) silent-
no-op rather than firing a toast, mirroring the click-on-locked-card
behaviour without the InfoToastEvent (the click path's toast is the
authoritative "level too low" surface).

The HomePlugin Update tuple is now .chain()ed because the Bevy 0.18
parallel scheduler would otherwise let handle_home_card_click,
handle_home_cancel_button, and the new digit handler all queue a
HomeScreen despawn concurrently — the second buffer apply panics
on the already-despawned entity.

help_plugin gains a new "Mode Launcher (M)" section with the digit
rows and a level-5 unlock note. onboarding's slide-3 hotkey table
gets one new line ("M — Open Mode Launcher (then 1-5 to pick)") so
first-run players see the full path. The help-modal canonical list
now mirrors the onboarding teach.

Four new headless tests pin the contract: Digit1 launches Classic
and closes the modal; Digit3 at level 0 is a no-op (modal stays
open); Digit3 at unlock level launches Zen and closes; digit keys
outside the modal fire no events at all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:01:41 +00:00
funman300 ca5788f714 feat(engine): one-shot achievement-onboarding toast on first win
After the player's very first win the engine now writes
"First win! Press A to see your achievements." via InfoToastEvent,
then flips a persisted Settings.shown_achievement_onboarding flag so
the cue never re-fires. Mentions the A hotkey by name so the toast
is actionable on its own.

The toast path runs after StatsUpdate so games_won has been
incremented to 1 when the system reads it; .after(GameMutation)
keeps the post-move state visible. Three guards: first win only,
flag was false, GameWonEvent fired this tick.

Persistence mirrors onboarding_plugin's complete_onboarding pattern:
save via save_settings_to with the existing
SettingsStoragePath/Option<&PathBuf> graceful-fallback shape.
Atomic .tmp+rename writes are unchanged.

Settings gains a single bool field with #[serde(default)] so legacy
settings.json files deserialize cleanly to false. The field is
local-only by design — it's about UI teaching for THIS device, not
progression — so SyncPayload and merge logic are untouched.

Seven new tests pin the contract: default value is false, field
round-trips through save/load, legacy JSON without the field
deserializes to false, first win fires the toast and flips the
flag, subsequent wins are silent, the fifth win on a synced device
is silent (won't fire when games_won has been bumped via sync), and
no win event means no toast.

Toast duration is the existing animation_plugin
QUEUED_TOAST_SECS = 2.5 s — InfoToastEvent is a tuple struct with
no duration parameter, so the agent kept the existing event shape
rather than expanding it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:01:18 +00:00
funman300 9887343d8b feat(engine): focus ring breathes at 1.4 s — gentle pulse instead of flat
The keyboard focus ring rendered as a static yellow outline. A new
pulse_focus_overlay system modulates the overlay's BorderColor alpha
with a sin curve over MOTION_FOCUS_PULSE_SECS (1.4 s), breathing the
visible alpha between 0.65× and 1.0× of FOCUS_RING's native value.
The motion is slow enough to read as a calm heartbeat in peripheral
vision rather than a competing animation, and a focus change still
draws the eye because the ring re-attaches at full brightness on
the next pulse cycle.

The pulse honours AnimSpeed::Instant by reading SettingsResource
and skipping the modulation entirely (static FOCUS_RING colour) for
reduced-motion users — matches the convention used elsewhere for
animation gating.

A pure focus_ring_pulse_factor(elapsed_secs) helper is unit-tested
for the curve shape: 0.825 at t=0 (mid-point), 1.0 at the
quarter-period peak, 0.65 at the three-quarter-period trough, and a
sweep across two full periods stays within the [0.65, 1.0] range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:40 +00:00
funman300 525fe0fe76 feat(engine): drag-cancel return tween — smooth ease-out instead of shake
Illegal drops previously snapped each dragged card to its origin
slot and ran a horizontal ShakeAnim wiggle for negative feedback —
which read as punitive on every misclick. The rejection now plays
a 150 ms quintic ease-out glide from the drop location back to the
resting slot. The audio cue (card_invalid.wav) still fires so the
player gets clear "no" feedback; the visual is just gentler.

Both rejection paths in input_plugin (mouse end_drag and touch
end_drag) construct a CardAnimation::slide(drag_pos → target_pos)
with MotionCurve::Responsive — the curve module's own docs
recommend Responsive specifically for invalid snap-back because its
zero overshoot reads forgiving rather than jittery.

card_plugin's update_card_entity gates its snap path on
CardAnimation absence so the StateChangedEvent that follows a
rejection no longer fights the in-flight tween. Mirrors how
resize_cards_in_place already drops in-flight tweens during a
window resize.

ShakeAnim itself stays in feedback_anim_plugin — the right-click
invalid-target and double-click in-place rejection paths still use
it because there's no movement to interpolate, just a "no" wiggle.
Only the drag-rejection path swaps to the smooth tween.

Six new rejection-tween tests pin the contract: CardAnimation is
inserted on every dragged card, start/end positions and z values
match the drag-to-resting transition, duration matches the new
MOTION_DRAG_REJECT_SECS token, and the curve is Responsive. The
two legacy ShakeAnim drag-rejection tests are removed since their
contract is intentionally inverted by this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:34:12 +00:00
funman300 69ce9afab9 feat(engine): foundation completion flourish — King-on-foundation celebration
Now that foundations are unlocked and "completing" one is a real
moment (rather than a foregone conclusion based on suit assignment),
each Ace-through-King run gets its own small celebration when the
King lands.

Three layers fire on a single FoundationCompletedEvent emitted by
game_plugin's handle_move when a successful move leaves a
PileType::Foundation pile holding 13 cards:

1. King card scale-pulse via a new FoundationFlourish component.
   Triangular curve 1.0 → 1.15 → 1.0 over MOTION_FOUNDATION_FLOURISH
   _SECS (0.4s) — same shape as the existing ScorePulse so the feel
   matches.
2. Pile-marker tint flourish via FoundationMarkerFlourish — the
   foundation marker's sprite colour lerps to STATE_SUCCESS for the
   first half of the duration then fades back. Reuses the existing
   success-signal palette; no new colour token.
3. Audio cue: foundation_complete.wav, a synthesised C6→E6→G6 triad
   with 2nd-harmonic warmth and AR decay (~240 ms). Sits an octave
   above win_fanfare's root so the layered fourth-completion + win
   cascade reads cleanly. Generated via solitaire_assetgen's
   foundation_complete() function and embedded via include_bytes!().

The visual systems run .after(GameMutation) so the post-move pile
state is visible when the King is identified. Both flourish
components remove themselves once elapsed time exceeds duration —
no animation queue or scheduler integration needed.

Pure foundation_flourish_scale(elapsed, duration) helper is
unit-tested for the curve, edge clamps, and zero-duration safety.
Three integration tests on the firing logic verify the event fires
exactly once when a King completes a foundation, doesn't fire for
non-foundation moves, and doesn't fire when the foundation is at 12
cards.

The fourth completion still co-occurs with the win cascade — the
two layer cleanly because the flourish's scale is on the King card
sprite while the cascade is a screen-shake + per-card rotation, and
the foundation_complete ping is a higher octave than the win
fanfare's root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:19:50 +00:00
funman300 13aa0fd833 fix(engine): match CARD_ASPECT to hayeah SVG dimensions (1.4 → 1.4523)
Cards rendered ~3.6 % squashed vertically because layout.rs assumed a
1.4 height/width ratio while the bundled hayeah/playing-cards-assets
SVGs are natively 167.087 × 242.667 (= 1.4523). The mismatch meant
every face was scaled to fit a too-short box; pip arrangements and
court-card art read slightly compressed.

Bumps CARD_ASPECT to 1.4523 to match the SVG. The vertical-budget
math in compute_layout (the height-based card_width candidate) uses
CARD_ASPECT algebraically, so the tableau-fits-on-screen check
adapts automatically — slightly smaller cards on aspect-ratio-tight
windows, no visible regression on standard 16:9.

Doc comments referencing the old 1.4 literal updated to point at
CARD_ASPECT instead so this can't drift again.

All 982 tests pass — the existing layout/test sentinel
(card_size.y / card_size.x - CARD_ASPECT) used the constant by name
and adapted for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:03:12 +00:00
funman300 9f095c4039 docs: add CHANGELOG.md covering v0.9.0 through v0.11.0
The CHANGELOG didn't exist; v0.11.0 felt too meaty to land without one
and starting from v0.10.0+ would have made the file feel rootless. The
format follows Keep a Changelog 1.1.0 with the standard Added /
Changed / Fixed / Removed sections per release plus a Migration block
when relevant.

v0.11.0 (2026-05-02) — full coverage of the card-theme system, HUD
overhaul, drag-feel polish (drop overlay, drop shadows, stock count
badge, unlocked foundations), the FiraMono fontdb fix, and the
schema-version bump that invalidates pre-v2 game_state.json saves on
launch. 982 tests, zero clippy.

v0.10.0 (2026-04-29) — PNG art pipeline, Bevy 0.15 → 0.18 migration,
kira 0.9 → 0.12 migration, Rust edition 2024 + MSRV 1.95, custom
font, JWT-secret-at-startup fix, SmartIpKeyExtractor, MessageReader
touch-input fix.

v0.9.0 (2026-04-28) — initial public-tagged release: workspace
structure, modal scaffold, design-token system, four-tier HUD,
progression, sync server, splash, focus rings, tooltips,
achievement integration tests, all the foundation work that
predates the card-theme rewrite.

README gains a Changelog section linking to the new file.

The bottom-of-file compare links use the corrected
github.com/funman300/Rusty_Solitaire URL so the rendered page on
GitHub auto-generates the correct diff views once the tags are
pushed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:59:13 +00:00
funman300 d8c70341f4 docs: refresh README for v0.11.0 — card themes, HUD overhaul, drag feel
The README hadn't been touched since before the card-theme system
landed and was missing every UX feel improvement from v0.11.0.
Anyone discovering the repo on the GitHub release page would have
seen pre-theme copy.

Features list now covers card themes (bundled default + user
zip-installable), the modern HUD (reserved band + action-bar
auto-fade), and the four drag-feel improvements (drop highlights,
drop shadows, stock count badge, unlocked foundations).

Controls table fixes three real discrepancies: Undo is U not
Z/Ctrl+Z (the README inverted the bindings), Help is F1 not H, and
Z actually toggles Zen mode. Adds the previously undocumented Tab /
Shift+Tab focus cycle, Enter activation, F11 fullscreen, double-
click to auto-move, and the G forfeit shortcut. Notes that every
action is also a visible UI button so the keyboard list is
optional-accelerator only — matches the project's UI-first rule.

Adds a small Card Themes section explaining how to install a theme
(drop a directory or zip-import via Settings → Cosmetic) without
diving into SVG technicals.

Test count updated to 982 to reflect v0.11.0 baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:56:24 +00:00
funman300 063269c70e docs: update repo URL references to corrected Rusty_Solitaire spelling
The GitHub repo was renamed from Rusty_Solitare to Rusty_Solitaire
(adding the missing 'i'). The local origin remote has been updated
via `git remote set-url`; this commit updates the three doc
references that hardcoded the old URL.

SESSION_HANDOFF.md's "Canonical remote" section now names the new
URL and explains the rename for future readers, including the note
that local clone directories may still be named Rusty_Solitare —
that's a local-only name and works fine, only the GitHub repo URL
changed.

docs/SESSION_HANDOFF.md (older snapshot, unchanged otherwise) gets
its single URL line corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:36:06 +00:00
funman300 b126df82b2 docs: refresh SESSION_HANDOFF for session 7 UX-iteration round complete
Session 6 closed with a four-item UX punch list (unlock foundations,
drop shadows, drop-target highlights, stock badge). All four shipped
in session 7, plus an unrelated font-fallback fix surfaced by a
second-machine smoke test that landed before the UX work.

Refreshes the doc to reflect:
- HEAD: 655dfde, 3 commits ahead of origin
- 982 tests pass (was 962)
- Session 7 changelog table summarising the five commits
- UX punch-list entirely closed; release-prep items still on the
  table but un-deferred (player gets a directional choice next session)
- New "next-round candidates" UX list (animated focus ring,
  achievement onboarding, mode-switch keyboard shortcut, aspect-ratio
  fidelity, foundation completion flourish, drag-cancel tween)
- Resume prompt asks A/B/C: tag v0.11.0, README/CHANGELOG first, or
  start a new UX round

Length 120 → 109 (-11) by trimming the spent priority list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:33:42 +00:00
funman300 655dfde736 feat(engine): stock-pile remaining-count badge
Players were recycling the stock blind — there's no in-world
indicator of how many cards are left before the recycle. A small
"·N" chip now sits at the top-right corner of the stock pile,
showing the remaining count.

The badge is a top-level world entity whose Transform.translation is
recomputed each tick from the live LayoutResource (so window resizes
and theme switches don't strand it), parented to neither the
PileMarker nor any card. update_stock_count_badge spawns the entity
on the first frame, then on every subsequent frame reads the stock
pile's card count, writes the formatted text into the child Text2d,
and toggles Visibility::Hidden when the count drops to zero — the
same state where StockEmptyLabel's existing ↺ icon takes over, so
the two never co-render.

Z_STOCK_BADGE = 30 sits above stock cards (z ≈ 1) and below
Z_DROP_OVERLAY = 50, so the badge stays visible during normal play
but green drop-target washes still cover it while a card is being
dragged. Card drop shadows live at negative local z relative to
each card and don't compete with the badge plane.

Tokens (STOCK_BADGE_BG, STOCK_BADGE_FG, Z_STOCK_BADGE) were already
present in ui_theme from prior work; this commit only wires them up.
The chip itself is 28×16 px, rendered with TYPE_CAPTION text in
ACCENT_PRIMARY against BG_ELEVATED_HI.

Four new tests pin the contract: badge shows "·24" on a fresh deal,
hides when the stock empties, updates as the count drops, and the
stock_card_count helper reports 0 when the pile is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:31:15 +00:00
funman300 f712b89fe4 feat(engine): drop shadows on cards with lifted state during drag
Cards previously read as flat stickers on the felt — no separation
cue, no sense the play surface had any depth. Each CardEntity now
spawns a CardShadow child sprite: neutral black at 25 % alpha, sized
to card_size + 4 px halo, offset (2, -3) and rendered at local z
-0.05 so it sits behind its card.

Cards in the active drag set switch to a lifted shadow: alpha 40 %,
offset (4, -6), padding (8, 8). update_card_shadows_on_drag runs
every Update and snaps each shadow to the right state based on
DragState membership — no lerp, no animation cost. The pure
card_shadow_params(is_dragged) helper is unit-tested for the four
parameter values.

resize_cards_in_place gains a third query for shadows so the
in-place resize keeps shadows cheap (no Sprite regeneration); the
shadow's current alpha is read to preserve idle vs lifted padding
across a resize. update_card_entity's despawn_related call is
followed by a fresh add_card_shadow_child so the shadow re-attaches
when the card is repainted (face flip, settings change, theme
swap). The pre-existing bulk drag-shadow under the whole lifted
stack is untouched — per-card shadows complement it.

All shadow values flow through eight new ui_theme tokens
(CARD_SHADOW_COLOR, alphas, offsets, paddings, local z) so the
visual is tunable in one place. Color is neutral black so the
shadows don't conflict with color-blind mode's red/blue suit tints.

Four new tests pin the contract: shadow params for idle and drag
states, every CardEntity spawns with exactly one CardShadow child,
and dragging shifts only the dragged shadow's offset while leaving
unrelated shadows on the idle offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:21:28 +00:00
funman300 f6c916641a feat(engine): visible drop-target overlay during drag
The existing update_drop_highlights system tinted PileMarker sprites
green for valid drops, but the marker is a card-sized rectangle that
sits behind the stack. Once a tableau column had any cards on it the
marker was occluded and the highlight effectively invisible — the
handoff's "drops feel guess-y because there's no preview" point.

A new update_drop_target_overlays system spawns an overlay above every
legal target during drag: a soft DROP_TARGET_FILL rectangle sized to
the pile's actual visible footprint (full fanned column for tableaux,
card-sized for foundations and empty tableaux) plus four thin
DROP_TARGET_OUTLINE edges forming a 3 px border. Z_DROP_OVERLAY = 50
sits above static cards (z ~1) but below the dragged stack (DRAG_Z =
500), so the overlay never occludes the card the player is holding.

The valid-target enumeration mirrors update_drop_highlights exactly so
the rules can't drift, and pile geometry mirrors input_plugin's
pile_drop_rect. The original marker-tint system is untouched; it still
does its job for empty-pile placeholders. The overlay layer is purely
additive — running alongside, not replacing.

Token values reuse the existing STATE_SUCCESS hue (#4ADE80) at 10%
fill / 75% outline so the overlay green matches the rest of the
success-signal palette (foundation completion, sync OK, etc.).

Three headless tests pin the contract: overlay spawns for valid
tableau drops, doesn't spawn for invalid destinations, and despawns
the moment the drag ends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:33:22 +00:00
funman300 95df5421c9 feat(core): unlock foundations — Foundation(u8) slots, suit derived from contents
Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.

Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.

can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.

next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.

The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.

Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.

9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
  for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
  distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
  empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
  is unaffected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:17:17 +00:00
funman300 fdb6c2ecfe fix(engine): bundle FiraMono into SVG fontdb as last-resort fallback
The hayeah card SVGs reference Bitstream Vera Sans and Arial by name.
The lenient FontResolver from efa063f appends Family::SansSerif and
Family::Serif so unmatched named families fall through to whatever
the system serves under those CSS generics — which works on machines
with a normal fontconfig setup, and silently fails on minimal Linux
installs, fresh Wayland sessions, or chroots where the generic
aliases don't resolve to anything either. The visible symptom on the
player's second machine was "card font didn't carry over": rank and
suit glyphs vanished from the cards because every lookup path hit a
None.

shared_fontdb now also include_bytes!()s the bundled
assets/fonts/main.ttf into the fontdb after load_system_fonts, and
pins each CSS generic (sans-serif, serif, monospace, cursive,
fantasy) to "Fira Mono". Named-family lookups still prefer the
system db first when those families exist, so machines with a normal
font setup behave identically; only when SansSerif/Serif fall through
does the resolver land on FiraMono — guaranteed present because it's
embedded in the binary.

The bundled font is ~170 KB; the binary already include_bytes!()s the
six audio WAVs and the embedded card-theme SVGs, so this fits the
existing self-contained-binary policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:41:35 +00:00
funman300 9a3d7f3876 docs: refresh SESSION_HANDOFF for session 6 + UX-iteration direction
Captures today's six commits (theme loader fix, exit-warn silence, two
font-warn rounds, HUD band, action fade), updates HEAD/test counts,
records that the player redirected from "cut v0.11.0 / package" to
"keep iterating on UX," and lists the new four-item UX punch list
(unlock foundations, drop shadows, drop highlighting, stock badge).

Resume prompt is rewritten so a fresh agent on a different machine
picks up cleanly: notes GitHub is the canonical remote (Gitea drift
caused commits to silently miss the alex machine earlier in session),
flags that the in-progress save format will invalidate when (1)
lands, and explicitly defers the release-prep items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:24:09 +00:00
funman300 c4970b16ea feat(engine): auto-fade HUD action buttons when cursor leaves the band
Player request: the Menu / Undo / Pause / Help / Modes / New Game
buttons stay visible during play even when the player isn't looking
at them. Fade them out when the cursor is in the play area, fade
back in when it returns to the top of the window.

Implementation mirrors video-player auto-hide UX:
- HudActionFade resource holds (alpha, target). Default both 1.0 so
  the bar starts visible on first launch.
- update_action_fade reads cursor.y each frame, sets target to 1.0
  when the cursor is in the top reveal zone (HUD_BAND_HEIGHT + 32 px)
  or off-window (keyboard navigation), 0.0 otherwise. Lerps alpha
  toward target at 6/sec ≈ 167 ms per full transition.
- apply_action_fade overrides BackgroundColor + child TextColor on
  every ActionButton. Runs in Last so a hover-state change in the
  same frame doesn't blip back to opaque mid-fade.

No interactivity guard needed: hover requires the cursor to be on a
button, and a faded button is geometrically out of reach (cursor must
re-enter the reveal zone, which is exactly the trigger that fades
the bar back in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:08:39 +00:00
funman300 2c72e1fc87 feat(engine): reserve top band for HUD so it stops crowding the cards
Player report: the action button bar (Menu / Undo / Pause / Help /
Modes / New Game) and Score / Moves / Timer text were sharing the
same vertical band as the stock + foundation row, with no visual
separation. The HUD read as part of the play surface.

Two-part fix:

1. layout.rs reserves HUD_BAND_HEIGHT (64 px) at the top of the
   window. Card-grid math takes that off the available vertical
   budget so cards still fit; top_y shifts down by the same amount.
   New layout test pins the reservation. Existing
   worst_case_tableau_fits_vertically tests verify the height-budget
   arithmetic still holds.

2. hud_plugin.rs spawns a translucent purple band (BG_HUD_BAND, new
   token in ui_theme.rs at the BG_BASE hue with 0.70 alpha) filling
   that reserved zone. Z-index sits one rung below Z_HUD so action
   buttons paint on top while the band reads as their container. The
   band's bottom edge lines up with the top edge of the highest
   playable card, so the buttons feel anchored to a "tools strip"
   rather than floating in the play area.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:57:51 +00:00
funman300 efa063fb8f fix(engine): fall through to system default font on unmatched family
CI / Test & Lint (push) Failing after 5s
CI / Release Build (push) Has been skipped
Replaces the previous LogPlugin-filter approach (which suppresses the
warn message) with a fix at the source: a custom usvg FontResolver
that appends `sans-serif` and `serif` to every family-lookup query.

usvg's default selector queries fontdb with [SVG-requested families,
Serif] and emits `log::warn!("No match for '{family}'")` when the
query returns None. On systems without the SVG's named family (Arial
on Linux, etc.), every text node logs a warn even though the system
has perfectly good fonts available — the warn is a false negative
because fontdb's named-family lookup is exact-match only.

The new resolver appends both `Family::SansSerif` and `Family::Serif`
to the query, both resolved by fontdb (via fontconfig on Linux or
built-in defaults elsewhere) to whatever the system has installed.
The query now finds *some* face on any reasonably configured machine,
so `id.is_none()` is never true and the warn branch never fires. The
visible behaviour: SVGs that request unavailable named families now
silently use the system's default sans-serif font.

Reverts the LogPlugin filter from main.rs — silencing warns at the
log level was the wrong layer; fixing the lookup is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:41:02 +00:00
funman300 78cf30e906 fix(engine): silence usvg font-substitution warn spam
CI / Test & Lint (push) Failing after 6s
CI / Release Build (push) Has been skipped
The bundled hayeah card SVGs declare font-family="Arial" for rank/suit
text. usvg matches family names exactly, so on systems without Arial
installed (every Linux distro by default) every text node bridged a
log::warn! into our tracing output — 50+ lines per launch.

Two-part fix:
- svg_loader now populates a process-wide fontdb with system fonts
  (lazy via OnceLock) so substitution actually has faces to fall
  through to. usvg::Options::default() ships an empty fontdb, which
  meant text glyphs had nothing to fall back on at all.
- LogPlugin extends DEFAULT_FILTER with usvg::text=error so the
  residual "no match" warns drop. The substitution itself works; the
  message is purely informational because Arial truly isn't installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:22:32 +00:00
funman300 9a9026e33a fix(engine): silence benign UnsupportedPlatform warn on exit
CI / Test & Lint (push) Failing after 4s
CI / Release Build (push) Has been skipped
push_on_exit logged every error including LocalOnlyProvider's expected
UnsupportedPlatform response, producing a misleading "sync push on exit
failed" warning on every shutdown in local-only mode. Mirror the pull
path: treat UnsupportedPlatform as silent no-op, warn only on real
errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:04:56 +00:00
funman300 ab1d098877 fix(engine): use resolve_embed for sibling theme assets
CI / Test & Lint (push) Failing after 5s
CI / Release Build (push) Has been skipped
`AssetPath::resolve` concatenates, so manifest-relative SVG paths
ended up under `…/theme.ron/<name>.svg` and the asset server
reported all 53 references missing. `resolve_embed` is the RFC 1808
sibling-resolution method that strips the base path's last segment
first, giving the intended `…/<name>.svg`. Default theme now loads
cleanly from the embedded:// source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:21:03 +00:00
funman300 160637d1c8 docs: update remote URL reference to github.com/funman300/Rusty_Solitare
CI / Test & Lint (push) Failing after 8s
CI / Release Build (push) Has been skipped
Mirrors the move of the canonical remote from git.aleshym.co to
GitHub. The git remote itself was switched via 'git remote set-url
origin'; this updates the one stale URL in docs/SESSION_HANDOFF.md
that named the old host.
2026-05-01 17:11:55 +00:00
funman300 43f13c615e chore: workspace cleanup after card-theme phase landings
Drops dead deps and stale doc content carried over from the pre-MIT
art swap.

Cargo.toml manifests:
- solitaire_core no longer depends on chrono (no source references it
  since the original sync-payload timestamps moved to solitaire_data).
- solitaire_sync no longer depends on serde_json (the sync types use
  serde-derive with whatever serializer the caller picks; the old
  json-specific helpers were removed earlier).

Cargo.lock pruned by `cargo build` to drop the now-untransitively-
referenced versions.

CREDITS.md redistribution clause: "LGPL and OFL notices" tightened to
"MIT (project + hayeah card art) and OFL (FiraMono)" since the LGPL
art is gone.

SESSION_HANDOFF.md:
- HEAD bumped to 924a1e2; test count to 960; 9 ignored.
- Punch list rewritten — the xCards-URL line is obsolete (we did the
  swap), v0.1.0 tag exists locally, and player smoke-test is the
  current top item.
- New "Card-theme system (CARD_PLAN.md, fully shipped)" section
  summarises the seven-phase end-to-end flow so a future session has
  the integration map without re-reading the plan.
- Optional list gains the SVG-vs-layout aspect-ratio note as a
  cosmetic-only follow-up.

Removed the locked worktree at .claude/worktrees/agent-aa55a94d18c669d70
left behind by a prior Claude session.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 16:41:53 +00:00
funman300 924a1e2af7 feat(engine): card-theme picker in Settings → Cosmetic
CI / Test & Lint (push) Failing after 5s
CI / Release Build (push) Has been skipped
Wires the runtime theme system (CARD_PLAN.md phases 1–7) into the
visible Settings UI so a player can switch between every theme
discovered by `ThemeRegistry` without restarting.

solitaire_data/src/settings.rs
  Settings gains `selected_theme_id: String` (default "default"),
  guarded by `#[serde(default = "default_theme_id")]` so existing
  settings.json files deserialize cleanly.

solitaire_engine/src/settings_plugin.rs
  - SettingsButton::SelectTheme(String) variant + focus order 85.
  - sync_settings_panel_visibility now reads
    Option<Res<ThemeRegistry>>, snapshots id+display_name pairs, and
    threads them into spawn_settings_panel. When the registry is
    absent (tests under MinimalPlugins) the picker silently skips —
    every existing test continues to pass unchanged.
  - theme_picker_row helper: like picker_row but keyed by String
    rather than usize, with chips wide enough for theme display
    names. Attaches the canonical tooltip ("Choose card-face
    artwork. Imported themes appear here.") and the FocusRow marker
    so Left/Right arrows cycle within the row.
  - Click handler updates settings.selected_theme_id, persists, and
    fires SettingsChangedEvent — same shape as every other picker.

solitaire_engine/src/theme/plugin.rs
  - load_default_theme renamed to load_initial_theme; reads
    SettingsResource on Startup and seeds ActiveTheme from
    settings.selected_theme_id (falling back to embedded default).
  - react_to_settings_theme_change watches SettingsChangedEvent,
    no-ops when the active theme already matches, and otherwise
    swaps ActiveTheme — the existing
    sync_card_image_set_with_active_theme system then refreshes
    every card sprite on the next AssetEvent::LoadedWithDependencies.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 16:24:24 +00:00
funman300 a6b8348332 docs: refresh README + ARCHITECTURE for hayeah art + theme system
CI / Test & Lint (push) Failing after 7s
CI / Release Build (push) Has been skipped
Updates the prose mentions of card-face provenance to point at
hayeah/playing-cards-assets (MIT) instead of xCards (LGPL-3.0), in
sync with the upstream art swap (b98cb8a).

ARCHITECTURE.md decision log gains two new rows: the licence-driven
art swap and the runtime SVG card-theme system landed across
CARD_PLAN.md phases 1–7. README.md credits paragraph rewritten to
match the new attribution.
2026-05-01 16:08:14 +00:00
funman300 b98cb8a99f feat(assets): swap card art to hayeah/playing-cards-assets (MIT)
Replaces the previous xCards-derived card faces (LGPL-3.0) with
hayeah/playing-cards-assets, which itself derives from the
public-domain vector-playing-cards Google Code project. The whole
package is MIT now — see CREDITS.md for the new attribution table
and the simpler license summary.

solitaire_engine/assets/themes/default/
  52 face SVGs (clubs/diamonds/hearts/spades × ace/2-10/jack/queen/
  king) — copied from hayeah, renamed to the canonical
  `{suit}_{rank}.svg` form `CardKey::manifest_name` produces. The
  bundled default theme manifest references each by the same name.
  back.svg — original midnight-purple-themed card back, hand-written
  to match the project's design tokens (BG_BASE / BG_ELEVATED /
  ACCENT_PRIMARY / ACCENT_SECONDARY). MIT, original work.

assets/cards/faces/{RANK}{SUIT}.png
  52 PNGs regenerated from the new SVGs at 750-tall via resvg 0.47.
  These remain the legacy backwards-compat path that
  `card_plugin::load_card_images` reads at startup; once the runtime
  theme system finishes loading the embedded default theme, the
  CardImageSet's face handles are overwritten with the SVG-rendered
  variants and these PNGs become moot. Keeping them in place avoids
  a brief blank-card flash before the async theme load completes.

solitaire_engine/src/assets/sources.rs
  embed_default_svg!() macro + DEFAULT_THEME_SVGS table that bundles
  every face + the back into the binary at compile time via
  include_bytes!. populate_embedded_default_theme now iterates the
  table so the EmbeddedAssetRegistry is populated under the same
  asset paths the manifest references.

CREDITS.md
  License summary collapses from MIT + LGPL-3.0 + OFL-1.1 to MIT +
  OFL-1.1 (the OFL still applies to FiraMono). The hayeah upstream
  URL replaces the previously-blank xCards entry.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 16:06:58 +00:00
funman300 7b59e70192 feat(engine): theme registry + discovery (Card theme phase 6)
CI / Test & Lint (push) Failing after 8s
CI / Release Build (push) Has been skipped
Implements Phase 6 of CARD_PLAN.md — discovers every available card
theme on startup so the future picker UI can list them.

solitaire_engine/src/theme/registry.rs
  ThemeEntry { id, display_name, manifest_url, meta }
  ThemeRegistry — Resource holding the entries; provides
    find(id), iter(), len(), is_empty().
  ThemeRegistryPlugin — Startup system that scans
    user_theme_dir() and populates the registry.
  build_registry(user_dir) — pure helper; takes the dir as a
    parameter so tests use tempfile::tempdir() without touching
    the global OnceLock-based user-theme path.
  refresh_registry(&mut, user_dir) — replaces in-place; called
    after a successful import_theme so a freshly-imported theme
    appears in the picker without an app restart.

The bundled default entry is always inserted (id "default", served
from DEFAULT_THEME_MANIFEST_URL) so the picker has at least one
option even when no user themes exist.

Discovery is best-effort: a directory whose theme.ron is missing,
malformed, or fails ThemeMeta::validate is silently skipped — broken
themes don't poison the registry. Only the meta block is parsed
(via a derive(Deserialize) struct that ignores other manifest
fields), which keeps startup quick even with dozens of themes
installed.

Wired into solitaire_app/main.rs after ThemePlugin so the asset
sources are registered before discovery scans for theme.ron files.

10 new tests covering: empty user dir, nonexistent user dir, valid
user theme registers, full-manifest tolerance via meta-only parser,
malformed theme.ron skipped, invalid-meta theme skipped, directory
without theme.ron ignored, find() returns None for unknown id,
refresh_registry replaces stale entries, default-entry URL matches
the embedded constant.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 06:04:34 +00:00
funman300 7f477b4ad8 feat(engine): ThemePlugin + ActiveTheme integration (Card theme phase 4)
Implements Phase 4 of CARD_PLAN.md — the runtime hook that loads the
default theme on startup and refreshes the card-rendering pipeline
whenever the active theme changes.

solitaire_engine/src/theme/plugin.rs
  ThemePlugin
    init_asset::<CardTheme>, register_asset_loader for SvgLoader and
    CardThemeLoader, Startup load_default_theme, and Update
    sync_card_image_set_with_active_theme.
  ActiveTheme(Handle<CardTheme>)
    Resource pointing at the currently-loaded theme.
  set_theme(commands, asset_server, theme_id)
    Public API for switching themes — formats the URL as
    `themes://<theme_id>/theme.ron` and updates the resource.

Integration approach: rather than refactor every `card_plugin.rs`
spawn site to read from `Assets<CardTheme>` directly, the sync system
writes the theme's face/back image handles into the existing
`CardImageSet` resource on `AssetEvent::LoadedWithDependencies` /
`Modified`, then fires `StateChangedEvent`. The existing
`sync_cards_on_change` pipeline rebuilds card sprites from the new
handles on the next tick — observable behaviour matches the plan's
intent (theme switches propagate immediately) while keeping
card_plugin's 1929-line surface area untouched.

Theme.back is mapped onto `CardImageSet.backs[0]` (the default-back
slot xCards previously occupied); `backs[1..=4]` are the
asset-generator patterns and remain user-selectable independent of
the active theme.

Added to solitaire_app/main.rs as `add_plugins(ThemePlugin)` after
`AssetSourcesPlugin` so the asset sources are registered before the
default-theme load is dispatched.

6 new tests covering suit/rank index mapping (matching the
`card_plugin` doc-commented `[suit][rank]` layout), empty-theme
no-panic, back-slot overwrite, and the URL format from `set_theme`.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (950 passed, 0 failed, 9 ignored).
2026-05-01 05:59:28 +00:00
funman300 ce38b26721 feat(engine): theme zip importer with safety validation (Card theme phase 7)
Implements Phase 7 of CARD_PLAN.md — the entry point that takes a
user-supplied theme zip archive, validates it end-to-end, and
atomically unpacks it into the per-platform user themes directory.

Public API:
  import_theme(zip_path) -> Result<ThemeId, ImportError>
    Resolves user_theme_dir() and unpacks into <user>/<id>/.
  import_theme_into(zip_path, target_root) -> Result<ThemeId, ImportError>
    Test-friendly variant that takes the destination explicitly so
    unit tests never touch the global OnceLock override.

Safety guarantees enforced:
- 20 MB hard cap on archive size (read from the central directory
  before any extraction).
- Zip-slip path traversal rejected via ZipFile::enclosed_name plus a
  Component::Normal-only belt-and-braces check.
- Manifest parsed via ron::de and validated via the existing
  ThemeManifest::validate (Phase 2) — surfaces named diagnostics for
  missing-of-52, unknown keys, duplicate keys, and meta errors.
- Every referenced face + back rasterised through rasterize_svg as a
  structural validity check before any bytes hit the destination.
- Atomic install: writes to <root>/.<id>.tmp/ then std::fs::rename
  into place, with a recursive copy + remove fallback for cross-
  device renames. Failed extraction wipes the staging dir; the user
  themes root is never touched on error.
- Id collision with an existing theme dir rejected up front.

7 new tests covering the happy path plus six failure modes (missing
manifest, missing face, oversized archive, zip-slip, missing-file,
id collision). Tests build zips in tempfile::TempDir so they never
touch the real user themes directory.

Workspace deps: zip 8.6 (default-features off + deflate only),
tempfile 3.27 (dev only).

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because cc disappeared from the sandbox; tests compile
under cargo check --tests and will run on a normal toolchain.
2026-05-01 05:47:30 +00:00
funman300 172d7773f0 feat(engine): asset sources for embedded + user theme dirs (Card theme phase 3)
Implements Phase 3 of CARD_PLAN.md — the embedded:// + themes:// asset
sources the card-theme system loads from. The bundled default-theme
manifest ships in the binary via Bevy's EmbeddedAssetRegistry; user
themes load from user_theme_dir() through a FileAssetReader-backed
source registered as `themes://`.

Registration is split across:
  register_theme_asset_sources(&mut App)
    Called BEFORE DefaultPlugins. Registers `themes://` while
    AssetSourceBuilders is still mutable.
  AssetSourcesPlugin
    Added AFTER DefaultPlugins. Populates the EmbeddedAssetRegistry
    that AssetPlugin's build step would otherwise overwrite.

Constants exposed for downstream consumers:
  USER_THEMES                 = "themes"   (asset-source name)
  DEFAULT_THEME_MANIFEST_URL  = "embedded://solitaire_engine/assets/themes/default/theme.ron"

Includes a stub default theme.ron (52 face slots + back) so
`ThemeManifest::validate()` accepts it today; PROVENANCE.md documents
the plan to drop in real SVG art (hayeah/playing-cards-assets) in a
follow-up.

4 new tests covering source registration, embedded-registry
population, manifest validation against the embedded stub, and the
manifest-URL constant matching the embedded asset path.

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because the C linker (cc) is unexpectedly absent from the
sandbox; the test bodies compile cleanly under cargo check --tests
and will run on a normal toolchain.
2026-05-01 05:47:13 +00:00
funman300 205ad6f646 feat(engine): per-platform user-theme directory (Card theme phase 5)
Implements Phase 5 of CARD_PLAN.md. Phase 3 (asset sources) and
Phase 7 (zip importer) both depend on this so it goes first.

solitaire_engine/src/assets/user_dir.rs
  user_theme_dir() -> PathBuf
    Desktop (Linux/macOS/Windows): joins dirs::data_dir() with
    "solitaire_quest/themes" — same parent as the rest of the
    project's per-user files (settings.json, stats.json, etc.)
    Mobile (Android/iOS): reads a process-wide OnceLock populated
    by set_user_theme_dir() at entry-point bootstrap. Panics with a
    targeted message if the override is missing — there is no
    platform default we can guess that won't be wrong inside iOS
    sandboxing or the Android storage model.
  set_user_theme_dir(PathBuf) -> Result<(), PathBuf>
    First-write-wins. Mobile entry points call this before App::run().

The plan suggested the `directories` crate; reused the existing `dirs`
workspace dep instead to keep the dependency surface minimal — both
crates share an author and the platform behaviour we need is identical.

3 new tests covering pure path composition (desktop nesting + empty
root) and a desktop-target-gated check that the detected data dir is
absolute. The OnceLock override is intentionally not unit-tested
because asserting its semantics would pollute global state for any
sibling test that calls `user_theme_dir()`.
2026-05-01 05:25:21 +00:00
funman300 936d035750 feat(engine): CardTheme asset + manifest loader (Card theme phase 2)
Implements Phase 2 of CARD_PLAN.md — the data types and `.theme.ron`
asset loader that build on Phase 1's SVG rasteriser.

solitaire_engine/src/theme/
  mod.rs        — CardKey { suit, rank } as the HashMap lookup key
                  (distinct from solitaire_core::Card which carries
                  per-deal id + face_up state); CardKey::all() yields
                  the 52 keys in suit-major / rank-ascending order;
                  manifest_name() and parse_manifest_name() round-trip
                  via the canonical "{suit}_{rank}" form.
                  ThemeMeta with structural validation (id non-empty,
                  no path separators, non-zero aspect components).
                  CardTheme #[derive(Asset, TypePath)] storing the
                  53 image handles + meta.
  manifest.rs   — ThemeManifest { meta, back, faces } with serde for
                  RON round-trip. validate() returns a strongly-typed
                  HashMap<CardKey, PathBuf>, surfacing precise errors
                  for unknown face keys, missing-of-52 entries, and
                  duplicate keys (RON silently keeps the last; brittle
                  for a release).
  loader.rs     — AssetLoader for .theme.ron. Validates manifest, then
                  composes sibling SVG paths via AssetPath::resolve so
                  the same loader works for both embedded:// and
                  themes:// asset sources (Phase 3 territory).
                  Schedules every face + back load through SvgLoader
                  with target_size derived from meta.card_aspect.

24 new tests covering: 52-key enumeration uniqueness, manifest-name
round trip, garbage-name rejection, complete/missing/unknown/duplicate
manifest validation, RON round-trip integrity, target-size aspect
math (2:3 → 512x768; non-standard; degenerate 1:10000 clamps to 1px).

Workspace deps added: ron 0.12.

cargo build / clippy --workspace --all-targets -- -D warnings / test
all green (937 passed total — +24 from Phase 2 vs the +7 from
Phase 1's b8fb3fb baseline).
2026-05-01 05:19:12 +00:00
funman300 13d1d013e9 chore: route rustc through sccache for cold-build wins
Adds .cargo/config.toml setting `rustc-wrapper = "sccache"` so cold
rebuilds (CI, fresh checkouts, post-`cargo clean`) replay previously-
compiled crates from disk instead of recompiling. Warm incremental
builds are unaffected — cargo's own target/ cache dominates there.

Cache lives at `.sccache-cache/` inside the project (gitignored). The
[env] entry uses `force = false` so a developer-set $SCCACHE_DIR in
their shell wins, matching whichever directory the sccache daemon
already adopted.

Requires sccache on PATH. Install: `pacman -S sccache`,
`brew install sccache`, or `cargo install sccache --locked`. Bypass
without editing: `RUSTC_WRAPPER= cargo build`.
2026-05-01 05:15:59 +00:00
funman300 b8fb3fbd6e feat(engine): SVG → Image asset loader (Card theme phase 1)
Implements the runtime SVG rasterisation pipeline that the card-theme
system (CARD_PLAN.md) is built on. Bevy 0.18 has no native SVG support;
this loader bridges usvg (parser) + resvg (renderer) + tiny-skia (CPU
pixmap) so the rest of the engine consumes themes as plain
Handle<Image>. Rasterisation happens once per (asset, settings) pair at
load time — Bevy's asset cache absorbs the cost.

solitaire_engine/src/assets/
  mod.rs           — module entrypoint
  svg_loader.rs    — SvgLoader (AssetLoader for .svg → Image)
                     SvgLoaderSettings { target_size: UVec2 } default 512×768
                     SvgLoaderError (Io / Parse / PixmapAlloc) via thiserror
                     rasterize_svg() helper exposed for non-asset-graph
                     callers (the future zip-importer validation step)

The rasteriser scales-to-fit while preserving aspect ratio, centring
the SVG inside the target box so a non-2:3 source doesn't pin to the
top-left corner.

7 new unit tests — default + custom target size, zero-dimension reject,
malformed-input reject, RGBA byte-count, extension advertisement, and
a compile-time guard that SvgLoaderSettings still satisfies the
AssetLoader::Settings trait bounds.

Workspace deps added: usvg 0.47, resvg 0.47, tiny-skia 0.12 (latest
minor versions; CARD_PLAN.md called out the placeholder numbers
needed verification).

cargo build / cargo clippy --workspace --all-targets -- -D warnings
/ cargo test --workspace all green (913 passed, 0 failed, 9 ignored —
+7 from the new loader tests).
2026-05-01 05:05:30 +00:00
206 changed files with 41144 additions and 2563 deletions
+1
View File
@@ -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"
}
+10 -6
View File
@@ -47,11 +47,10 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
### Design Principles
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
- **Pure core.** All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
---
@@ -70,8 +69,8 @@ solitaire_quest/
├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
│ ├── 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_14 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_14 are generated patterns
│ ├── backgrounds/bg_0.png bg_4.png # generated textures
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
│ └── audio/
@@ -716,11 +715,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 +1011,7 @@ Using `axum::test` and an in-memory SQLite database:
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 |
| 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 |
+883
View File
@@ -0,0 +1,883 @@
# Changelog
All notable changes to Solitaire Quest are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
_Nothing yet._
## [0.19.0] — 2026-05-06
Closes the v0.18.0 punch list (items B and D — async hint and
persistent replay share URLs), expands desktop platform fit
(Wayland session support + monitor-aware default window size for
HiDPI / 4K displays), polishes the win-celebration and
double-click animation paths, and clears two test-flake
contributors. A short-lived "Rusty Pixel" pixel-art card theme
was prototyped and reverted in the same window — the engine
plumbing it touched (`pixel_art` field on `ThemeMeta`, PNG
manifest face support, second `embedded://` theme channel) was
fully reverted and is not part of this release.
### Changed
- **H-key hint runs on `AsyncComputeTaskPool`** (`3e11e9e`). The
synchronous `try_solve_from_state` call on every H press is gone;
`handle_keyboard_hint` now spawns a task whose result the new
`pending_hint::poll_pending_hint_task` system surfaces one frame
later. New `PendingHintTask` resource carries the in-flight handle
plus `move_count_at_spawn` for staleness detection;
`drop_pending_hint_on_state_change` cancels the task whenever the
game state shifts; `PendingHintTask::spawn` implements
cancel-on-replace so two quick H presses keep at most one task in
flight. Mirrors the v0.18.0 `PendingNewGameSeed` template.
`emit_hint_visuals` and `find_heuristic_hint` are extracted as
`pub` helpers so the polling system can call them.
- **Persistent replay share URLs** (`42d90b1`). v0.18.0's
`LastSharedReplayUrl` was an in-memory resource wiped on quit —
the player had to share within the session of the win.
`solitaire_data::Replay` now carries a `share_url: Option<String>`
field with `#[serde(default)]` (no `REPLAY_SCHEMA_VERSION` bump
needed; older `replays.json` files load unchanged with `share_url
== None` on every entry). `poll_replay_upload_result` writes the
resolved URL into `replays[0].share_url` and persists the updated
history via `save_replay_history_to`. The Stats overlay's
"Copy share link" button reads from
`history.0.replays[selected.0].share_url`, so the Prev/Next
selector's currently-displayed replay drives the clipboard
contents — each historical win keeps its own URL.
`LastSharedReplayUrl` removed (its role is now subsumed by the
`share_url` field on the replay record).
### Added
- **Wayland session support** (`b57db01`). The workspace
`Cargo.toml` Bevy feature list now enables `wayland` alongside
`x11`. winit prefers Wayland when `WAYLAND_DISPLAY` is set on the
session, falling back to X11 when it isn't. Pre-fix, a Wayland
desktop environment fell through to XWayland, rendering the
game inside an X11 frame stitched into the Wayland compositor.
Post-fix, the game opens as a native Wayland surface. Costs a
few hundred KB of binary for the libwayland-client bindings;
cross-distro friendly because winit dlopen-probes the libraries
rather than hard-linking them.
- **Monitor-relative default window size** (`b57db01`). On launches
with no saved geometry, the new
`apply_smart_default_window_size` Update system queries
`Monitor` (with the `PrimaryMonitor` marker) and resizes the
primary window to ~70 % of the monitor's *logical* size on the
first frame. Before, every fresh launch opened at 1280×800
regardless of monitor; on a 4K monitor that's a comparatively
tiny window in one corner. Logical size already accounts for
the OS's HiDPI scale factor, so a Retina display reporting
scale_factor 2.0 yields the same physical inches as a 1080p
display reporting 1.0. Skipped entirely when saved geometry was
applied — the player's chosen size always wins.
### Fixed
- **Duplicate "You Win" toast on game-won** (`55c235b`). The
post-win UI was firing two celebration surfaces: a 4-second
toast banner ("You Win! Score: X Time: Y") on top of the
`win_summary_plugin`'s "You Won!" modal. In screenshots the
toast banner was partially clipped behind the modal card,
peeking out on either side. The toast predated the modal and is
strictly subsumed by it; removed. The cards-fly-off cascade
animation (`MotionCurve::Expressive` per-card rotation drift)
is unchanged — that's the visual celebration, distinct from
the textual celebration the modal owns. `WIN_TOAST_SECS` const
removed.
- **Double-click on a single card with no destination now plays
the reject animation** (`d7ffb16`). `handle_double_click` only
fired `MoveRejectedEvent` for multi-card stacks with no
destination; a double-click on a single card whose top didn't
fit any foundation or tableau slot produced zero feedback —
no `card_invalid.wav`, no source-pile shake. Both priorities'
failure paths now converge on a single rejection at the end of
the double-click branch, so single-card and stack misses get
the same feedback shape as drag-and-drop rejections.
- **Double-click move animation no longer plays twice**
(`6037596`). On a successful double-click, the slide-to-
destination animation rendered twice — once from the move's
`StateChangedEvent` landing, then again from the release's
`end_drag` firing a redundant `StateChangedEvent` mid-slide.
`sync_cards_on_change` saw the card mid-CardAnim (`cur ≠
target`) and replaced the in-flight tween with a fresh one
starting at the mid-position, visibly restarting the slide. The
defensive `StateChangedEvent` write in `end_drag`'s
uncommitted-drag branch is removed; `start_drag` only mutates
`DragState` (never card transforms), so an uncommitted drag
has no visual side effect to undo. The committed-drag branch
keeps its `StateChangedEvent` since real drag snap-backs do
need a resync.
- **`auto_save_writes_after_30_seconds` test flake** (`91b7605`).
The test's single-frame `app.update()` was sensitive to
first-frame `Time::delta_secs()` variance under heavy parallel
cargo-test load, and to production-disk
`~/.local/share/solitaire_quest/game_state.json` state leaking
into the test world via `GamePlugin::build`'s load path.
`test_app` now resets `PendingRestoredGame(None)` after plugin
build (preventing the dev machine's saved-game state from
tripping the auto-save guard) and the test re-arms the timer in
a small bounded loop until the file appears (robust against
first-frame Time variance). No production-code change.
### Stats
- 1170 passing tests (was 1166 at v0.18.0 close — net +4 from
the persistent share URL backwards-compat test, the three
async-hint tests, minus the dropped synchronous hint tests).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.18.0] — 2026-05-06
The launch-experience round. The engine used to drop the player on a
silent default Classic deal whether they had unfinished work or not;
v0.18.0 replaces that with two stacked decision points — a Restore
prompt for in-progress saves, then an MSSC-style Home / mode picker
that surfaces Daily / Zen / Challenge / Time Attack as picture tiles
with live stats. The same round closes the last solver-on-main-thread
hot path (winnable-only seed selection moves to
`AsyncComputeTaskPool`), wires "Copy share link" into Stats, lights a
"Won before" HUD chip on re-deals of beaten seeds, and tidies the
unified-3.0 rule set across CLAUDE.md / CLAUDE_SPEC.md /
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md.
### Added
- **Restore prompt on launch** (`3c7a0eb`). When `game_state.json`
holds an in-progress game (`move_count > 0`, not won), the engine
now seeds `GameStateResource` with a fresh deal and holds the saved
game in a new `PendingRestoredGame` resource. After the splash
clears, a "Welcome back" modal offers **Continue** (Enter / C /
click) or **New game** (N / click). Fresh-deal saves
(`move_count == 0`) skip the prompt and load directly.
- **Save preservation while the prompt is unanswered** (`f863d85`).
Both `save_game_state_on_exit` and `auto_save_game_state` consult
`PendingRestoredGame` first: if it still holds a pending saved
game, that's what gets persisted (or the auto-save is skipped),
so exiting before answering the prompt no longer overwrites the
meaningful save with the placeholder fresh deal.
- **Home / mode picker auto-shows on launch** (`dd63261`). The mode
picker was only reachable via **M** during gameplay; players who
hadn't discovered the hotkey never saw the Daily / Zen / Challenge
/ Time Attack entry points after the splash cleared. `HomePlugin`
gains an `auto_show_on_launch` flag (default true) and a
one-shot `LaunchHomeShown` gate. Skips when the Restore prompt is
on screen so Welcome-back still takes precedence.
- **MSSC-style Home picker — header / chips / score chips / draw
mode** (`ae40a1d`). Player-stats header strip (Level / XP /
Lifetime Score, compact-formatted as `1.2M` / `12.3K` / `1,234`)
acts as a clickable shortcut to Profile. Draw-mode chip row above
the mode cards lets the player flip Draw 1 / Draw 3 from the
picker itself; persists `settings.json` and respawns the modal so
the active state repaints cleanly. Per-mode best-score / streak
chips on each card; hidden on a 0 best so a fresh profile doesn't
read "Best 0" everywhere.
- **Today's Event callout on the Daily card** (`b73d246`). "Today,
May 6" date line plus the server-fetched goal (when SyncPlugin is
wired). Once today's daily is recorded as completed, the date
flips to `Today, May 6 • Done` in `ACCENT_PRIMARY` so the picker
reads as a reward state rather than a TODO.
- **Picture-tile mode cards** (`9fe650f` + glyph-picking follow-ups
`40d6e0a`, `c30b04e`, `d065d49`). Mode cards become a wrapping
2-up grid (`FlexWrap::Wrap`, tiles 48 % wide, `min_height: 180px`)
with a centred Unicode-glyph centrepiece per tile. Final glyph set
picked from FiraMono-Medium's actual coverage: ♣ Classic, ◆ Daily,
○ Zen, ▲ Challenge, → TimeAttack. `ACCENT_PRIMARY` when the mode is
unlocked, `TEXT_DISABLED` when locked. Centrepiece is a `Text` node
for now — when real per-mode artwork lands, swap to `Image` without
touching tile layout, focus order, or chip rendering.
- **Solver-vetted seed selection on `AsyncComputeTaskPool`**
(`d489e7a`). Closes the worst-case 6 s UI stall on a New Game
click with "Winnable deals only" enabled. New `PendingNewGameSeed`
resource holds the in-flight `Task<u64>` plus the original
request's `mode` / `confirmed` flags. `poll_pending_new_game_seed`
runs `.before(GameMutation)` and replays a synthetic
`NewGameRequestEvent` once the task resolves — the player sees no
extra-frame visual lag. Cancel-on-replace: a fresh
`NewGameRequestEvent` while a task is in flight drops the old
task, letting Bevy's `Task` Drop cancel cooperatively at the next
await point.
- **"Won before" HUD indicator** (`bdac754`). When the current
deal's `(seed, draw_mode, mode)` triple matches an entry in the
rolling `ReplayHistory`, the HUD's tier-2 context row shows
**✓ Won before** in `STATE_SUCCESS`. Cleared on win (the on-screen
victory cue is enough) and on first-time deals. New
`HudWonPreviously` marker driven by a separate
`update_won_previously` system; gracefully no-ops in headless
tests that don't load `StatsPlugin`.
- **"Copy share link" Stats button** (`540869c`). End-to-end replay
sharing on a server-backed sync backend:
`sync_plugin::push_replay_on_win` spawns the upload on
`AsyncComputeTaskPool` and stores the handle in
`PendingReplayUpload` (drops any in-flight predecessor — the most
recent win is what the player wants the link for);
`poll_replay_upload_result` writes `<server>/replays/<id>` to
`LastSharedReplayUrl` on success; the Stats overlay's action bar
gains a button that writes the URL to the OS clipboard via
`arboard` and surfaces a "Copied: \<url\>" toast. URL is in-memory
only — sharing must happen within the session of the win.
- **Empty-state copy + onboarding hints** (`56e2e6f`). Leaderboard
empty state: two-tier "Be the first on the leaderboard." headline
+ body invite. Achievements panel: first-launch hint above the
grid until the first unlock. Volume hotkeys (`[` / `]`) now emit
an `InfoToastEvent` with the new percentage so off-panel
adjustments give visible feedback (previously silent).
- **Enter dismisses the Win Summary and starts a fresh deal**
(`17e0737`). The post-win modal's "Play Again" was click-only;
keyboard-only players had to reach for the mouse to leave the
celebration screen. The button label gains a trailing return-key
glyph so the keyboard path is discoverable on first sight.
- **`N` opens the real Confirm/Cancel modal** (`93660c2`). The old
"Press N again" double-tap pattern was a UI-first violation (only
continuation was another keystroke). `N` now fires
`NewGameRequestEvent::default()` directly; `handle_new_game`'s
active-game check spawns the existing `ConfirmNewGameScreen`. The
HUD button already routed through the same modal — keyboard and
mouse paths are unified. `Shift+N` keeps the keyboard power-user
bypass (`confirmed: true`).
### Changed
- **Settings row layout** (`a4bc063`). All five
slider/toggle row helpers (volume × 2, tooltip delay, time-bonus
multiplier, replay-move interval, generic toggle) restructured to
a label-spacer-cluster layout (`width: 100%`, label gets
`flex-grow: 1`, controls cluster sits flush right). Stable across
varying value-text widths ("0.80" → "1.00", "Instant" vs "1.5 s")
and narrow windows.
- **Docs adopt the unified-3.0 rule set** (`f2f30c8`). `CLAUDE.md`
grows from a 114-line pointer doc to a 571-line rulebook (hard
global constraints §2, engine rules §3, asset rules §4, code
standards §5, build + verification §6, git workflow §7, the ASK
BEFORE list §8, Context Injection System §14). New companions:
`CLAUDE_SPEC.md` (formal architecture spec — crate dependency
graph, data ownership, state-machine invariants, sync merge /
server contracts, validation checklist),
`CLAUDE_WORKFLOW.md` (two-agent Builder/Guardian pipeline with
hard-fail patterns), `CLAUDE_PROMPT_PACK.md` (task-type
templates). Three duplicate rule passages removed across
`CLAUDE_SPEC.md` and `ARCHITECTURE.md`.
- **Test discipline pruning** (`a49a340`). Removed 43 low-value
tests across `solitaire_data` and `solitaire_core` (default-value
tests, serde-derive round-trips on plain structs, single-field
clamp tests, near-duplicates, constant-equals-itself tests). None
pinned a behaviour contract or a regression on a real bug. Future
agent briefs request tests for behaviour contracts or real-bug
regressions, not a count of N.
### Fixed
- **Esc on a modal no longer opens Pause underneath** (`08b006f`).
A single Esc press on Confirm New Game / Restore / Home /
Onboarding / Settings used to both close the modal and spawn the
Pause overlay on top in the same frame. `toggle_pause` now skips
when any non-Pause `ModalScrim` is in the world; the HUD-button
path is gated too. The four modal queries are bundled into a
`PauseModalQueries` `SystemParam` to stay under Bevy's
16-parameter cap.
- **Esc dismisses Home / accepts the Restore-prompt default**
(`d48b948`). Both screens previously ignored Esc, leaving the
player no keyboard-only escape after the previous fix. Home: Esc
behaves like Cancel (despawns the modal, keeps the underlying
default deal). Restore: Esc maps to Continue (preserves the saved
game, matching how the primary action already advertises Enter).
- **Esc dismisses the topmost modal when Profile stacks on Home**
(`9aa0dd2`). Clicking the Home header chip opens Profile on top
of Home; Esc used to close Home (because
`handle_home_cancel_button` fired with no awareness of layered
modals) and leave Profile orphaned over the game.
`profile_plugin` now splits P/button (toggle) from Esc
(close-only); `handle_home_cancel_button` skips its Esc branch
when any other `ModalScrim` exists.
- **Restore-prompt resolution suppresses Home auto-show**
(`b7c3a49`). Resolving the Welcome-back prompt cleared
`PendingRestoredGame` and despawned the modal, but the
launch-time Home auto-show then fired the next frame and stacked
itself over the player's chosen path. `LaunchHomeShown` becomes
`pub` so `handle_restore_prompt` flips it to `true` after either
resolution; **M** still re-opens the picker on demand.
- **Game timers freeze while the Home picker is up** (`c497c31`).
The HUD's elapsed-time counter ticked from the moment the default
Classic deal landed at startup, even though the auto-show Home
picker was still up — the player saw "0:11" before they had
chosen a mode. `tick_elapsed_time` and `advance_time_attack` now
also gate on the absence of `HomeScreen`, mirroring their
existing `PausedResource` check.
- **Popover rows stay visible regardless of action-bar fade**
(`cc63532`). Opening Modes / Menu showed a solid dark-purple
block in the top-right with no readable content — the action-bar
auto-fade was matching the popover rows by their shared
`ActionButton` marker and dropping their alpha to the
cursor-position-based fade value (typically 0). New `PopoverRow`
marker on rows in `spawn_modes_popover` / `spawn_menu_popover`;
`apply_action_fade` excludes them via `Without<PopoverRow>`.
### Stats
- 1166 passing tests (was 1208 at v0.17.0 close — 43 net removals
from the test-discipline prune plus 1 net-new test from the
async-seed work, no behaviour regressions).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.17.0] — 2026-05-06
A short follow-up round on top of v0.16.0: the H-key hint is no
longer a heuristic guess but the actual best first move suggested by
the v0.15.0 solver, and the in-engine replay player now has a
player-tunable playback rate.
### Added
- **Replay-rate slider** in Settings → Gameplay. Tunes
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
default 0.45 s. `tick_replay_playback` reads the value from
`SettingsResource` per frame so the slider takes effect on the
next playback tick — no restart required.
### Changed
- **Solver-driven hints.** Pressing **H** used to surface a
heuristic-best move (foundation moves preferred, then
tableau-to-tableau by depth-of-flip-revealed). It now asks the
v0.15.0 solver for the actual provably-best first move via the
new `solitaire_core::solver::try_solve_with_first_move` /
`try_solve_from_state` APIs. When the solver returns inconclusive
(rare deals where the bound runs out before a result), the old
heuristic remains the fallback. Median 2 ms per H press.
### Stats
- 1208 passing tests (was 1196 at v0.16.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.16.0] — 2026-05-06
A modal-feel polish round. Every overlay screen now scrolls when its
content overflows the 800×600 minimum window, every clickable button
shows a hand cursor on hover, keyboard focus lands on the primary
button on the same frame the modal opens, and read-only modals
dismiss when the player clicks the scrim outside the card.
### Added
- **Pointer cursor on hover** for every interactive `Button` entity
(modal buttons, HUD action bar, mode-launcher cards, settings
toggles, Stats selectors). `update_cursor_icon` gains a fourth
branch sitting between Grabbing (active drag) and Grab
(draggable card hover): when no drag is active and any
`Interaction::Hovered`/`Pressed` button is detected, the window
cursor swaps to `SystemCursorIcon::Pointer`. A pure
`pick_cursor_icon` helper makes the priority logic
unit-testable.
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
Achievements, Help, Profile, Leaderboard, Home. New
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
topmost dismissible scrim on a left-mouse press whose cursor
lands on the scrim and outside every `ModalCard`. Bevy's
hierarchy despawn cascades to the card and children.
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
Game intentionally don't opt in — they carry unsaved or
destructive state.
### Fixed
- **Modal content scrolls when it overflows** (Achievements, Help,
Stats, Profile, Leaderboard). Each modal's body Node now
carries `Overflow::scroll_y()` plus a `max_height` constraint
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
leaderboard's variable-length ranking section) and a marker
component (`AchievementsScrollable`, `HelpScrollable`,
`StatsScrollable`, `ProfileScrollable`,
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
per modal routes `MouseWheel` events into the body's
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
pattern. Home modal intentionally not scrolled — its five
mode cards + Cancel are sized to fit at 800×600 by design.
- **Modal focus arrives on the same frame the modal opens.**
Previously `attach_focusable_to_modal_buttons` and
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
click-handlers that spawn modals; with no ordering edge,
Bevy's deferred `Commands` queued the new entities but the
attach system couldn't see them on the same tick. Both systems
moved to `PostUpdate` so the schedule boundary itself supplies
the sync point — `FocusedButton` is always populated before
`app.update()` returns. The very next Tab/Enter press lands on
a populated resource instead of wasting itself moving focus
from None to the primary.
### Stats
- 1196 passing tests (was 1178 at v0.15.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.15.0] — 2026-05-02
In-engine replay playback, the Klondike solver + "Winnable deals
only" toggle, a 19th achievement, rolling replay history, and a
significant build-time / binary-size win from disabling Bevy's
default audio stack.
### Added
- **In-engine replay playback** for the Stats overlay's Watch Replay
button. New `ReplayPlaybackPlugin` runs a state machine
(Inactive / Playing / Completed) that resets the live game to the
recorded deal and ticks through `replay.moves` at
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
Recording is suppressed during playback so replays don't re-record
themselves.
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
top of the window during playback. Shows "Replay" label, "Move N
of M" progress, and a Stop button. Z-order leaves modals
(Settings, Pause, Help) free to render on top so the player can
adjust audio mid-replay.
- **Rolling replay history** at `<data_dir>/replays.json` capped at
8 entries. Replaces the single-slot `latest_replay.json` (legacy
file is migrated forward on first launch via
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
selector and a "Replay N / M" caption so the player can revisit
older wins.
- **"Cinephile" achievement** (#19). Unlocks the first time
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
replay played out to its end without the player pressing Stop).
Stop transitions Playing → Inactive directly so it doesn't count.
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
with memoisation on a 64-bit canonical state hash, two budget
knobs (move_budget + state_budget) for pathological cases, and a
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
Median solve time 2 ms; pathological inconclusives cap near
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
- **"Winnable deals only" toggle** in Settings → Gameplay (default
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
through `try_solve` until it finds Winnable or Inconclusive,
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
challenges, replays, and explicit-seed requests bypass the
solver — only random Classic deals are gated.
### Changed
- **Bevy default-feature trim** (`bevy = { default-features = false,
features = [...] }` in workspace Cargo.toml) drops 51 transitive
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
chain that the project doesn't use (kira handles audio directly).
The retained feature list is curated to exactly what the engine
uses; `solitaire_wasm` is unaffected because it doesn't depend on
bevy.
### Stats
- 1178 passing tests (was 1134 at v0.14.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.14.0] — 2026-05-02
Two threads land in v0.14.0: the second half of the post-v0.12.0 UX
candidate list (theme thumbnails, daily-challenge calendar, Time Attack
auto-save, per-mode bests, time-bonus multiplier) plus a **major new
feature** — the replay pipeline (record → upload → web viewer). Three
Quat-reported bugs from a smoke-test round shipped alongside.
### Added
- **Theme-picker thumbnails** in Settings → Cosmetic. Each theme chip
renders a small Ace-of-Spades + back preview pair via the existing
`rasterize_svg` path. Cached per theme in a new
`ThemeThumbnailCache`. Themes that lack a preview SVG fall back to
a transparent placeholder rather than crashing.
- **14-day daily-challenge calendar** in the Profile modal. Horizontal
row of dots showing the trailing two weeks; today's dot is ringed
in `ACCENT_PRIMARY`, completed days fill `STATE_SUCCESS`, missed
days fill `BG_ELEVATED`. Caption above the row reads "Current
streak: N · Longest: M".
- **Time Attack session auto-save** to `<data_dir>/time_attack_session.json`,
atomic .tmp + rename. 30-second auto-save while a session is active,
plus on `AppExit`. Sessions whose 10-minute window expired in real
time while the app was closed are discarded on load. Classic, Zen,
and Challenge already auto-saved correctly via `game_state.json` —
Time Attack was the only mode missing session-level persistence.
- **Per-mode best-score and fastest-win readouts** in the Stats screen.
`StatsSnapshot` gains six `#[serde(default)]` fields (Classic / Zen
/ Challenge × best_score + fastest_win_seconds). Stats screen renders
a "Per-mode bests" section between the primary cell grid and
progression. Lifetime totals continue to roll all modes together.
- **Time-bonus multiplier slider** in Settings → Gameplay (0.02.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 15 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.0v0.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 17).
- 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 13), 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
+531 -74
View File
@@ -1,114 +1,571 @@
# Solitaire Quest — Claude Code Instructions
# CLAUDE.md
See @ARCHITECTURE.md for full project design, crate responsibilities, data models, and API reference.
version: unified-3.0
---
## Project Layout
# 0. Role of This File
```text
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
solitaire_data/ # Persistence + SyncProvider trait + server client
solitaire_engine/ # Bevy ECS systems, components, plugins
solitaire_server/ # Axum sync server binary
solitaire_app/ # Thin binary entry point
assets/ # Source assets — embedded at compile time via include_bytes!()
This document defines:
* **Execution rules (what Claude must do)**
* **System constraints (what Claude must never violate)**
* **Operational architecture (how code is structured)**
For full system design details:
`ARCHITECTURE.md` (authoritative source of truth)
This file overrides all conversational assumptions.
---
# 1. System Architecture (Authoritative Mapping)
## 1.1 Crates
```text id="crate_map"
solitaire_core/ # PURE logic (no IO, no Bevy, deterministic)
solitaire_sync/ # Shared API + merge logic
solitaire_data/ # Persistence + sync client
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer)
solitaire_app/ # Entry binary
assets/ # Runtime assets (except audio)
```
---
## Build & Test Commands
## 1.2 Architecture Source of Truth
```bash
# Dev run (fast compile via dynamic linking)
cargo run -p solitaire_app --features bevy/dynamic_linking
* Full system design: `ARCHITECTURE.md`
* This file NEVER redefines system design
* This file ONLY enforces behavior
# Release build
cargo build --workspace --release
---
# All tests — MUST pass before any commit
# 2. Hard Global Constraints (NON-NEGOTIABLE)
These override all other instructions.
## 2.1 Core Determinism
* `solitaire_core` MUST:
* be deterministic
* be side-effect free
* never depend on Bevy / IO / async
---
## 2.2 Sync Isolation
* `solitaire_sync`:
* no Bevy
* no IO
* no engine dependencies
* merge logic must be pure functions only
---
## 2.3 Error Policy
* NO `unwrap()`
* NO `panic!()` in runtime/game logic
* All state transitions:
```rust id="err_model"
Result<T, MoveError>
```
---
## 2.4 Threading Rules
* Sync must run on `AsyncComputeTaskPool`
* NEVER block Bevy main thread
---
## 2.5 Persistence Rules
* atomic writes only:
* write `.tmp`
* rename atomically
* no partial state writes allowed
---
## 2.6 Security Rules
* credentials ONLY via `keyring`
* NEVER store secrets in:
* files
* logs
* source code
---
## 2.7 Sync System Rules
* All sync backends implement:
```rust id="sync_trait"
trait SyncProvider
```
* `SyncPlugin` MUST be backend-agnostic
* NEVER match on backend inside ECS systems
---
# 3. Engine Rules (Bevy Layer)
## 3.1 ECS Design
* systems = single responsibility
* communication = Events only
* shared state = Resources only
* per-entity state = Components only
---
## 3.2 Game State Authority
* ONLY `GameStateResource` can mutate game state
* UI systems MUST NOT directly modify core logic
---
## 3.3 UI-First Constraint (CRITICAL)
Every player action MUST:
* have a visible UI control
* NOT rely solely on keyboard shortcuts
Keyboard shortcuts are:
→ optional accelerators only
---
## 3.4 Layout System
* recompute on `WindowResized`
* no fixed resolution assumptions
---
# 4. Asset System Rules
## 4.1 Runtime Assets (AssetServer)
Loaded via:
* `CardImageSet`
* `BackgroundImageSet`
* `FontResource`
Includes:
* cards
* backgrounds
* fonts
---
## 4.2 Embedded Assets
Only audio:
```text id="audio_rule"
include_bytes!()
```
---
## 4.3 Test Compatibility Rule
All asset loaders MUST accept:
```rust id="asset_fallback"
Option<Res<AssetServer>>
```
Must degrade gracefully under `MinimalPlugins`.
---
# 5. Code Standards
## 5.1 Error Handling
* use `thiserror`
* no `Box<dyn Error>` in libraries
---
## 5.2 Public API Rules
* prefer `Into<T>` over concrete types
* all public items require doc comments
---
## 5.3 Derive Order
```rust id="derive_order"
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
```
---
## 5.4 Performance Rules
* NO `clone()` in hot paths
* profile before optimizing
---
## 5.5 SQL Rules
* ONLY `sqlx::query!`
* NO raw SQL strings
---
# 6. Build & Verification Rules
These are mandatory before ANY commit.
```bash id="build_rules"
cargo test --workspace
# Lint — MUST pass clean (zero warnings)
cargo clippy --workspace -- -D warnings
# Run sync server locally
cargo run -p solitaire_server
# Check a single crate
cargo test -p solitaire_core
cargo clippy -p solitaire_core -- -D warnings
```
---
## Hard Rules
# 7. Git Workflow Rules
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
- `cargo test --workspace` must pass after every change.
## Commit format
```text id="commit_fmt"
type(scope): description
```
Examples:
* feat(core): add draw-three rules
* fix(engine): correct drag z-order
* test(core): undo boundary cases
---
## Code Style
## Commit conditions
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
- Prefer `Into<T>` over concrete types in public API function parameters.
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
* tests must pass
* clippy must be clean
NEVER commit otherwise
---
## Bevy Conventions
# 8. Change Control (ASK BEFORE DOING)
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
- Resources own shared state. Events communicate between systems. Components own per-entity data.
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
Claude must request confirmation before:
* adding dependencies
* modifying `solitaire_sync`
* changing DB schema
* introducing `unsafe`
* changing merge strategy
---
## Git Workflow
# 9. System Mental Model (IMPORTANT)
- Commit after each passing phase, not after every file change.
- Commit message format: `type(scope): description`
- `feat(core): add draw-three mode validation`
- `fix(engine): card z-order during drag`
- `test(core): undo stack boundary conditions`
- `chore(server): add sqlx migration 002`
- Never commit with failing tests or clippy warnings.
- Never commit secrets, `.env` files, or `*.db` files.
```text id="mental_model"
Core (rules + deterministic logic)
Engine (Bevy orchestration)
Data layer (persistence + sync)
Server (optional external system)
```
Core is always the source of truth.
---
## Ask Before Doing
# 10. Known Platform Pitfalls
- Adding a new crate dependency (discuss alternatives first).
- Changing a type in `solitaire_sync` (breaking change on both client and server).
- Altering the database schema (requires a new sqlx migration).
- Introducing `unsafe` code anywhere.
- Changing the merge strategy in `solitaire_sync::merge()`.
Must always be handled explicitly:
* Bevy `Time` uses `f32`
* `sqlx::migrate!()` path is crate-relative
* `dirs::data_dir()` may return `None`
* Linux may lack keyring backend
---
## Lessons Learned
# 11. Forbidden Patterns
> Add entries here when Claude makes a mistake so it isn't repeated.
* game logic inside Bevy systems
* duplication across crates
* blocking async calls in ECS
* insecure credential storage
* bypassing core logic layer
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
---
# 12. Execution Rules for Claude
When generating code:
1. respect crate boundaries
2. minimize diff size
3. do not expand scope
4. follow existing patterns
5. preserve invariants
If unclear:
→ ask before acting
---
# 13. Relationship to ARCHITECTURE.md
| File | Role |
| --------------- | ------------------------- |
| CLAUDE.md | execution + constraints |
| ARCHITECTURE.md | system design truth |
| Both combined | full system understanding |
---
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
## 14.1 Purpose
Before generating any response, Claude MUST construct a **minimal relevant context set**.
This prevents:
* architectural drift
* irrelevant spec loading
* over-engineering
* cross-crate confusion
---
## 14.2 Input Classification Step (MANDATORY)
Every request MUST be classified into exactly one task type:
```text id="task_types"
feature
bugfix
refactor
system_design
bevy_system
core_logic
sync
optimization
test
debug
```
If uncertain → ask clarification.
---
## 14.3 Context Selection Engine
After classification, Claude MUST include ONLY the relevant sections below.
---
## 14.4 Context Map (CORE RULESET)
### feature
Include:
* §2 Hard Global Constraints
* §3 Engine Rules
* ARCHITECTURE.md (crate of target feature only)
* relevant data models (GameState, SyncPayload if needed)
---
### bugfix
Include:
* §2 Hard Global Constraints
* §5 Code Standards
* affected crate boundaries
* relevant system (engine/core/sync only)
---
### refactor
Include:
* §3 Engine Rules
* §5 Code Standards
* §11 Forbidden Patterns
* target crate boundaries
---
### system_design
Include:
* ARCHITECTURE.md (FULL)
* §9 Mental Model
* §1 System Architecture Mapping
---
### core_logic
Include:
* solitaire_core rules only
* GameState model
* MoveError model
* §2.12.3 constraints
---
### bevy_system
Include:
* §3 Engine Rules
* ECS rules (Events/Resources/Components)
* UI-first constraint
* relevant plugin system only
---
### sync
Include:
* SyncProvider trait
* merge strategy rules
* solitaire_sync models
* §2.6 Sync Rules
---
### optimization
Include:
* target crate only
* §5.4 Performance Rules
* hot path constraints
---
### test
Include:
* §6 Build Rules
* relevant module
* expected invariants
---
### debug
Include:
* target file/module only
* §2.3 Error Policy
* runtime assumptions relevant to failure
---
## 14.5 Context Compression Rules
Claude MUST obey:
* never include full ARCHITECTURE.md unless system_design
* max 2 crates per response unless explicitly required
* prefer function-level context over file-level context
* exclude unrelated plugins/systems
---
## 14.6 Context Priority Order
When space is limited:
1. Hard Constraints (§2)
2. Target crate rules
3. Data models
4. Only then: architecture snippets
---
## 14.7 “No Context Pollution” Rule
Claude must NOT include:
* unrelated crates
* unrelated plugins
* unused data models
* full architecture dumps
* speculative systems
---
## 14.8 Self-Check Before Execution
Before writing code, Claude MUST verify:
* [ ] Is only relevant context included?
* [ ] Is at least one hard constraint present?
* [ ] Am I touching more than one crate unnecessarily?
* [ ] Am I duplicating ARCHITECTURE.md content?
If any fail → revise context selection.
---
## 14.9 Injection Output Format (Internal Model)
Claude should behave as if it constructed:
```text id="ctx_format"
[SELECTED TASK TYPE]
[MINIMAL REQUIRED RULES]
[MINIMAL ARCHITECTURE SLICES]
[RELEVANT MODELS]
[REQUEST]
```
---
## 14.10 Relationship to ARCHITECTURE.md
* ARCHITECTURE.md = source of truth
* CLAUDE.md = execution constraints
* THIS SECTION = filtering layer between them
---
# END CONTEXT INJECTION SYSTEM
+497
View File
@@ -0,0 +1,497 @@
# CLAUDE_PROMPT_PACK.md
version: 1.0
---
# 0. GLOBAL INSTRUCTION (prepend to every prompt)
```
You must follow CLAUDE_SPEC.md strictly.
Rules:
- Do not expand scope beyond what is defined
- Do not refactor unrelated code
- Do not introduce new dependencies
- Prefer minimal, surgical changes
- Use existing patterns in the codebase
- Return minimal diffs or changed functions only
Before writing code:
1. List relevant constraints from CLAUDE_SPEC.md
2. Identify risks
3. Then implement
```
---
# 1. FEATURE IMPLEMENTATION
```
# TASK: Feature Implementation
feature: "<name>"
goal:
"<clear outcome>"
scope:
crates: []
systems: []
files: []
non_goals:
- ""
constraints:
- must follow CLAUDE_SPEC.md
- event-driven architecture required
- no blocking operations
- no cross-crate leakage
acceptance_criteria:
- ""
- ""
edge_cases:
- ""
---
## Required Patterns
Use this pattern for systems:
<PASTE EXISTING SYSTEM SNIPPET HERE>
---
## Output Format
intent:
plan:
constraints_used:
risks:
code_changes:
(minimal diffs only)
notes:
```
---
# 2. BUGFIX
```
# TASK: Bug Fix
bug_description:
"<what is broken>"
expected_behavior:
"<correct behavior>"
root_cause_hint (optional):
""
scope:
crates: []
files: []
constraints:
- minimal fix only
- no refactors unless required
- must add regression protection if applicable
---
## Requirements
1. Identify root cause
2. Fix it minimally
3. Preserve all invariants
4. Do not change unrelated logic
---
## Output Format
analysis:
root_cause:
fix_strategy:
code_changes:
(minimal diff)
regression_test (only if high-value):
notes:
```
---
# 3. REFACTOR
```
# TASK: Refactor
target:
"<what is being improved>"
goal:
"<what improves>"
scope:
crates: []
files: []
non_goals:
- no behavior changes
- no new features
constraints:
- must preserve behavior exactly
- must respect crate boundaries
- must not duplicate logic
---
## Refactor Type
- [ ] simplify logic
- [ ] reduce duplication
- [ ] improve readability
- [ ] performance (non-invasive)
---
## Output Format
analysis:
issues_found:
refactor_plan:
code_changes:
(diff only)
verification:
- behavior unchanged: yes/no
- invariants preserved: yes/no
notes:
```
---
# 4. SYSTEM DESIGN (NEW FEATURE)
```
# TASK: System Design
feature:
"<name>"
goal:
"<what problem it solves>"
constraints:
- must fit existing architecture
- must follow plugin + event model
- must not violate crate boundaries
---
## Required Output
design:
components:
- plugins:
- systems:
- events:
- resources:
data_flow:
(step-by-step)
integration_points:
- where it connects to existing systems
risks:
- ""
tradeoffs:
- ""
---
## DO NOT
- write full implementation
- modify unrelated systems
```
---
# 5. NEW BEVY SYSTEM
```
# TASK: Add Bevy System
system_name:
""
trigger:
(event or condition)
reads:
[Resources]
writes:
[Resources]
emits:
[Events]
constraints:
- must be event-driven
- must not directly mutate unrelated state
- must be single responsibility
---
## Output Format
system_signature:
implementation:
(code only)
notes:
```
---
# 6. CORE LOGIC FUNCTION (solitaire_core)
```
# TASK: Core Logic Implementation
function:
"<name>"
goal:
"<what it does>"
rules:
- no IO
- no async
- no Bevy
- deterministic
invariants:
- ""
- ""
errors:
- ""
---
## Output Format
constraints_checked:
implementation:
(code only)
edge_case_handling:
notes:
```
---
# 7. SYNC / MERGE LOGIC
```
# TASK: Sync Logic
goal:
"<what is being merged or synced>"
constraints:
- must be deterministic
- must be idempotent
- must be lossless
- must not delete data
rules:
- counters → max
- times → min
- collections → union
---
## Output Format
analysis:
merge_logic:
code_changes:
invariants_verified:
- deterministic
- idempotent
- lossless
notes:
```
---
# 8. PERFORMANCE OPTIMIZATION
```
# TASK: Optimization
target:
"<what is slow>"
constraints:CLAUDE_WORKFLOW.md
- no behavior change
- no architecture change
- minimal code changes
---
## Output Format
analysis:
bottleneck:
optimization_strategy:
code_changes:
impact_estimate:
notes:
```
---
# 9. TEST GENERATION (STRICT MODE)
```
# TASK: Test Generation
target:
"<function/system>"
reason:
- bugfix | complex logic | invariant protection
constraints:
- no redundant tests
- must test real behavior
- must fail if logic breaks
---
## Output Format
test_cases:
- ""
test_code:
notes:
```
---
# 10. DEBUGGING / INVESTIGATION
```
# TASK: Debug
problem:
"<symptom>"
context:
"<relevant code or system>"
---
## Required Steps
1. List possible causes
2. Narrow down most likely
3. Suggest verification steps
4. Provide minimal fix
---
## Output Format
hypotheses:
most_likely:
verification_steps:
fix:
notes:
```
---
# 11. HARD CONSTRAINT OVERRIDE (RARE)
```
# TASK: Exception Handling
reason:
"<why constraints must be bent>"
requested_exception:
"<rule being broken>"
justification:
"<why unavoidable>"
---
## Output Format
analysis:
alternatives_considered:
final_decision:
risk:
```
---
# 12. STOP CONDITIONS (always append)
```
Stop when:
- acceptance criteria are met
- code is minimal and correct
Do NOT:
- expand scope
- refactor unrelated code
- optimize prematurely
```
---
# END
+292
View File
@@ -0,0 +1,292 @@
# CLAUDE_SPEC.md
version: 1.0
---
## 0. Global Rules
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
rules:
* id: single_source_of_truth
description: "GameStateResource is the only mutable game state in runtime"
* id: sync_is_additive
description: "Remote data must never destructively overwrite local data"
---
## 1. Crate Graph
crates:
solitaire_core:
depends_on: [rand, serde, chrono]
forbidden_deps: [bevy, reqwest, tokio, std::fs]
solitaire_sync:
depends_on: [serde, serde_json, uuid, chrono]
role: "shared_types"
solitaire_data:
depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring]
role: "persistence_and_sync"
solitaire_engine:
depends_on: [bevy, kira, solitaire_core, solitaire_data]
role: "runtime_engine"
solitaire_server:
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
role: "backend"
solitaire_app:
depends_on: [solitaire_engine]
role: "entrypoint"
---
## 2. Data Ownership
ownership:
GameState:
owner: solitaire_core
mutable_in: solitaire_engine
access_pattern: "via GameStateResource only"
StatsSnapshot:
owner: solitaire_data
PlayerProgress:
owner: solitaire_data
AchievementRecord:
owner: solitaire_data
SyncPayload:
owner: solitaire_sync
---
## 3. State Transitions
state_machine:
GameState:
transitions:
- action: move_cards
returns: Result<GameState, MoveError>
```
- action: draw
returns: Result<GameState, MoveError>
- action: undo
returns: Result<GameState, MoveError>
invariants:
- "52 cards always exist"
- "no duplicate card IDs"
- "all cards belong to exactly one pile"
```
---
## 4. Event System
events:
input:
- MoveRequestEvent
- DrawRequestEvent
- UndoRequestEvent
- NewGameRequestEvent
state:
- StateChangedEvent
- GameWonEvent
meta:
- AchievementUnlockedEvent
- SyncCompleteEvent
rules:
* "Input events trigger core logic"
* "Core logic emits state events"
* "UI reacts to state events only"
---
## 5. Sync Contract
sync:
provider_trait:
methods:
- pull() -> SyncPayload
- push(payload) -> SyncResponse
guarantees:
- "non-blocking during gameplay"
- "blocking allowed on exit only"
merge:
rules:
counters: "max"
best_times: "min"
collections: "union"
achievements: "never removed"
```
properties:
- deterministic
- idempotent
- lossless
```
---
## 6. Persistence
storage:
format: json
files:
- stats.json
- progress.json
- achievements.json
- settings.json
- game_state.json
guarantees:
- atomic_write: true
- crash_safe: true
---
## 7. Engine Rules
engine:
mutation_rules:
- "Only GameLogicSystem mutates GameState"
- "UI systems are read-only"
threading:
- "sync runs on AsyncComputeTaskPool"
- "main thread must never block"
plugins:
pattern: "feature_isolation"
communication: "events"
---
## 8. Server Contract
server:
auth:
method: jwt
access_expiry: 24h
refresh_expiry: 30d
endpoints:
- POST /api/auth/register
- POST /api/auth/login
- GET /api/sync/pull
- POST /api/sync/push
limits:
payload_max: 1MB
rate_limit: "10 req/min auth routes"
---
## 9. Achievement System
achievements:
definition_location: solitaire_core
state_location: solitaire_data
types:
- condition_based
- event_driven
rule:
- "achievements cannot be revoked"
---
## 10. Testing Rules
testing:
philosophy:
- "test real failures"
- "avoid redundant tests"
required_coverage:
solitaire_core:
- move_validation
- undo_integrity
- win_detection
```
solitaire_sync:
- merge_correctness
- idempotency
```
---
## 11. Prohibited Patterns
(See CLAUDE.md §11 for the canonical forbidden-patterns list.)
---
## 12. Extension Points
extensibility:
sync_backends:
pattern: "implement SyncProvider"
game_modes:
location: solitaire_core::GameMode
plugins:
rule: "new feature = new plugin"
---
## 13. Validation Checklist (for Claude)
validation:
* check: "crate dependency rules respected"
* check: "no panics in core"
* check: "events used for cross-system communication"
* check: "GameState mutations centralized"
* check: "merge function properties preserved"
* check: "no blocking operations in main loop"
---
## 14. Mental Model
model:
layers:
- core
- engine
- data
- server
flow:
- input -> engine -> core -> engine -> ui
- data <-> sync <-> server
+335
View File
@@ -0,0 +1,335 @@
# CLAUDE_WORKFLOW.md
version: 1.0
---
## 0. Overview
This workflow defines a **two-agent system**:
* **Builder Agent** → writes and modifies code
* **Guardian Agent** → enforces architecture + rejects invalid changes
No code is considered valid unless it passes Guardian validation.
---
## 1. Agent Roles
### 1.1 Builder Agent
role: "code_generation"
responsibilities:
* implement features
* refactor code
* generate tests (only when justified)
* follow CLAUDE_SPEC.md
constraints:
* cannot bypass validation
* must declare intent before writing code
output_contract:
must_produce:
- change_summary
- files_modified
- reasoning (short)
- code_diff
---
### 1.2 Guardian Agent
role: "architecture_enforcement"
responsibilities:
* validate against CLAUDE_SPEC.md
* detect violations
* reject or approve changes
* suggest minimal fixes (not full rewrites)
constraints:
* no feature implementation
* no large rewrites
* must be deterministic
output_contract:
must_produce:
- status: APPROVED | REJECTED
- violations[]
- required_fixes[]
- optional_improvements[]
---
## 2. Workflow Pipeline
```text
User Request
Builder Agent (proposal + code)
Guardian Agent (validation)
IF approved → commit
IF rejected → feedback → Builder retry
```
---
## 3. Builder Protocol
### Step 1 — Intent Declaration
Builder MUST start with:
```yaml
intent:
feature: "<name>"
crates_touched: []
systems_affected: []
risk_level: low|medium|high
```
---
### Step 2 — Plan
```yaml
plan:
- step: "..."
- step: "..."
```
---
### Step 3 — Implementation
* Only modify declared crates
* Follow ownership rules
* Use events for cross-system communication
---
### Step 4 — Output
```yaml
change_summary: "..."
files_modified:
- path: ...
change: "..."
violations_self_check:
- none | list
notes: "short reasoning"
```
---
## 4. Guardian Protocol
### Step 1 — Spec Validation
Check against:
* crate boundaries
* mutation rules
* event system usage
* sync guarantees
* forbidden patterns
---
### Step 2 — Invariant Validation
Must verify:
* GameState invariants preserved
* no new panic paths
* no blocking calls in engine
* merge properties unchanged
---
### Step 3 — Output Decision
#### APPROVED
```yaml
status: APPROVED
notes:
- "no violations"
```
---
#### REJECTED
```yaml
status: REJECTED
violations:
- id: core_purity_violation
file: "solitaire_core/src/..."
reason: "uses std::fs"
required_fixes:
- "move IO to solitaire_data"
optional_improvements:
- "simplify event naming"
```
---
## 5. Enforcement Rules
### Hard Fail (automatic rejection)
* core crate uses IO / Bevy / network
* GameState mutated outside GameLogicSystem
* blocking async on main thread
* duplicate logic across crates
* merge function altered incorrectly
---
### Soft Fail (allowed but flagged)
* unnecessary complexity
* redundant tests
* minor architectural drift
---
## 6. Iteration Loop
Max attempts per task: **3**
```text
Attempt 1 → Reject → Fix
Attempt 2 → Reject → Fix
Attempt 3 → Final decision
```
If still failing:
→ escalate to user
---
## 7. Diff Strategy
Builder MUST produce:
* minimal diffs
* no unrelated refactors
* no formatting-only changes
---
## 8. Test Strategy Integration
Builder rules:
* only add tests if:
* fixing a bug
* protecting complex logic
* validating invariants
Guardian rejects:
* redundant tests
* no-op tests
---
## 9. Optional Extensions
### 9.1 Third Agent (Optimizer)
role: performance + cleanup
runs AFTER approval:
* reduce allocations
* simplify logic
* improve ECS scheduling
---
### 9.2 CI Integration
Pipeline:
```text
Builder → Guardian → cargo check → clippy → tests
```
Guardian runs BEFORE compilation to catch structural issues early.
---
## 10. Example Interaction
### Builder
```yaml
intent:
feature: "undo stack limit fix"
crates_touched: [solitaire_core]
risk_level: low
```
```yaml
change_summary: "limit undo stack to 64 entries"
files_modified:
- solitaire_core/src/game_state.rs
notes: "prevents unbounded memory growth"
```
---
### Guardian
```yaml
status: APPROVED
notes:
- "respects core constraints"
- "no invariant violations"
```
---
## 11. Mental Model
* Builder = **creative**
* Guardian = **strict**
Builder explores
Guardian enforces
Neither replaces the other.
---
## 12. Success Criteria
System is working if:
* architectural violations go to ~0
* code stays consistent across features
* refactors become safe
* complexity grows sub-linearly
+22 -14
View File
@@ -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.
Generated
+407 -844
View File
File diff suppressed because it is too large Load Diff
+71 -1
View File
@@ -7,6 +7,7 @@ members = [
"solitaire_server",
"solitaire_app",
"solitaire_assetgen",
"solitaire_wasm",
]
resolver = "2"
@@ -29,15 +30,84 @@ dirs = "6"
keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
arboard = { version = "3", default-features = false }
solitaire_core = { path = "solitaire_core" }
solitaire_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/webgl/gilrs/sysinfo)
"std",
"bevy_winit",
"default_font",
"multi_threaded",
# winit prefers Wayland when WAYLAND_DISPLAY is set on the
# session and falls through to X11 otherwise. Without `wayland`,
# winit-on-Wayland-session falls back to XWayland which renders
# the game in an X11 frame inside the Wayland compositor.
"wayland",
"x11",
# common_api
"bevy_color",
"bevy_image",
"bevy_mesh",
"bevy_shader",
"bevy_text",
"png",
# 2d rendering
"bevy_camera",
"bevy_render",
"bevy_core_pipeline",
"bevy_sprite",
"bevy_sprite_render",
# UI rendering
"bevy_ui",
"bevy_ui_render",
] }
kira = "0.12"
# 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"] }
+65 -24
View File
@@ -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
+143 -84
View File
@@ -1,121 +1,180 @@
# Solitaire Quest — UX Overhaul Session Handoff
# Solitaire Quest — Session Handoff
**Last updated:** 2026-05-01 — Phases 3, 4, and 5 all shipped. Smoke-test bugs closed. v1 release-readiness scope is essentially done; remaining work is the v0.1.0 tag plus desktop packaging.
**Last updated:** 2026-05-06 (post-v0.19.0) — Tagged + pushed at
`6037596`. v0.19.0 closes the v0.18.0 punch list (async H-key hint,
persistent replay share URLs), expands desktop platform fit (Wayland
session support + monitor-aware default window size), polishes the
win-celebration and double-click animation paths, and clears two
test-flake contributors. A short-lived "Rusty Pixel" pixel-art card
theme was prototyped and reverted in the same window.
## Status at pause
- **HEAD:** `902560c` — local master is **up to date** with `origin/master`.
- **Working tree:** clean.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **906 passed / 0 failed** across the workspace.
- **HEAD on origin:** `6037596` (post-tag commit; the tag itself
points at this commit).
- **Working tree:** modified — `CHANGELOG.md` and
`SESSION_HANDOFF.md` carry the v0.19.0 promotion + this refresh,
ready to commit.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean (verified this session).
- **Tests:** **1170 passing / 0 failing** across the workspace
(verified this session). One known flake remains:
`solitaire_engine::sync_plugin::tests::pull_failure_sets_error_status`
occasionally fails when cargo-test parallelism starves the
`AsyncComputeTaskPool` within the test's 5-update budget. Same
shape as the auto-save flake before v0.19.0's hardening; could be
fixed similarly with a wall-clock-bounded loop.
- **Tags on origin:** `v0.9.0` through `v0.18.0` (v0.19.0 ready to
push once committed).
## Where we are
Phase 3 (design tokens + modal scaffold) and Phase 4 (release polish) shipped earlier. Phase 5 — running the binary end-to-end and fixing what broke — landed nine more commits today: a layout fit fix so tableau columns stop spilling off-screen, a three-pronged resize-lag fix, persisted window geometry, splash skip on subsequent launches, achievement tooltips, a code-quality sweep, client-side sync round-trip tests, and a hit-test fix so dragging a card no longer requires aiming for the bottom strip.
v0.18.0's resume-prompt menu (AD) is closed:
Polish is essentially complete; the remaining work is tagging v0.1.0 and desktop packaging.
- ~~**A — Tag v0.18.0:**~~ shipped at `bfcd05f`.
- ~~**B — Solver-on-`AsyncComputeTaskPool` for the H-key hint:**~~
shipped at `3e11e9e`.
- **C — Desktop packaging:** still gated on artwork + signing
certs. Icon export PNGs (11 sizes, 161024 px) sit in
`artwork/` from the v0.18-era export; not yet wired into the
Bevy window or assembled into `.icns` / `.ico`. App icon is
the first natural step.
- ~~**D — Persistent share link:**~~ shipped at `42d90b1`.
The Rusty Pixel theme arc is documented as a sub-history but
not part of v0.19.0's content:
| Commit | Status |
|---|---|
| `de47511` PNG-format thumbnail support | reverted |
| `17e3112` `pixel_art: bool` field + nearest-sampling opt-in | reverted |
| `21ec03b` bundle Rusty Pixel as `embedded://` theme | reverted |
| `aad8bb9` / `e41def8` / `0b3140a` reverts | landed |
The arc remains in commit history for archaeology but the
codebase reaches v0.19.0's HEAD identical to where it would be if
the arc had never landed.
### 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.
- **Tone:** Balatro — chunky readable type, theatrical hierarchy,
satisfying micro-interactions.
- **Palette:** Midnight Purple base + Balatro yellow primary + warm
magenta secondary.
## 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 2026-04-30)
## v0.19.0 (2026-05-06)
| Area | Commit | What landed |
| Area | Commits | 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. |
| Score feedback | `dcfa976` | `ScorePulse` triangular 1.0→1.1→1.0; floating "+N" for jumps ≥ threshold. |
| Hit targets | `b082bd6` | `ICON_BUTTON_PX` 28 → 32; sync status reads "local only". |
| 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. |
| Credits | `fd7fb7b`, `f866299` | CREDITS.md added; README links it. |
| Home | `c1bde18` | Home repurposed as Mode Launcher with level-5 lock state. |
| Focus rings (Phase 1) | `1278952` | Tab/Shift-Tab/Enter on every modal button; auto-focus primary. |
| 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 `FocusRow`; auto-scroll. |
| Achievement tests | `2e080d0` | Integration coverage for `draw_three_master` and `zen_winner`. |
| Microcopy | `0c86cac` | "New game" / "Forfeit" replace "Yes, abandon" / "Yes, forfeit". |
| Tooltip infra | `54d3497` | `Tooltip(Cow<'static, str>)` component + hover-delay overlay. |
| HUD tooltips | `220e3f0` | 10 readouts + 6 action buttons. |
| Settings tooltips | `74597a8` | Volume, toggles, swatches, Sync Now. |
| Popover tooltips | `dbe6c60` | Modes and Menu rows. |
| Splash | `5d57b67` | Branded splash overlay (300ms fade-in / ~1s hold / 300ms fade-out). |
| Doc-rot | `73e210b` | ARCHITECTURE.md `bevy_kira_audio` references → `kira`. |
| Doc | `de52c8a`, `60a8036` | Mid-session and end-of-Phase-4 SESSION_HANDOFF refreshes. |
| Async H-key hint | `3e11e9e` | New `pending_hint.rs` module: `PendingHintTask` resource, `poll_pending_hint_task` + `drop_pending_hint_on_state_change` systems, cancel-on-replace, stale-state guard via `move_count_at_spawn`. Removes the last synchronous solver hot path. |
| Persistent share URLs | `42d90b1` | `Replay.share_url: Option<String>` with `#[serde(default)]`. `poll_replay_upload_result` writes into `replays[0].share_url` + persists. Stats Copy button reads from selected replay. `LastSharedReplayUrl` deleted. |
| Auto-save flake fix | `91b7605` | `test_app` clears `PendingRestoredGame(None)` after plugin build; test re-arms the timer in a bounded loop. No production-code change. |
| Wayland support | `b57db01` | Adds `wayland` to Bevy features. winit prefers Wayland when `WAYLAND_DISPLAY` is set, falls back to X11. Native Wayland surface instead of XWayland frame. |
| Smart default window size | `b57db01` | New `apply_smart_default_window_size` Update system queries `PrimaryMonitor` and resizes the window to ~70 % of monitor's logical size on the first frame. Skipped when saved geometry was applied. |
| Win-celebration cleanup | `55c235b` | Drops the duplicate "You Win" toast that rendered behind the WinSummary modal. Cards-fly-off cascade kept; toast removed. |
| Double-click reject animation | `d7ffb16` | Single-card double-clicks with no destination now play the same shake + sound as multi-card stack misses. Both priorities' failure paths converge on one `MoveRejectedEvent` write. |
| Double-click animation dedup | `6037596` | Drops the redundant `StateChangedEvent` write in `end_drag`'s uncommitted-drag branch; previously raced an in-flight CardAnim and restarted the slide visibly. |
## Phase 5 (shipped 2026-05-01)
## Open punch list
Smoke test surfaced three issues: window-resize lag, tableau columns clipped below viewport, hit-target offset on cards. All fixed, plus four bonus polish items.
### Carried forward
| Area | Commit | What landed |
|---|---|---|
| Layout fit | `8dda954` | `card_height` constrained by vertical budget; worst-case 13-card column always fits. |
| Resize perf | `1719fda` | In-place sprite/text mutation + 50ms `ResizeThrottle` (was full re-spawn per pixel). |
| Resize stall | `59316de` | `PresentMode::AutoNoVsync` eliminates the X11/Wayland vsync stall during drag. |
| Window geometry | `6e7705b` | `WindowGeometry` persisted to settings.json; debounced save on resize/move. |
| Achievements | `7448225` | Tooltips on rows: reward shown when unlocked, condition + reward when locked, secrets stay cryptic. |
| Lint sweep | `4b9d008` | 33 pedantic warnings cleared (`map_unwrap_or`, `uninlined_format_args`, `match_same_arms`). |
| Sync tests | `3ef4ecb` | Five client-side round-trip integration tests via in-process axum + mock keyring. |
| Splash | `912b08c` | Splash skipped on subsequent launches via existing `first_run_complete` flag. |
| Hit test | `902560c` | `card_position` mirrors face-down fan step (0.12) for accurate AABB on tableau columns. |
- **Desktop packaging** per `ARCHITECTURE.md §17`. Eleven icon
PNG sizes (16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 1024)
exported via `artwork/Icon Export.html` sit in `artwork/`
pending wiring. Pending: actual Bevy window-icon hookup,
macOS `.icns` assembly via `iconutil`, Windows `.ico` via
`magick convert`, Linux hicolor PNG hierarchy install,
AppImage recipe, macOS notarisation cert, Windows
Authenticode cert.
## Open punch list for v1
### Possible next-round candidates
1. **`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.
2. **Tag `v0.1.0`** — workspace builds clean and tests are green; this is the next strategic milestone.
3. **Desktop packaging** per ARCHITECTURE.md §17 — Docker compose for the server is documented; desktop client packaging (icon, .ico/.icns, signing, AppImage) is not yet done. Needs artwork and signing certs.
- **App icon round** — wire the icon into the Bevy window via
`Window::icon`, generate `.icns` and `.ico` from the existing
PNGs. Half-day task; doesn't depend on signing certs.
- **`pull_failure_sets_error_status` flake fix** — same pattern
as the auto-save flake. Wall-clock-bounded loop instead of
fixed 5-update budget. ~10 lines.
- **Settings UI for "open at this size on launch"** — once the
smart-default-size system is shipping, expose a checkbox to
*disable* it (player who specifically wants 1280×800 every
time). Trivial.
- **Persistent share link URL on selector caption** — surface
whether the currently-selected replay has a `share_url`
populated (e.g. "Replay 3 / 8 \u{2022} Shareable") so players
know which entries the Copy button can copy.
### Optional, deferred
### Process notes (from this round)
- Animated focus ring (currently a static overlay; could pulse on focus change).
- Achievement onboarding pass — show first-time players the achievement panel after their first win.
- Mode-switch keyboard shortcut from inside the Mode Launcher (today only mouse opens it).
- **Async port template (worked again):** the H-key port
followed `d489e7a`'s `PendingNewGameSeed` shape one-to-one
and the second async port required no new infrastructure.
Future async ports (e.g. moving `try_solve_with_first_move`'s
full-search variant, if it ever surfaces in the picker UI)
should follow the same shape.
- **Rusty Pixel reverted cleanly:** `git revert` of three
contiguous feature commits produced a clean three-revert
sequence with no manual conflict resolution. Bisect remains
fast over the full v0.19.0 history because the reverts are
individual commits, not a squash.
- **Defensive event writes pattern:** the
`auto_save_writes_after_30_seconds` flake AND the
`end_drag` double-animation bug shared a root cause:
defensive `MessageWriter` writes that originally covered an
edge case which no longer holds, but became load-bearing
once another system started paying attention to the event.
Worth a periodic pass: any event write that doesn't
correspond to a real state change is a candidate for
removal.
## Resume prompt
```
You are a senior Rust + Bevy developer finishing v1 of Solitaire
Quest. Working directory: /home/manage/Rusty_Solitare. Branch:
master. The 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>.
Branch: master. v0.19.0 just shipped. The next natural item is
desktop-packaging follow-through, starting with the app icon.
State: HEAD=902560c, fully pushed to origin. Working tree clean.
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 906 passed / 0 failed.
State: HEAD at 6037596 + the v0.19.0 docs commit on top (this
session). Tag v0.19.0 points at the docs commit.
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
1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [Unreleased] is empty; [0.19.0] just landed
3. CLAUDE.md — unified-3.0 rule set
4. CLAUDE_SPEC.md — formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow
6. ~/.claude/projects/<this-project>/memory/MEMORY.md
— saved feedback / project context
(machine-local; may be missing on a
fresh machine)
PUNCH LIST (in priority order):
1. Confirm or fill the xCards upstream URL in CREDITS.md (one-line
edit; not a release blocker).
2. Tag v0.1.0 once the user signs off.
3. Desktop packaging: icon hookup, platform bundles (.ico/.icns/
AppImage), signing. Needs artwork and certs from the user.
DECISION TO ASK THE PLAYER FIRST:
A. App icon — wire artwork/icon-{size}.png into Bevy's
Window::icon, generate .icns + .ico, drop into Linux
hicolor hierarchy. Half-day task. No cert dependency.
B. Desktop packaging continued — AppImage recipe, .desktop
file, install scripts. Larger task; unlocks distro
packaging. No cert dependency.
C. macOS / Windows signing cert acquisition — needs user
action; agent can't drive.
D. `pull_failure_sets_error_status` flake fix — small, well-
scoped. Same pattern as the v0.19.0 auto-save flake fix.
WORKFLOW NOTES:
- Commits use:
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..."
- Use the system git config (already correct).
- 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) — gh auth setup-git is already
wired on this machine.
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 AD. Don't pick unilaterally.
```
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 196 KiB

+1 -1
View File
@@ -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
---
+105 -13
View File
@@ -3,15 +3,19 @@ use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use bevy::window::{
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, 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() {
@@ -41,8 +45,10 @@ fn main() {
// 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.
// primary monitor) — `apply_smart_default_window_size` will resize
// up to a monitor-relative target on the first frame so HiDPI / 4K
// sessions don't end up with a comparatively tiny window.
let had_saved_geometry = settings.window_geometry.is_some();
let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => (
(geom.width, geom.height).into(),
@@ -54,7 +60,17 @@ fn main() {
),
};
App::new()
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 {
@@ -91,17 +107,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())
@@ -111,7 +133,7 @@ fn main() {
.add_plugins(TimeAttackPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin)
.add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
@@ -123,8 +145,78 @@ fn main() {
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.run();
.add_plugins(SplashPlugin);
// Smart default window sizing: when no saved geometry was loaded,
// resize the freshly-opened 1280×800 window to ~70 % of the primary
// monitor's logical size on the first frame. Without this, a 4K
// monitor opens the same 1280×800 window that a 1080p monitor
// does — visually tiny relative to screen. Skipped entirely when
// saved geometry was applied; the player's preference always wins.
if !had_saved_geometry {
app.add_systems(Update, apply_smart_default_window_size);
}
app.run();
}
/// One-shot Update system that runs only on launches without saved
/// window geometry. Resizes the primary window to a fraction of the
/// primary monitor's *logical* size — bigger monitors get bigger
/// windows automatically. Logical size already accounts for the OS's
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
/// target window — same physical inches as a 1920×1080 monitor with
/// scale_factor 1.0 yielding 1344×756.
///
/// Uses `Local<bool>` to make itself one-shot rather than introducing
/// a dedicated resource. The Update tick is necessary because Bevy
/// populates the `Monitor` entities asynchronously after winit's
/// Resumed event fires; they may not exist on the first Startup pass.
fn apply_smart_default_window_size(
mut applied: Local<bool>,
monitors: Query<&Monitor, With<PrimaryMonitor>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
if *applied {
return;
}
let Ok(monitor) = monitors.single() else {
// Primary monitor not yet spawned by bevy_winit. Try again
// next frame; the cost is one early-exit per tick until
// monitors arrive (typically frame 1 or 2).
return;
};
let Ok(mut window) = windows.single_mut() else {
return;
};
let scale = monitor.scale_factor as f32;
if scale <= 0.0 {
// Defensive: a zero or negative scale factor would NaN the
// arithmetic below. Bail and accept the default size.
*applied = true;
return;
}
let logical_w = monitor.physical_width as f32 / scale;
let logical_h = monitor.physical_height as f32 / scale;
// Target 70 % of monitor in each dimension, clamped to the
// existing 800×600 minimum and the monitor's own logical size
// (so we never request a window larger than the screen).
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
// Resize only when the change is meaningful — at exactly 1280×800
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
// wider), worth the resize; at the same default on an 800×600
// monitor the clamp pins us at 800×600 and we shouldn't resize.
let curr_w = window.resolution.width();
let curr_h = window.resolution.height();
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
window.resolution.set(target_w, target_h);
}
*applied = true;
}
/// Wraps the default panic hook with one that also appends a crash log
+40 -1
View File
@@ -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).
///
-1
View File
@@ -6,6 +6,5 @@ edition.workspace = true
[dependencies]
serde = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
rand = { workspace = true }
+47
View File
@@ -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();
+6 -31
View File
@@ -77,16 +77,6 @@ pub struct Card {
mod tests {
use super::*;
#[test]
fn rank_value_ace_is_one() {
assert_eq!(Rank::Ace.value(), 1);
}
#[test]
fn rank_value_king_is_thirteen() {
assert_eq!(Rank::King.value(), 13);
}
#[test]
fn rank_values_are_sequential() {
let ranks = [
@@ -100,26 +90,11 @@ mod tests {
}
#[test]
fn suit_red_is_diamonds_and_hearts() {
assert!(Suit::Diamonds.is_red());
assert!(Suit::Hearts.is_red());
assert!(!Suit::Clubs.is_red());
assert!(!Suit::Spades.is_red());
}
#[test]
fn suit_black_is_clubs_and_spades() {
assert!(Suit::Clubs.is_black());
assert!(Suit::Spades.is_black());
assert!(!Suit::Diamonds.is_black());
assert!(!Suit::Hearts.is_black());
}
#[test]
fn card_face_up_field_reflects_construction() {
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false };
assert!(!card.face_up);
let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true };
assert!(card2.face_up);
fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
}
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
}
}
+217 -41
View File
@@ -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,
@@ -763,11 +815,6 @@ mod tests {
assert!(g.undo_stack_len() <= 64);
}
#[test]
fn undo_count_starts_at_zero() {
assert_eq!(new_game().undo_count, 0);
}
#[test]
fn undo_count_increments_on_each_undo() {
let mut g = new_game();
@@ -848,11 +895,6 @@ mod tests {
assert_eq!(g.score, 0);
}
#[test]
fn zen_mode_default_is_classic_via_default_trait() {
assert_eq!(GameMode::default(), GameMode::Classic);
}
#[test]
fn zen_mode_field_persists_through_construction() {
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
@@ -904,12 +946,6 @@ mod tests {
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
}
#[test]
fn time_attack_score_starts_at_zero() {
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
assert_eq!(g.score, 0);
}
#[test]
fn time_attack_draw_three_combination() {
// TimeAttack + DrawThree is a valid combination; verify construction.
@@ -1039,7 +1075,8 @@ mod tests {
let mv = g.next_auto_complete_move().expect("should find a move");
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 +1086,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",
);
}
}
+1
View File
@@ -6,3 +6,4 @@ pub mod game_state;
pub mod pile;
pub mod rules;
pub mod scoring;
pub mod solver;
+38 -5
View File
@@ -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 (06).
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));
}
}
+71 -26
View File
@@ -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),
]));
}
}
+3 -4
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
-5
View File
@@ -90,9 +90,4 @@ mod tests {
seeds.dedup();
assert_eq!(seeds.len(), len_before);
}
#[test]
fn challenge_count_matches_seed_list_length() {
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
}
}
+30 -3
View File
@@ -56,6 +56,15 @@ pub trait SyncProvider: Send + Sync {
async fn delete_account(&self) -> Result<(), SyncError> {
Ok(())
}
/// Upload a winning replay to the backend. On success, returns the
/// shareable web URL the player can copy to their clipboard
/// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
/// so backends without a server (e.g. `LocalOnlyProvider`) are
/// silently no-op'd by the engine's push-on-win system, matching
/// the same pattern `pull` / `push` follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
}
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
@@ -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<String, 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,10 @@ 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, WindowGeometry,
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
};
pub mod auth_tokens;
@@ -136,3 +154,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,
};
+66 -15
View File
@@ -162,21 +162,6 @@ mod tests {
// --- Persistence ---
#[test]
fn round_trip_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let mut p = PlayerProgress::default();
p.add_xp(1234);
p.unlocked_card_backs.push(2);
save_progress_to(&path, &p).expect("save");
let loaded = load_progress_from(&path);
assert_eq!(loaded.total_xp, 1234);
assert_eq!(loaded.level, p.level);
assert!(loaded.unlocked_card_backs.contains(&2));
}
#[test]
fn load_from_missing_file_returns_default() {
let path = tmp_path("missing_xyz");
@@ -298,4 +283,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"
);
}
}
+740
View File
@@ -0,0 +1,740 @@
//! Win-game replay recording + storage.
//!
//! When a player wins, the engine freezes the in-memory recording into a
//! [`Replay`] and persists it to `<data_dir>/solitaire_quest/latest_replay.json`
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
//! action that loads it via [`load_latest_replay_from`] so the player can
//! revisit (or, in a future build, watch the engine re-execute) the path
//! they took to victory.
//!
//! Schema versioning: bump [`REPLAY_SCHEMA_VERSION`] whenever the on-disk
//! shape changes. [`load_latest_replay_from`] returns `None` when the file
//! carries any other version so older replays are silently dropped instead
//! of crashing the loader.
//!
//! The recording is intentionally minimal — only [`ReplayMove`] entries
//! that successfully advanced the game. `Undo` is **not** recorded: a
//! replay represents the canonical path the player ultimately took to win,
//! so backed-out missteps simply do not appear in the move list. The
//! starting deal is not stored either — the [`seed`](Replay::seed) +
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
//! for `GameState::new_with_mode` to rebuild the identical layout.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
const APP_DIR_NAME: &str = "solitaire_quest";
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
/// Maximum number of recent winning replays the rolling history retains.
///
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
/// the oldest entry is dropped so the file never grows unbounded. The
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
/// the Stats overlay's replay selector — older wins age out silently.
pub const REPLAY_HISTORY_CAP: usize = 8;
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
/// returns `None` for older files (the player simply sees an empty
/// history rather than a half-loaded broken one). Bumping
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
/// [`Replay`] payloads inside an otherwise-current history.
///
/// History:
/// - v1 (current): initial release of the rolling history wrapper.
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
/// Default value for [`ReplayHistory::schema_version`] when deserialising
/// files that pre-date the field. Any value other than
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
/// to return `None`.
fn history_schema_v0() -> u32 {
0
}
/// Save-file schema version for [`Replay`]. Increment when the on-disk
/// representation changes incompatibly so [`load_latest_replay_from`] can
/// reject older formats and the player simply has no replay rather than
/// seeing a broken one.
///
/// History:
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
/// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input.
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
/// variant. The engine resolves draw-vs-recycle deterministically from
/// the current stock state, so the input alone is sufficient and the
/// replay model now stores atomic player inputs end-to-end.
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
/// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
/// causes [`load_latest_replay_from`] to return `None`.
fn schema_v0() -> u32 {
0
}
/// One atomic player input recorded during a winning game, in the order
/// it was applied to the live `GameState`.
///
/// `Undo` is intentionally absent — see the module-level docs.
///
/// The variants represent *inputs*, not outcomes. `StockClick` covers
/// every player click on the stock pile; the engine then resolves
/// draw-vs-recycle deterministically from the current state during both
/// recording and playback, so the same input always produces the same
/// effect on the same starting deal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call.
Move {
/// Source pile.
from: PileType,
/// Destination pile.
to: PileType,
/// Number of cards moved.
count: usize,
},
/// A click on the stock pile. Resolves to a draw when stock is
/// non-empty and to a waste→stock recycle when stock is empty.
StockClick,
}
/// A complete recording of a single winning game.
///
/// Replays are reconstructed by rebuilding a fresh
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
/// [`moves`](Self::moves) in order. The presentation fields
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
/// "Replay (2:14 win on 2026-05-02)".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Replay {
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
#[serde(default = "schema_v0")]
pub schema_version: u32,
/// Seed used for the deal — replay rasterises the deck via
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
pub seed: u64,
/// Draw mode the recorded game was played in.
pub draw_mode: DrawMode,
/// Game mode the recorded game was played in.
pub mode: GameMode,
/// Total wall-clock seconds the win took. Used for the Stats UI
/// "Replay (2:14 win on 2026-05-02)" caption.
pub time_seconds: u64,
/// Final score at the moment of the win.
pub final_score: i32,
/// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate,
/// Ordered move list. Each entry is what the player did, replayable
/// against a fresh `GameState` constructed from the seed.
pub moves: Vec<ReplayMove>,
/// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only
/// backend, the upload failed, or the replay pre-dates v0.19.0
/// share-link persistence. `#[serde(default)]` keeps older
/// `replays.json` files loadable without bumping
/// [`REPLAY_SCHEMA_VERSION`].
#[serde(default)]
pub share_url: Option<String>,
}
impl Replay {
/// Construct a fresh replay with the current schema version. The
/// caller fills in the recorded fields; this is the canonical
/// constructor used by the engine on win.
pub fn new(
seed: u64,
draw_mode: DrawMode,
mode: GameMode,
time_seconds: u64,
final_score: i32,
recorded_at: NaiveDate,
moves: Vec<ReplayMove>,
) -> Self {
Self {
schema_version: REPLAY_SCHEMA_VERSION,
seed,
draw_mode,
mode,
time_seconds,
final_score,
recorded_at,
moves,
share_url: None,
}
}
}
/// Rolling history of the player's most recent winning replays.
///
/// Stored as a single JSON file at
/// `<data_dir>/solitaire_quest/replays.json` (see
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
/// when [`append_replay_to_history`] pushes past the cap, the oldest
/// entry is dropped so the file never grows unbounded.
///
/// `replays[0]` is always the most recent win; the Stats overlay's
/// replay selector defaults to that entry and surfaces the older
/// entries behind a small chooser so the player can revisit a memorable
/// game even after a more recent win.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplayHistory {
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
#[serde(default = "history_schema_v0")]
pub schema_version: u32,
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
/// older entries drop off when the cap is hit.
pub replays: Vec<Replay>,
}
impl Default for ReplayHistory {
/// An empty history at the current schema version. Used by callers
/// that need a starting point before the first winning replay has
/// ever been recorded.
fn default() -> Self {
Self {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: Vec::new(),
}
}
}
impl ReplayHistory {
/// Returns the most recent replay (`replays[0]`), or `None` when the
/// history is empty. Convenience used by the Stats overlay's default
/// selector position.
pub fn most_recent(&self) -> Option<&Replay> {
self.replays.first()
}
/// Returns the number of replays currently retained.
pub fn len(&self) -> usize {
self.replays.len()
}
/// Returns `true` when no replays have been recorded yet.
pub fn is_empty(&self) -> bool {
self.replays.is_empty()
}
}
/// Returns the platform-specific path to `latest_replay.json`, or `None`
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
#[deprecated(
note = "single-slot replay storage replaced by the rolling history at \
replay_history_path(); kept for the one-shot legacy migration \
in migrate_legacy_latest_replay"
)]
pub fn latest_replay_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
}
/// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `dirs::data_dir()` is unavailable (e.g.
/// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
}
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
/// rename contract that the rest of `storage.rs` uses.
///
/// Overwrites any existing replay — only the most recent winning replay
/// is retained on disk.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
use append_replay_to_history instead. Kept for the one-shot \
legacy migration."
)]
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(replay).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Load a [`Replay`] from `path`, returning `None` when the file is
/// missing, corrupt, or carries a [`schema_version`](Replay::schema_version)
/// other than [`REPLAY_SCHEMA_VERSION`].
///
/// Schema-mismatch is treated as "no replay" so the player just sees the
/// "No replay recorded yet" caption rather than a half-loaded broken
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
/// older save without further migration code.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
use load_replay_history_from instead. Kept for the one-shot \
legacy migration."
)]
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
let data = fs::read(path).ok()?;
let replay: Replay = serde_json::from_slice(&data).ok()?;
if replay.schema_version != REPLAY_SCHEMA_VERSION {
return None;
}
Some(replay)
}
/// Save a [`ReplayHistory`] atomically to `path` using the standard
/// `.tmp` → rename contract.
///
/// The on-disk encoding is pretty-printed JSON; the file is intended to
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
/// hundred move records at most) so the readability tradeoff is fine.
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
///
/// Individual [`Replay`] entries inside an otherwise-current history are
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
/// entries are silently dropped so a future bump of the inner replay
/// schema does not corrupt the wrapper.
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
let data = fs::read(path).ok()?;
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
return None;
}
let filtered: Vec<Replay> = history
.replays
.into_iter()
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
.collect();
Some(ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: filtered,
})
}
/// Append `replay` to the front of the rolling history at `path`,
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
/// and persist the updated history atomically.
///
/// If `path` has no existing history (missing file, corrupt, or
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
/// starting point so the new replay is always saved. The returned
/// [`ReplayHistory`] is the exact value written to disk so callers can
/// update an in-memory mirror (e.g. the Stats overlay's
/// `ReplayHistoryResource`) without a follow-up `load`.
pub fn append_replay_to_history(
path: &Path,
replay: Replay,
) -> io::Result<ReplayHistory> {
let mut history = load_replay_history_from(path).unwrap_or_default();
// Most recent first. Reserve the front slot; pop the oldest if we
// exceed the cap so the file never grows unbounded.
history.replays.insert(0, replay);
if history.replays.len() > REPLAY_HISTORY_CAP {
history.replays.truncate(REPLAY_HISTORY_CAP);
}
save_replay_history_to(path, &history)?;
Ok(history)
}
/// One-shot migration from the legacy single-slot
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
/// `history_path`.
///
/// Behaviour matrix:
/// - `history_path` already exists → no-op (the rolling history wins).
/// - `history_path` is absent and `latest_path` is absent → no-op.
/// - `history_path` is absent and `latest_path` exists with a valid
/// replay → seed a fresh history with that one replay and write it.
/// - `history_path` is absent and `latest_path` exists but is corrupt /
/// schema-mismatched → write an empty history (we know the player is
/// on the new build and shouldn't keep being prompted to migrate).
///
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
/// this helper — keep it for one release as a safety net so a player
/// rolling back to the previous build doesn't lose their last winning
/// replay. The deletion is planned for the release after this one.
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
if history_path.exists() {
// Rolling history is authoritative once it exists.
return;
}
if !latest_path.exists() {
return;
}
// Use the deprecated loader directly — the migration is the one
// place we still consult the legacy file shape on purpose.
#[allow(deprecated)]
let legacy = load_latest_replay_from(latest_path);
let history = match legacy {
Some(replay) => ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay],
},
None => ReplayHistory::default(),
};
if let Err(e) = save_replay_history_to(history_path, &history) {
// Migration failure is non-fatal: on the next launch we'll just
// try again. We log to stderr rather than panic so headless
// tests stay quiet.
eprintln!(
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
);
}
}
#[cfg(test)]
// The legacy single-slot tests still exercise `save_latest_replay_to` /
// `load_latest_replay_from` on purpose — they're the round-trip
// guardrails for the migration source format.
#[allow(deprecated)]
mod tests {
use super::*;
use std::env;
fn tmp_path(name: &str) -> PathBuf {
env::temp_dir().join(format!("solitaire_test_replay_{name}.json"))
}
fn sample_replay() -> Replay {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
12345,
DrawMode::DrawThree,
GameMode::Classic,
134,
5_120,
date,
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(3),
count: 1,
},
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Tableau(3),
to: PileType::Foundation(0),
count: 1,
},
],
)
}
/// A non-trivial replay with mixed move kinds must round-trip
/// byte-identically through `save_latest_replay_to` /
/// `load_latest_replay_from`. Catches any future field that forgets
/// `Serialize`/`Deserialize` or breaks the on-disk format.
#[test]
fn replay_round_trips_through_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let replay = sample_replay();
save_latest_replay_to(&path, &replay).expect("save");
let loaded = load_latest_replay_from(&path).expect("load must succeed");
assert_eq!(loaded, replay, "round-trip must preserve every field");
let _ = fs::remove_file(&path);
}
/// A file written by an older schema (or a pre-`schema_version`
/// build) must be rejected. We write a minimal v0 fixture and assert
/// that `load_latest_replay_from` returns `None` so the player gets
/// a clean "no replay" state instead of a broken one.
#[test]
fn replay_legacy_schema_version_falls_through_to_none() {
let path = tmp_path("legacy_schema");
let _ = fs::remove_file(&path);
// No `schema_version` key — defaults to 0 via `schema_v0()`. Even
// if the rest of the JSON parses cleanly, the version gate must
// reject it.
let v0_json = r#"{
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2025-01-01",
"moves": []
}"#;
fs::write(&path, v0_json).expect("write v0 fixture");
assert!(
load_latest_replay_from(&path).is_none(),
"v0 replay must be rejected (schema gate)",
);
let _ = fs::remove_file(&path);
}
/// Backwards-compat: a `Replay` record persisted before v0.19.0
/// share-link persistence carries no `share_url` field on disk.
/// `#[serde(default)]` must let it deserialise cleanly with
/// `share_url == None`, so existing players don't see their
/// rolling history wiped on the v0.19.0 update.
#[test]
fn replay_loads_when_share_url_field_is_absent() {
let pre_v019_json = format!(
r#"{{
"schema_version": {schema},
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2025-01-01",
"moves": []
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
let parsed: Replay = serde_json::from_str(&pre_v019_json)
.expect("pre-v0.19.0 replay JSON must still deserialise");
assert!(
parsed.share_url.is_none(),
"missing share_url field must default to None",
);
}
/// Atomic-write contract — `.tmp` must not be left behind after
/// `save_latest_replay_to` returns. Mirrors the same check that
/// guards `save_game_state_to` in `storage.rs`.
#[test]
fn replay_save_is_atomic() {
let path = tmp_path("atomic");
let _ = fs::remove_file(&path);
save_latest_replay_to(&path, &sample_replay()).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
/// Loading from a path that does not exist must return `None`, not
/// panic or surface an `Err`.
#[test]
fn replay_missing_file_returns_none() {
let path = tmp_path("missing_xyz");
let _ = fs::remove_file(&path);
assert!(load_latest_replay_from(&path).is_none());
}
/// Loading from a corrupt / partially-written file must return
/// `None`, not surface a deserialiser error to the engine.
#[test]
fn replay_corrupt_file_returns_none() {
let path = tmp_path("corrupt");
fs::write(&path, b"not valid json!!!").expect("write");
assert!(load_latest_replay_from(&path).is_none());
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// ReplayHistory — rolling list of recent wins
// -----------------------------------------------------------------------
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
/// assert ordering / identity without writing a deep equality match.
fn replay_with_id(id: i32) -> Replay {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
id as u64,
DrawMode::DrawOne,
GameMode::Classic,
60,
id,
date,
vec![ReplayMove::StockClick],
)
}
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
/// the on-disk file (and the in-memory mirror returned by the helper)
/// stays bounded so the user's data dir never grows unbounded.
#[test]
fn append_replay_to_history_caps_at_eight() {
let path = tmp_path("history_cap");
let _ = fs::remove_file(&path);
let mut last_returned = ReplayHistory::default();
for i in 0..10 {
last_returned = append_replay_to_history(&path, replay_with_id(i))
.expect("append must succeed");
}
assert_eq!(
last_returned.replays.len(),
REPLAY_HISTORY_CAP,
"history must be capped at REPLAY_HISTORY_CAP entries",
);
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
// survive (newest first), ids 0 and 1 aged out.
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![9, 8, 7, 6, 5, 4, 3, 2],
"newest entries must survive, oldest must age out",
);
// The on-disk file must agree with the returned in-memory copy.
let loaded = load_replay_history_from(&path).expect("load must succeed");
assert_eq!(loaded, last_returned, "disk must mirror returned history");
let _ = fs::remove_file(&path);
}
/// `append_replay_to_history` must place new entries at index 0 so
/// the Stats overlay's default selector (most recent) lands on the
/// just-saved replay.
#[test]
fn append_replay_inserts_at_front() {
let path = tmp_path("history_front");
let _ = fs::remove_file(&path);
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![3, 2, 1],
"history must be reverse-chronological (newest first)",
);
let _ = fs::remove_file(&path);
}
/// On first launch with the new code, a pre-existing
/// `latest_replay.json` must seed the new rolling history so the
/// player doesn't lose their last winning replay across the upgrade.
#[test]
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
let latest = tmp_path("legacy_migrate_latest");
let history = tmp_path("legacy_migrate_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
// Seed the legacy file with a real replay.
let legacy_replay = sample_replay();
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
assert!(!history.exists(), "history file must not exist pre-migration");
migrate_legacy_latest_replay(&latest, &history);
assert!(history.exists(), "migration must create the history file");
let loaded = load_replay_history_from(&history)
.expect("post-migration history must load");
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
// Legacy file is intentionally retained for one release as a
// safety net — see `migrate_legacy_latest_replay` doc comment.
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// When the rolling history file already exists, the migration must
/// be a no-op — we never want to overwrite the player's accumulated
/// history with a stale single-slot legacy entry.
#[test]
fn migrate_is_noop_when_history_already_exists() {
let latest = tmp_path("legacy_noop_latest");
let history = tmp_path("legacy_noop_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
let pre_existing = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(42)],
};
save_replay_history_to(&history, &pre_existing).expect("seed history");
migrate_legacy_latest_replay(&latest, &history);
let loaded = load_replay_history_from(&history).expect("load");
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// A populated [`ReplayHistory`] must round-trip byte-identically
/// through `save_replay_history_to` / `load_replay_history_from`.
#[test]
fn replay_history_round_trips_through_save_and_load() {
let path = tmp_path("history_round_trip");
let _ = fs::remove_file(&path);
let history = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
};
save_replay_history_to(&path, &history).expect("save");
let loaded = load_replay_history_from(&path).expect("load");
assert_eq!(loaded, history, "round-trip must preserve every field");
let _ = fs::remove_file(&path);
}
/// A file written by an older history schema must be rejected so the
/// player sees a clean empty history rather than a half-loaded one.
#[test]
fn replay_history_legacy_schema_version_falls_through_to_none() {
let path = tmp_path("history_legacy_schema");
let _ = fs::remove_file(&path);
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
let v0_json = r#"{
"replays": []
}"#;
fs::write(&path, v0_json).expect("write v0 fixture");
assert!(
load_replay_history_from(&path).is_none(),
"v0 history must be rejected (schema gate)",
);
let _ = fs::remove_file(&path);
}
/// Atomic-write contract for the rolling history — `.tmp` must not be
/// left behind after `save_replay_history_to` returns.
#[test]
fn replay_history_save_is_atomic() {
let path = tmp_path("history_atomic");
let _ = fs::remove_file(&path);
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
}
+261 -216
View File
@@ -124,6 +124,74 @@ pub struct Settings {
/// `None` thanks to `#[serde(default)]`.
#[serde(default)]
pub window_geometry: Option<WindowGeometry>,
/// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"default"` is the bundled
/// theme and is always present in the registry; user-supplied
/// themes register under their own ids when they're imported.
/// Older `settings.json` files default cleanly to `"default"` via
/// `#[serde(default = ...)]`.
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been
/// shown to the player after their very first win. Acts as a
/// one-shot teach: subsequent wins must not re-fire the cue. Older
/// `settings.json` files written before this field existed
/// deserialize cleanly to `false` thanks to `#[serde(default)]` —
/// players who already had wins recorded before this field was
/// introduced are guarded by the post-condition `games_won == 1`
/// checked by `achievement_plugin::fire_achievement_onboarding_toast`,
/// so the toast still does not fire for them.
#[serde(default)]
pub shown_achievement_onboarding: bool,
/// Hover delay (seconds) before a tooltip appears. Range
/// `[0.0, 1.5]`; default matches `MOTION_TOOLTIP_DELAY_SECS` (0.5 s).
/// `0.0` means tooltips fire on the very next tick after hover —
/// the "Instant" setting. Older `settings.json` files written before
/// this field existed deserialize cleanly to the default via
/// `#[serde(default = "default_tooltip_delay")]`.
#[serde(default = "default_tooltip_delay")]
pub tooltip_delay_secs: f32,
/// Multiplier applied to the post-game time-bonus score component
/// shown in the win-summary modal. Range
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`
/// (`0.0``2.0`); default `1.0` keeps the existing behaviour.
///
/// **COSMETIC ONLY** — this multiplier changes what the player
/// sees in the win modal's score breakdown but does **not** affect
/// achievement unlock thresholds, lifetime score totals, or
/// leaderboard submissions, which all use the raw, unmultiplied
/// score values produced by `solitaire_core`. Older
/// `settings.json` files written before this field existed
/// deserialize cleanly to `1.0` via
/// `#[serde(default = "default_time_bonus_multiplier")]`.
#[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// [`solitaire_core::solver`] cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the
/// pathological deals that hit the budget cap, and not every
/// player wants to wait. Older `settings.json` files written
/// before this field existed deserialize cleanly to `false` via
/// `#[serde(default)]`.
///
/// Scope: only random-seed Classic-mode deals are filtered.
/// Daily challenges, replays, and explicit-seed requests skip the
/// solver retry loop — see `solitaire_engine::handle_new_game`.
#[serde(default)]
pub winnable_deals_only: bool,
/// Per-move duration during replay playback, in seconds. Range
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
/// (0.45 s/move) so existing playback behaviour is unchanged for
/// players who never touch the slider. Smaller values scrub
/// faster through the recorded move list. Older `settings.json`
/// files written before this field existed deserialize cleanly to
/// the default via
/// `#[serde(default = "default_replay_move_interval_secs")]`.
#[serde(default = "default_replay_move_interval_secs")]
pub replay_move_interval_secs: f32,
}
fn default_draw_mode() -> DrawMode {
@@ -138,6 +206,87 @@ 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
}
/// Default per-move duration during replay playback, in seconds.
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
/// so legacy `settings.json` files load to the existing baseline and
/// playback feels identical for players who never touch the slider.
/// The constant is duplicated across the data and engine crates
/// because `solitaire_data` cannot depend on the engine crate — keep
/// the two values in sync when adjusting either.
fn default_replay_move_interval_secs() -> f32 {
0.45
}
/// Lower bound of the player-tunable replay-playback per-move interval,
/// in seconds. Below this the cards barely register visually before
/// the next move fires; the cap keeps the playback legible.
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
/// Upper bound of the player-tunable replay-playback per-move interval,
/// in seconds. One second per move is a comfortable upper limit for
/// players who want to study a recorded game frame by frame.
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
/// Increment applied by the replay-playback decrement / increment
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
/// without making the slider feel stuck on the same value.
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
/// every retry comes back [`SolverResult::Unwinnable`] (which would
/// be very unusual) we'd rather hand the player a possibly-unwinnable
/// deal than spin forever on the main thread.
///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on.
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
impl Default for Settings {
fn default() -> Self {
Self {
@@ -152,17 +301,34 @@ impl Default for Settings {
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,
replay_move_interval_secs: default_replay_move_interval_secs(),
}
}
}
impl Settings {
/// Clamps both `sfx_volume` and `music_volume` into `[0.0, 1.0]` after
/// deserialization or hand-editing of `settings.json`.
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
/// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self {
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),
replay_move_interval_secs: self
.replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
..self
}
}
@@ -178,6 +344,44 @@ 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
}
/// Adjust the replay-playback per-move interval by `delta`
/// seconds, clamped to
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
/// The result is rounded to two decimal places so the readout
/// stays clean across repeated `±` clicks at the 0.05 s step
/// (avoids float drift like `0.45000003`). Returns the new value.
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
let raw = (self.replay_move_interval_secs + delta)
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
// Round to 2 decimal places — the slider step is 0.05, so this
// collapses any FP drift introduced by repeated additions.
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
self.replay_move_interval_secs
}
}
/// Returns the platform-specific path to `settings.json`, or `None` if
@@ -218,18 +422,6 @@ mod tests {
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
}
#[test]
fn defaults_are_reasonable() {
let s = Settings::default();
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
assert!((s.music_volume - 0.5).abs() < 1e-6);
assert!(!s.first_run_complete);
assert_eq!(s.draw_mode, DrawMode::DrawOne);
assert_eq!(s.animation_speed, AnimSpeed::Normal);
assert_eq!(s.theme, Theme::Green);
assert_eq!(s.sync_backend, SyncBackend::Local);
}
#[test]
fn adjust_sfx_volume_clamps() {
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
@@ -262,71 +454,6 @@ mod tests {
assert!(s.first_run_complete);
}
#[test]
fn sanitized_clamps_music_volume() {
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
assert_eq!(s.music_volume, 1.0);
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
assert_eq!(s2.music_volume, 0.0);
}
#[test]
fn round_trip_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
sfx_volume: 0.42,
first_run_complete: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded, s);
}
#[test]
fn round_trip_save_and_load_full_settings() {
let path = tmp_path("round_trip_full");
let _ = fs::remove_file(&path);
let s = Settings {
draw_mode: DrawMode::DrawThree,
sfx_volume: 0.3,
music_volume: 0.7,
animation_speed: AnimSpeed::Fast,
theme: Theme::Dark,
sync_backend: SyncBackend::SolitaireServer {
url: "https://example.com".to_string(),
username: "testuser".to_string(),
},
selected_card_back: 0,
selected_background: 0,
first_run_complete: true,
color_blind_mode: false,
window_geometry: None,
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded, s);
}
#[test]
fn round_trip_preserves_non_default_cosmetic_selections() {
// selected_card_back and selected_background must survive save→load with
// non-zero values — zero is the default and not a meaningful regression check.
let path = tmp_path("cosmetic_selections");
let _ = fs::remove_file(&path);
let s = Settings {
selected_card_back: 3,
selected_background: 2,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded.selected_card_back, 3);
assert_eq!(loaded.selected_background, 2);
}
#[test]
fn load_from_missing_file_returns_default() {
let path = tmp_path("missing_xyz");
@@ -344,152 +471,70 @@ mod tests {
}
#[test]
fn load_from_old_format_uses_defaults_for_new_fields() {
// Simulate a settings.json written by an older version that only had
// sfx_volume and first_run_complete.
let path = tmp_path("old_format");
fs::write(
&path,
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
)
.expect("write");
let s = load_settings_from(&path);
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
assert!(s.first_run_complete);
// New fields should fall back to their defaults.
assert!((s.music_volume - 0.5).abs() < 1e-6);
assert_eq!(s.animation_speed, AnimSpeed::Normal);
assert_eq!(s.theme, Theme::Green);
assert_eq!(s.sync_backend, SyncBackend::Local);
assert_eq!(s.draw_mode, DrawMode::DrawOne);
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
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 color_blind_mode_defaults_to_false_when_field_absent() {
// Simulate a JSON file that has no color_blind_mode field.
let json = br#"{ "sfx_volume": 0.7 }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
}
#[test]
fn color_blind_mode_round_trips() {
let path = tmp_path("color_blind");
let _ = std::fs::remove_file(&path);
let s = Settings {
color_blind_mode: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
let _ = std::fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// Task #62 — selected_card_back
// -----------------------------------------------------------------------
#[test]
fn settings_card_back_default_is_zero() {
assert_eq!(Settings::default().selected_card_back, 0);
}
#[test]
fn settings_card_back_serializes_round_trip() {
let path = tmp_path("card_back_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
selected_card_back: 2,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip");
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// Task #63 — selected_background
// -----------------------------------------------------------------------
#[test]
fn settings_background_default_is_zero() {
assert_eq!(Settings::default().selected_background, 0);
}
#[test]
fn settings_background_serializes_round_trip() {
let path = tmp_path("background_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
selected_background: 3,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// window_geometry — persisted window size/position
// -----------------------------------------------------------------------
#[test]
fn settings_window_geometry_default_is_none() {
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!(
Settings::default().window_geometry.is_none(),
"default window_geometry must be None so first launch uses platform defaults"
(s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6
);
}
#[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();
// Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN.
assert!(
s.window_geometry.is_none(),
"legacy settings.json missing window_geometry must deserialize to None"
(s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6
);
assert_eq!(s.time_bonus_multiplier, 0.0);
// Repeated incremental adds must not drift past the 0.1 grid.
let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() };
for _ in 0..10 {
s2.adjust_time_bonus_multiplier(0.1);
}
// After ten +0.1 steps, value should be exactly 1.0 (1 decimal).
assert!(
(s2.time_bonus_multiplier - 1.0).abs() < 1e-6,
"rounding should pin repeated 0.1 steps to the decimal grid, got {}",
s2.time_bonus_multiplier
);
}
#[test]
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());
fn adjust_replay_move_interval_clamps_and_rounds() {
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
// Step down to 0.40.
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
// Big positive jump clamps to MAX.
assert!(
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
);
// Big negative jump clamps to MIN.
assert!(
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
);
// Repeated 0.05 steps must not drift past the 0.05 grid.
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
for _ in 0..6 {
s2.adjust_replay_move_interval(0.05);
}
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
assert!(
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
s2.replay_move_interval_secs
);
}
}
+177 -2
View File
@@ -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);
}
}
+372 -2
View File
@@ -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.
@@ -331,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);
}
}
+64
View File
@@ -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,69 @@ impl SyncProvider for SolitaireServerClient {
extract_leaderboard_body(resp).await
}
/// Upload a winning replay to `POST /api/replays`. On success the
/// server returns `{ "id": "<uuid>" }`; this method composes that
/// id with the configured base URL into the player-shareable
/// `<base>/replays/<id>` link and returns it. Mirrors the `push`
/// auth flow: 401 triggers a token refresh and one retry.
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/replays", self.base_url);
let resp = self
.client
.post(&url)
.bearer_auth(&token)
.json(replay)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
.post(&url)
.bearer_auth(new_token)
.json(replay)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
return self.share_url_from_response(resp).await;
}
self.share_url_from_response(resp).await
}
}
impl SolitaireServerClient {
/// Pulled out of `push_replay` so both the first attempt and the
/// post-401-retry attempt go through the same parse path.
async fn share_url_from_response(
&self,
resp: reqwest::Response,
) -> Result<String, SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth(format!("server returned {status}"))
} else {
SyncError::Network(format!("server returned {status}"))
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let id = body["id"].as_str().ok_or_else(|| {
SyncError::Serialization("upload response missing `id`".into())
})?;
Ok(format!("{}/replays/{}", self.base_url, id))
}
}
// ---------------------------------------------------------------------------
+10
View File
@@ -13,6 +13,16 @@ 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 }
arboard = { 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

Some files were not shown because too many files have changed in this diff Show More