Core (solitaire_core):
- fix(scoring): apply -15 penalty for Foundation→Tableau moves when
take_from_foundation is enabled; update test
- fix(solver): is_won() validates full Ace→King suit sequence, not
just card count — prevents hint system from emitting invalid paths
Engine — animation / layout:
- fix(animation): guard CardAnim advance against duration=0 to prevent
NaN-poisoned Transform (analogous to CardAnimation's instant-snap path)
- fix(card_plugin): align TABLEAU_FAN_FRAC (0.25→0.18) and
TABLEAU_FACEDOWN_FAN_FRAC (0.20→0.14) with layout.rs so the initial
layout and first dynamic update produce identical fan spacing
- fix(layout): update tableau_fan_frac doc comment from 0.25→0.18
Engine — ECS / modal guards:
- fix(auto_complete): drive_auto_complete now checks PausedResource so
cooldown does not tick while paused (prevents instant-move on unpause)
- fix(play_by_seed): handle_open_dialog checks global ModalScrim guard
to prevent stacking over an existing modal
- fix(win_summary): spawn_win_summary_after_delay checks global
ModalScrim guard; collect_session_achievements uses .next() not
.last() to avoid draining the new_games stream
Engine — message registration:
- fix(leaderboard): register InfoToastEvent in LeaderboardPlugin::build
so opt-in/opt-out toasts work under MinimalPlugins
- fix(replay_playback): register StateChangedEvent in
ReplayPlaybackPlugin::build to prevent panic when used standalone
Security:
- fix(sync_setup): zero password SyncFieldBuffer immediately after
spawning auth task — credential must not linger in ECS components
Server:
- fix(auth): replace MIME contains-chain with exact match for avatar
upload; removes illusory starts_with guard and dead ALLOWED_IMAGE_TYPES
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move /avatars ServeDir behind require_auth middleware so avatar files
can only be fetched by authenticated users (H-11)
- Make avatar upload atomic via .tmp write + rename, cleaning up stale
extensions only after the rename succeeds (H-12)
- Return 401 instead of silently returning an empty username string when
the user row is unexpectedly missing a username (L-17)
- Add user_id mismatch guard to merge(): returns local payload unchanged
with a ConflictReport rather than silently cross-contaminating data (H-2)
- Truncate opt-in display_name to 32 chars client-side before sending,
matching the server's DISPLAY_NAME_MAX validation (L-5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add migration 005: nullable avatar_url column on users table
- Add GET /api/me: returns id, username, avatar_url from DB (fixes UUID-on-profile bug)
- Add PUT /api/me/avatar: accepts raw image bytes (≤1 MB, jpeg/png/webp/gif),
writes to avatars/ dir, updates avatar_url in DB
- Serve /avatars via ServeDir so uploaded images are publicly accessible
- Update account.html: fetch username from /api/me instead of parsing JWT;
add circular avatar display with initials fallback and click-to-upload
- Add SolitaireServerClient::fetch_me() for desktop/Android profile display
- Add avatar_url field to SyncBackend::SolitaireServer settings (serde default None)
- Update sqlx offline query cache for new avatar_url queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the hand-rolled analytics endpoint and SQLite event table in favour
of Matomo — a self-hosted, full-featured analytics platform.
k8s:
- Deploy MariaDB 11 + Bitnami Matomo 5 in the solitaire namespace
- Route analytics.aleshym.co ingress to the Matomo service
- Remove Datasette sidecar and its BasicAuth middleware/secret
- Remove the analytics port from the solitaire-server Service
Rust:
- Replace AnalyticsClient (custom HTTP endpoint) with MatomoClient (Matomo
HTTP Tracking API bulk endpoint); maps game events to Matomo categories
- Add matomo_url + matomo_site_id fields to Settings (serde default → None/1)
- Privacy toggle in Settings now activates when matomo_url is set (not tied
to SyncBackend::SolitaireServer)
- Remove POST /api/analytics route from solitaire_server
Web:
- Add Matomo JS tracking snippet to game.html (/play page)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Content-Security-Policy, X-Content-Type-Options, and X-Frame-Options are
now injected by a single Axum middleware on the web router subtree, so
all HTML pages get consistent headers without touching each file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- leaderboard.html, replays.html: escape user-supplied display_name and
username before inserting into innerHTML to prevent stored XSS
- game.js: call POST /api/replays on win so browser-game completions are
recorded; scores were never submitted before this fix
- replays.rs: after replay insert, upsert leaderboard best_score /
best_time_secs for opted-in users when the new score beats their current
best (classic mode only); scores were never updated before this fix
- leaderboard.rs: add LIMIT 100 to GET /api/leaderboard to prevent
unbounded query growth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add account.html: tabbed form for login and registration, signed-in
state with sign-out, links to leaderboard and replays
- Wire /account route in build_router_inner
- Add Account card to landing page
- Link leaderboard login prompt to /account for new users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace all display-name occurrences across web pages, Rust source,
docs, and Cargo metadata. Update localStorage token key from sq_token
to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add leaderboard.html: JWT login form + localStorage token + table
- Add replays.html: public listing of recent replays, row click to viewer
- Wire /leaderboard and /replays routes in build_router_inner
- Fix home.html Recent Replays link from /api/replays/recent to /replays
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SqlitePool::connect defaults create_if_missing=false in SQLx 0.8, causing
SQLITE_CANTOPEN (error 14) when the PVC is empty on first deploy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add `SolitaireGame` WASM binding to `solitaire_wasm` exposing draw(),
move_cards(), undo(), auto_complete_step(), and state() — all backed by
the real solitaire_core rules engine.
Add /play route to solitaire_server serving a full vanilla-JS
interactive Klondike game (game.html / game.css / game.js). Features:
drag-and-drop card moves (mouse + touch via PointerEvents), click stock
to draw, double-click card to auto-move to foundation, undo, draw-1/3
toggle, new game, auto-complete animation, win overlay, seed display.
Rebuild solitaire_wasm.js + solitaire_wasm_bg.wasm.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Self-hosters can now run:
./solitaire_server --reset-password <username>
to update a player's password and invalidate all their refresh tokens
(forcing re-login on every device). Password is read from stdin so it
can be piped from scripts or a password manager without appearing in
shell history.
Implementation:
- reset_password() in auth.rs: validates length, bcrypt-hashes new
password, updates users.password_hash, deletes all refresh_tokens
rows for the user.
- main.rs: --reset-password dispatch before HTTP server startup;
JWT_SECRET not required for this path.
- 4 integration tests covering: login works after reset, old password
rejected, refresh tokens invalidated, unknown user → NotFound,
short password → BadRequest.
- README_SERVER.md: admin password-reset section with examples.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds push_retries_after_401_on_expired_access_token to sync_round_trip.rs,
closing the push-side coverage gap alongside the existing pull test
(jwt_refresh_on_401_succeeds). Both tests use an expired-but-validly-signed
access token to trigger the 401 → refresh → retry path in
SolitaireServerClient.
Also exposes build_test_pool() from solitaire_server so downstream crates
can boot a test server without duplicating the migration boilerplate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a UserIdKeyExtractor that decodes the Authorization JWT to rate-limit
each user individually (falls back to client IP for unauthenticated
requests). Protected routes now throttle at 10-request burst / 1 token
per 10 s steady-state (6/min), matching the surface attack area of the
1 MB sync/push endpoint.
Also adds an integration test: sync_push_rate_limit_returns_429_on_11th_request.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a `refresh_tokens` table (migration 003) with one row per live
refresh token, keyed by UUID jti. On every POST /api/auth/refresh the
old jti row is deleted and a new token pair is issued and stored. Using
a consumed token returns 401. Expired rows are pruned inline on each
successful rotation.
Server: Claims gains an optional `jti` field; make_refresh_token now
returns (jwt, jti); register/login insert the jti row; RefreshResponse
now carries both tokens. Client: stores the rotated refresh token from
the response. ARCHITECTURE.md: API table + Security Model updated.
Three new integration tests cover rotation, consumed-token rejection,
and chained rotations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
Conservative cleanup pass — applied only the high-signal pedantic
lints whose fixes either remove genuine waste or read more naturally,
skipping anything stylistic that would bloat the diff.
- map_unwrap_or: 29 .map(...).unwrap_or(...) sites collapsed to
.map_or / .is_some_and / .map_or_else equivalents
- uninlined_format_args: 7 production format!/write!/println! sites
rewritten to the inline-argument style; assert! sites in test code
intentionally untouched
- match_same_arms: 2 redundant arms collapsed where the bodies were
identical and the merger didn't obscure intent
Public API is unchanged. No dependencies added or removed. The
pedantic warning count dropped from 840 to 807 (-33). Out-of-scope
findings — needless_pass_by_value on Bevy Res params, false-positive
explicit_iter_loop on Bevy Query iterators, items_after_statements
inside test mods, and the "ask before changing" merge logic in
solitaire_sync — were intentionally deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five test-only lints surfaced by --all-targets were blocking CI under
-D warnings: a useless vec! in a leaderboard sort test, a
field_reassign_with_default in tuning tests, and three
assertions_on_constants in card_plugin sanity tests. The constant
assertions are now wrapped in const blocks so they run at compile time;
the runtime-formatted values were dropped from their messages because
const-block assert messages must be string literals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- tower_governor: switch from PeerIpKeyExtractor (socket address) to
SmartIpKeyExtractor so x-forwarded-for headers are honoured in tests
and behind reverse proxies. Fixes auth_rate_limit_returns_429 test
returning 500 instead of 429.
- solitaire_core: collapse nested if/if-let per clippy::collapsible_if.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Introduce AppState { pool, jwt_secret } so JWT_SECRET is loaded once in
main() and any missing value is a fatal startup error rather than a 500
on the first request. All four env::var("JWT_SECRET") call sites in
auth.rs and middleware.rs are replaced with state.jwt_secret.
- build_test_router embeds the fixed test secret so integration tests do
not need to set JWT_SECRET in the environment.
- Add tracing::warn! in login (invalid password) and register (username
taken) to surface brute-force attempts in production logs.
- Fix daily-challenge race condition: after INSERT OR IGNORE, re-SELECT
the persisted row so concurrent requests both return the winner's data.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- engine/lib.rs: re-export CardFaceRevealedEvent so external crates can consume flip-midpoint audio events
- gpgs/stub.rs: add explicit impls for all six defaulted SyncProvider methods; future trait changes now cause a compile error in the stub rather than silently picking up wrong defaults
- core/game_state.rs: add /// doc comments to DrawMode and GameMode variants
- server/auth.rs: replace terse BCRYPT_COST comment with full /// doc comment matching ARCHITECTURE.md §19
- server/leaderboard.rs: add /// doc comment to DISPLAY_NAME_MAX; fix misplaced comment that was prepended to the opt_in handler instead of the constant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
(all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
on normal draws; add move_count_increments_on_recycle regression test
P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
(solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)
P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
new_game_confirm_window_is_positive
P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing
P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order
P3 — documentation:
- Add struct-level /// to 12 Plugin structs (ChallengePlugin, CursorPlugin,
AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win
card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile
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>
Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.
Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).
Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).
Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.
Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.
Task #39: Daily challenge HUD constraint label (time limit / target score).
Task #40: Undo-count HUD label; amber colour when undos > 0.
Task #44: Win-streak and level line on pause screen.
Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.
Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.
Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The daily-challenge seed function had no unit test coverage. Added tests
for determinism, cross-day and cross-year distinctness, non-zero output,
and all six generate_goal variants (score/time field correctness).
This acts as a change-detection guard for the authoritative seed algorithm
that all players worldwide rely on to receive the same daily deal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The leaderboard opt-in handler was calling `.len()` on the display name,
which returns byte count. Multi-byte Unicode characters (emoji, CJK, etc.)
would be rejected well before the 32-character visual limit and with a
misleading error message. Switched to `.chars().count()` to enforce the
limit in terms of Unicode scalar values as the error message advertises.
test(core): add boundary tests for 7 uncovered achievement conditions
test(server): add display_name validation integration tests (empty,
too-long ASCII, 32-emoji succeeds, 33-emoji rejected)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
register() strips leading/trailing whitespace from the username before
storing it; login() was not, so a user who typed " alice " at login
would get a 401 even though their account existed as "alice". Now both
handlers trim consistently.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds three new challenge types (win in 3 min, score ≥5000, win in 8 min)
so the daily challenge rotates through a fortnight of variety before any
variant repeats. Seeds are still deterministic worldwide.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ProgressPlugin now fires XpAwardedEvent on every win. AnimationPlugin
shows a "+N XP" toast so players see XP feedback immediately after
winning. Server leaderboard opt-in endpoint also now validates that
display_name is at most 32 characters.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Username: 3–32 chars, alphanumeric + underscore only.
Password: minimum 8 characters.
Both return HTTP 400 Bad Request with a human-readable message.
Adds three integration tests for the new validation rules.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a user pushes sync data and is opted in to the leaderboard, the
server now updates their leaderboard row with the merged stats using
MAX(best_score) and MIN(best_time_secs) — scores never regress even if
the client sends stale data.
Eliminates the need for a separate score-submission API call: the sync
push already carries the full stats, so the leaderboard stays current
after every push.
Added two integration tests:
- push_after_opt_in_updates_leaderboard_score
- push_lower_score_does_not_overwrite_leaderboard_best
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>