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>
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>
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>
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).
Add rust-toolchain.toml so all developers and CI use the same Rust
channel (latest stable = 1.95.0 as of 2026-04-14). Set rust-version
= "1.95" in workspace Cargo.toml to declare the minimum supported
Rust version explicitly.
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>
- pkg/solitaire-quest/PKGBUILD: builds solitaire_app binary, depends on
alsa-lib, libxkbcommon, systemd-libs (Bevy Linux requirements); check()
runs only non-Bevy crates (solitaire_core, solitaire_sync) since Bevy
integration tests require a GPU/display unavailable in chroot
- pkg/solitaire-quest-server/PKGBUILD: builds solitaire_server binary,
installs systemd service unit and hardened environment file template
- pkg/solitaire-quest-server/solitaire-quest-server.service: systemd unit
with ProtectSystem=strict, NoNewPrivileges, dedicated service user
- pkg/solitaire-quest-server/server.env: documented env template installed
to /etc/solitaire-quest-server/server.env (mode 0640, listed in backup=)
- LICENSE: add MIT license
- Cargo.toml: add license = "MIT" to [workspace.package]
- All member crates: add license.workspace = true
Both PKGBUILDs follow the Arch Rust package guidelines:
prepare() uses --locked + cargo fetch
build() uses --frozen --release -p <crate>
RUSTUP_TOOLCHAIN=stable and CARGO_TARGET_DIR=target set in each stage
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>
- 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>