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>
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).
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.
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).
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).
Server-side endpoint tests already exist in solitaire_server. This
adds the client-side counterpart: five integration tests in
solitaire_data/tests/sync_round_trip.rs that drive
SolitaireServerClient against an in-process axum::serve harness with
an in-memory SQLite database, covering:
- register_login_push_pull_round_trip — happy path: register, push
non-default stats, pull from a fresh client, assert the merged
payload reflects the pushed values
- pull_after_concurrent_pushes_merges_correctly — two clients on one
user push different games_played values, verify the server-side
merge returns the max
- unauthenticated_pull_returns_authentication_error — pull without
tokens surfaces SyncError::Auth as expected
- jwt_refresh_on_401_succeeds — replace the access token with one
whose exp is two hours stale (same signing key), pull triggers
401 → /api/auth/refresh → retry, asserts the call ultimately
succeeds
- pull_after_account_deletion_returns_default_or_error — register,
push, delete via the trait, confirm the next push surfaces a
result rather than panicking
keyring_core's mock store is installed once per process via Once;
each test uses a unique username so the shared store doesn't
cross-contaminate. Production code in sync_client.rs needed no
changes — the Box<dyn SyncProvider> design plus the mock keyring
were sufficient to drive every flow from outside.
solitaire_server is added as a path dev-dependency along with the
direct crates the harness needs (axum, sqlx, jsonwebtoken, uuid,
chrono, solitaire_sync); no runtime deps changed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single shared face.png placeholder with 52 individual card
face images (120×168 px each), generated by the updated gen_art tool:
- solitaire_assetgen: add ab_glyph dep; rewrite gen_art to render each
card with FiraMono rank characters, programmatic suit symbols (heart,
spade, diamond, club drawn via circles/triangles), and standard pip
layout for numbered cards (A–10) plus large face letter for J/Q/K.
- CardImageSet: replace single `face` handle with `faces: [[Handle; 13]; 4]`
indexed by [suit][rank].
- card_sprite(): select the per-card face image by suit/rank indices.
- spawn/update_card_entity: suppress Text2d overlay when PNG faces are
loaded (rank/suit baked into image); keep overlay in solid-colour
fallback for tests.
- gen_sfx.rs: rename `gen` variable to `make` (reserved keyword in 2024).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Art pass (Phase 4):
- Generate placeholder PNG assets: face.png, back_0–4.png, bg_0–4.png via
solitaire_assetgen gen_art binary (16×16 RGBA, embedded via include_bytes!)
- Add FiraMono-Medium font (assets/fonts/main.ttf) embedded at compile time
- Add FontPlugin: loads font at startup, exposes FontResource; gracefully
falls back to default handle when Assets<Font> absent (MinimalPlugins tests)
- Wire CardImageSet into card_plugin: face/back PNGs replace solid-colour
sprites when available; tests continue using colour fallback via MinimalPlugins
- Wire BackgroundImageSet into table_plugin: bg PNGs replace solid-colour
background; empty set inserted when Assets<Image> absent in tests
- Fix hint highlight system (input_plugin): tint sprite.color directly instead
of replacing the whole Sprite (which would discard the image handle)
- Export FontPlugin, FontResource, CardImageSet from solitaire_engine::lib
- Register FontPlugin in solitaire_app before other plugins
Dependency upgrades (latest releases):
- keyring "2" → keyring "4" + keyring-core "1" (v4 split architecture into
separate core library crate)
- auth_tokens.rs: Entry::new now returns Result; delete_password →
delete_credential; NoDefaultStore error variant handled
- solitaire_app: add keyring::use_native_store(true) at startup for Linux
Secret Service / macOS Keychain / Windows Credential Store selection
ARCHITECTURE.md: fix Edition 2025→2021, update asset pipeline section,
add FontPlugin/CardImageSet/BackgroundImageSet to plugin and resource tables,
update Section 14 to reflect actual include_bytes!() rendering approach,
add Decision Log entries for embedded PNG and font decisions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deletes the solitaire_gpgs crate and all GPGS references from settings,
sync client, profile plugin, CLAUDE.md, and ARCHITECTURE.md. The
self-hosted server covers all sync needs without the Android-only backend.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Import paths simplified: manager/tween modules re-exported from kira root
(AudioManager, AudioManagerSettings, DefaultBackend, Tween all via kira::*)
- Volume::Amplitude removed; replaced with Value<Decibels> using a new
amplitude_to_decibels() helper (20*log10 conversion, clamps to SILENCE)
- output_destination field removed from StaticSoundSettings; sounds routed
to sub-tracks by calling TrackHandle::play() directly instead of
AudioManager::play()
- set_volume() now accepts f32 (Decibels) not f64
- start_ambient_loop signature updated to take &mut Option<TrackHandle>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BorderRadius is no longer a Component; moved into Node.border_radius
field at all 15 spawn sites across 6 plugin files
- Events<T> renamed to Messages<T> in test code (12 files)
- KeyboardEvents SystemParam renamed to KeyboardMessages to match the
MessageWriter rename done in the 0.17 hop
- WindowResolution::from((f32,f32)) removed; use (u32,u32) tuple in main.rs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Event/EventReader/EventWriter renamed to Message/MessageReader/MessageWriter
- add_event → add_message for all 67 call sites
- ScrollPosition changed to tuple struct ScrollPosition(Vec2)
- CursorIcon import moved from bevy::winit::cursor to bevy::window
- WindowResolution::from((f32,f32)) removed — use (u32,u32) tuple
- World::send_event → World::write_message in test code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves 15 violations found by `cargo clippy --workspace --tests -D warnings`:
- Remove unused imports (Card, Rank) in cursor_plugin tests
- Replace absurd i32::MAX comparison with a meaningful >= 0 check
- Use range .contains() instead of manual >= && <= (manual_range_contains)
- Move impl FromRequestParts before test module in middleware.rs (items_after_test_module)
- Move _VEC3_REFERENCED const before test module in input_plugin.rs
- Convert runtime assert on constant to const { assert!(...) }
- Use .contains() instead of .iter().any() for slice membership
- Replace .get(...).is_none() with !.contains_key(...) in HashMap checks
- Collapse Default::default() + field assignment into struct literal initializers
across solitaire_sync, solitaire_data, and solitaire_engine test helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily
challenge, account deletion, JWT middleware, rate limiting via
tower_governor, SQLite migrations, health endpoint
- solitaire_server: expose build_test_router (no rate limiting) so
integration tests work without a peer IP in oneshot requests
- solitaire_sync: SyncPayload, merge logic, shared API types
- solitaire_data: SyncProvider trait, LocalOnlyProvider,
SolitaireServerClient, auth_tokens keyring integration, blanket
Box<dyn SyncProvider> impl
- solitaire_data/settings: derive Default on SyncBackend (clippy fix)
- .sqlx/: offline query cache so server compiles without a live DB
- sqlx: removed non-existent "offline" feature flag
- keyring v2: fixed Entry::new() returning Result<Entry>
- sqlx 0.8: all SQLite TEXT columns wrapped in Option<T>
- Integration tests: max_connections(1) on in-memory pool so all
connections share the same schema
All 191 tests pass; cargo clippy -D warnings clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New solitaire_assetgen crate with gen_sfx binary: synthesizes
five 44.1kHz mono 16-bit PCM WAVs (flip/place/deal/invalid/fanfare)
from an LCG noise source + sine/square synths. Output committed
under assets/audio/.
- AudioPlugin (engine): embeds the WAVs via include_bytes!, decodes
once with kira::StaticSoundData, plays on Draw / Move / NewGame /
GameWon events. card_invalid is loaded but unused — wiring it
needs a MoveRejectedEvent.
- AudioManager kept on the main thread (NonSend) since cpal is !Send
on some platforms; degrades gracefully if no audio device present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces the plumbing layer for Phase 3: GameStateResource wraps
solitaire_core::GameState, DragState tracks in-progress drags, and
SyncStatusResource holds runtime sync status. GamePlugin routes
Draw/Move/Undo/NewGame request events into GameState and emits
StateChangedEvent and GameWonEvent for downstream systems.
Also adds the Phase 3 implementation plan under docs/superpowers/plans/.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>