Commit Graph

70 Commits

Author SHA1 Message Date
funman300 0dcb783e94 feat(analytics): opt-in usage analytics with server ingest and settings toggle
- Server: POST /api/analytics endpoint with per-IP rate limit (5/min),
  batch validation (≤50 events, event_type regex, UUID dedup, clock check),
  INSERT OR IGNORE for idempotency, and migration 004_analytics.sql
- Client (solitaire_data): AnalyticsClient with in-memory Mutex buffer,
  UUID session_id per launch, async flush via background task
- Engine: AnalyticsPlugin records game_won, game_forfeit, game_start,
  achievement_unlocked; flushes immediately on game-end, every 60 s otherwise
- Settings UI: Privacy section with ON/OFF toggle, hidden in local-only mode
- Default: analytics_enabled = false (explicit opt-in required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:06:34 -07:00
funman300 8325bf6cf7 chore: rename app from Solitaire Quest to Ferrous Solitaire
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>
2026-05-13 17:04:45 -07:00
funman300 24ab25b0b7 feat(android): tap-to-toggle HUD visibility (A1)
On Android, a short tap on the empty game area (not on a card) toggles
the HUD band, info column, and action bar between Visible and Hidden.
Layout recomputes with band_h=0 when hidden so cards fill the full
screen. Any modal open restores the HUD to Visible automatically.

- hud_plugin: HudVisibility resource, HudBand/HudColumn/HudActionBar
  markers, apply_hud_visibility (fires synthetic WindowResized),
  restore_hud_on_modal, and Android-only toggle_hud_on_tap +
  HudTapTracker (15 px slop, skips card taps via DragState.is_idle())
- layout: compute_layout gains hud_visible: bool; passes band_h=0.0
  when hidden; all callers updated
- input_plugin: TouchDragSet (AfterStartDrag / BeforeEndDrag) public
  system-set anchors for cross-plugin ordering
- table_plugin: setup_table + on_window_resized read HudVisibility and
  pass hud_visible to compute_layout
- Desktop behaviour is unchanged (HudVisibility always Visible, tap
  system is #[cfg(target_os = "android")] gated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:46:36 -07:00
funman300 432061c3ec feat(sync): Phase 8 sync setup UI — login/register modal + Connect/Disconnect
Adds SyncSetupPlugin: a three-field (URL / Username / Password) modal
that handles both login and register flows via an async task on
AsyncComputeTaskPool wrapped in a Tokio single-thread runtime (same
pattern as the existing sync push/pull). On success, tokens are stored
to the OS keychain / Android Keystore and SyncProviderResource is
hot-swapped so subsequent pull/push use the new credentials immediately.

Settings sync section now shows Connect (when Local) or Sync Now +
Disconnect + username label (when SolitaireServer). SyncAuthResultEvent
stub registered for future re-auth prompt wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:40:29 -07:00
funman300 b9aa2620b8 feat(android): safe-area insets for HUD positioning
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.

UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.

Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).

Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:37:06 -07:00
funman300 2c822ba2d7 feat(android): JNI clipboard bridge for Stats share-link button
Replaces the informational "Share link: {url}" toast on Android with a
real clipboard write via ClipboardManager JNI. Falls back to the old
toast on JNI error so the user can still copy the URL manually.

Adds `jni = "0.21"` (default-features = false) as a workspace dep;
`jni 0.21.1` was already in Cargo.lock as a transitive dep so no new
packages are fetched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:05:11 -07:00
funman300 0cb15872b1 feat(engine): add Play-by-Seed dialog with solver preview
Adds a numeric-input modal (PlayBySeedPlugin) that lets the player type
a decimal seed and receive an instant solver-verified verdict before the
hand is dealt.  A new HomeMode::PlayBySeed card surfaces it in the home
overlay, matched by the StartPlayBySeedRequestEvent carrier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:19:02 -07:00
funman300 690e1d2ad6 feat(engine): F3-toggleable FPS / frame-time overlay
Performance work for the upcoming Android port needs a numeric
baseline we can quote across desktop and mobile, instead of "feels
slow". DiagnosticsHudPlugin wraps Bevy's FrameTimeDiagnosticsPlugin
and renders a tiny corner readout the developer can toggle with F3.

- Hidden by default — production builds ship the plugin but the
  overlay starts invisible.
- F3 reads ButtonInput<KeyCode> directly (not gated by pause /
  modal state); diagnostics should always be reachable.
- Reads `smoothed()` FPS + frame_time so the cell isn't a jittery
  per-frame scoreboard. Format: "FPS NN \u{2022} M.MM ms".
- Anchored top-right at z = Z_SPLASH + 100 so the readout sits
  above every modal / toast / splash layer.
- Update system bails when hidden so we don't pay the
  diagnostic-store lookup or text mutation when nobody's looking.

Next up on the perf track: get the Android build target wired so we
can put real numbers in this readout from a phone or emulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:03:18 +00: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 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 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 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 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 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 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 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 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 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 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 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
funman300 6e7705b256 feat(app): persist window geometry across launches
Settings gains an optional window_geometry field (size + position)
serialized via #[serde(default)] so legacy settings.json files without
the field deserialize cleanly to None. On launch the app restores
the persisted dimensions and position; first run and pre-upgrade
saves keep the existing 1280x800 centered default.

settings_plugin records changes from WindowResized and WindowMoved
into a PendingWindowGeometry resource and writes them to disk through
the existing atomic .tmp+rename path once the events have stayed
quiet for WINDOW_GEOMETRY_DEBOUNCE_SECS (0.5s). A merge_geometry
helper preserves whichever component (size or position) the latest
event burst didn't carry, so a position-only WindowMoved never wipes
the recorded size.

Pure should_persist_geometry and merge_geometry helpers are unit
tested for the boundary cases. Headless integration tests cover the
full flow: a single resize event then a quiet window persists, a
move event after a resize updates only position, a rapid storm
collapses to the final size, and a quiet frame with no events
leaves the geometry untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:17:54 +00:00
funman300 5d57b67934 feat(engine): branded splash screen on launch
The window previously snapped straight to a card deal, which read more
like a prototype than a finished game. SplashPlugin lays a fullscreen
overlay (BG_BASE backdrop, ACCENT_PRIMARY title, version subtitle) on
top of the gameplay layer for MOTION_SPLASH_TOTAL_SECS — the board
deals behind it so the splash dissolve hands off naturally to the
deal animation.

Visibility curves through fade-in (300ms), hold (~1s), fade-out
(300ms) using a pure splash_alpha helper that gets pinned by a unit
test rather than wired to the Bevy clock — Time<Virtual>'s 250ms
per-tick clamp makes float-tight alpha assertions around the fade
boundary brittle.

Any keystroke or mouse-button press jumps the age forward to the
fade-out window so the splash dissolves immediately. The dismiss
handler is read-only on ButtonInput / Touches, so the same press is
still visible to gameplay handlers downstream — pressing Space on the
splash both dismisses it and triggers the next-tick stock draw, as
verified by dismissal_keypress_is_visible_to_other_systems.

Z_SPLASH sits above every other UI rung (Z_TOAST + 100) so the splash
owns the viewport for its brief lifetime. The hierarchy test was
extended to enforce the new rung's monotonic position.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:31:13 +00:00
funman300 54d34972d4 feat(engine): tooltip infrastructure with hover delay (foundation only)
A new ui_tooltip module owns a Tooltip(Cow<'static, str>) component
that turns any UI node into a hover-revealing help target. Bevy 0.18's
required-components attribute auto-inserts an Interaction so callers
just attach Tooltip and the rest is wired.

A single overlay entity is reparented above the focus ring (new
Z_TOOLTIP token = Z_FOCUS_RING + 10) and tracked from the hovered
target's GlobalTransform + ComputedNode. The chained Update systems
start a hover timer on Interaction::Hovered, show the overlay once
MOTION_TOOLTIP_DELAY_SECS (0.5s) has elapsed, hide it the moment hover
ends, and refresh the text when the hover target switches without an
intervening unhover.

Tested headless under MinimalPlugins with a 200ms ManualDuration
ticker — Bevy clamps Time<Virtual>'s max_delta to 250ms by default, so
a one-shot 1s step doesn't actually advance the clock past the
threshold; the tests step five times to exercise both pre- and
post-delay invariants.

This commit ships the infrastructure only — no entity in the engine
has Tooltip attached yet. A follow-up applies tooltips to the HUD
readouts and action bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:03:43 +00:00
funman300 12789529a1 feat(engine): keyboard focus rings on modal buttons (Phase 1)
Every button spawned via spawn_modal_button is now keyboard-navigable.
Tab/Shift-Tab cycles focus within the active modal, Enter activates
the focused button via the same Interaction::Pressed signal mouse
clicks use, and the primary action auto-focuses on modal open. Mouse
clicks transfer focus so the two input modes stay in sync.

The visual indicator is a single overlay entity that's reparented
above the topmost modal scrim and tracks the focused button's
GlobalTransform + ComputedNode each frame. Sitting outside the
modal-card subtree means the ring isn't affected by the open
animation's 0.96→1.0 scale, and sitting outside any scroll container
means it can't be clipped by Settings' Overflow::scroll_y. Z-order
sits one rung above Z_MODAL_TOP via the new Z_FOCUS_RING token.

Existing 11 modals (Help, Stats, Achievements, Settings, Profile,
Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new game,
Onboarding, Home) get focus support without any call-site changes —
attach_focusable_to_modal_buttons walks the ancestry of any
ModalButton lacking Focusable to find its scrim and tags it
automatically. selection_plugin's Tab handler keeps working when no
modal is open; when one is, focus consumes Tab/Enter before the
selection system sees them.

Phase 1 scope only — HUD action bar, Home mode cards, and Settings
bespoke buttons (icon, swatch, toggle) come in Phase 2/3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:17:25 +00:00
funman300 6723416a55 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
CI / Test & Lint (push) Failing after 25s
CI / Release Build (push) Has been skipped
Pause overlay drops its bespoke full-screen layout and is rebuilt on
the standard `ui_modal` scaffold: uniform scrim, centred card, real
Resume (Primary, Esc) and Forfeit (Tertiary, G) action buttons. The
Draw Mode row stays inline in the body so the existing toggle still
fires `SettingsChangedEvent`.

The G-key double-press toast countdown is replaced with a real
modal: `G` (or clicking Forfeit on Pause) fires the new
`ForfeitRequestEvent`, which `PausePlugin` answers by spawning
`ForfeitConfirmScreen` at `Z_PAUSE_DIALOG` (above pause). The modal
exposes Cancel + "Yes, forfeit" buttons plus Y/Enter/N/Esc
accelerators; confirmation despawns both modals, clears
`PausedResource`, and fires `ForfeitEvent` for `StatsPlugin`.

`toggle_pause` now early-returns when a forfeit modal is visible (and
runs `.before(handle_forfeit_keyboard)`) so an Esc that closes the
forfeit modal doesn't also re-open pause in the same frame.

The legacy `forfeit_countdown` field, `FORFEIT_CONFIRM_WINDOW`
constant, and the six pure-function countdown tests are removed; new
tests cover the modal-spawn / confirm / cancel paths and the active-
game predicate that still gates the G hotkey.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 02:15:47 +00:00
funman300 8da62bd05f feat(engine): add ui_modal primitive (scaffold + button variants)
Phase 3 step 3 of the UX overhaul. Adds a reusable modal helper that
the next 6 commits use to convert each overlay screen. The audit found
11 overlays using 3 different visual styles with scrim alpha drift
between 0.60 and 0.92; this primitive collapses all of that into one
consistent shape.

API surface:
- spawn_modal(commands, plugin_marker, z, build_card)  — full-screen
  scrim (uniform SCRIM token) + centred card (BG_ELEVATED, RADIUS_LG,
  BORDER_STRONG outline, max-width 720, min-width 360, padding
  SPACE_5).  Returns the scrim entity for one-call despawn.
- spawn_modal_header(parent, title, font_res)          — TYPE_HEADLINE
  + TEXT_PRIMARY, the canonical overlay heading.
- spawn_modal_body_text(parent, text, color, font_res) — TYPE_BODY_LG
  paragraph; pass TEXT_PRIMARY or TEXT_SECONDARY.
- spawn_modal_actions(parent, build_buttons)           — flex-row
  justify-end with margin-top.
- spawn_modal_button(parent, marker, label, hotkey,
                     variant, font_res)                — real Button
  entity with optional TYPE_CAPTION hotkey-hint chip.

ButtonVariant enum drives colour:
  Primary    idle ACCENT_PRIMARY      hover ACCENT_PRIMARY_HOVER
             pressed ACCENT_SECONDARY (yellow → pink press flash)
  Secondary  idle BG_ELEVATED_HI      hover BG_ELEVATED_TOP
             pressed BG_ELEVATED
  Tertiary   idle BG_ELEVATED         hover BG_ELEVATED_HI
             pressed BG_ELEVATED_PRESSED

A new BG_ELEVATED_TOP token plus ACCENT_PRIMARY_HOVER cover the new
hover/press combinations cleanly.

UiModalPlugin registers paint_modal_buttons so every ModalButton gets
hover and press feedback automatically — overlay plugins don't add
their own paint systems. Plugin registered in solitaire_app.

A self-test asserts each variant's idle / hover / pressed colours are
all distinct; another verifies the plugin builds under MinimalPlugins.

This commit is purely additive — no overlay calls the new helpers
yet. The next commits convert each overlay to use them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:43:14 +00:00
funman300 e14852c093 feat(engine): add ui_theme.rs design-token module
Phase 3 step 1 of the UX overhaul. Centralises every UI design token —
colours, typography, spacing, border-radius, z-index, and motion
durations — so subsequent overhaul commits read from one source of
truth instead of scattering hex codes and magic numbers across plugin
files.

The audit (2026-04-30) found:
- 40+ hardcoded Color::srgb literals across UI surfaces.
- 12 distinct font sizes (14/15/16/17/18/22/26/28/30/32/40/48 px)
  with no scale.
- 8+ z-index magic numbers across overlay plugins (200, 210, 220,
  230, 250, 300, 400) with no documented hierarchy.
- Motion durations only partially honouring AnimSpeed — slide and
  cascade did, but toast / shake / settle / deal were hardcoded.

ui_theme.rs collapses these into:
- Midnight Purple base (BG_BASE / BG_ELEVATED / BG_ELEVATED_HI /
  BG_ELEVATED_PRESSED) + Balatro-yellow ACCENT_PRIMARY + warm
  magenta ACCENT_SECONDARY + state colours (success/warning/danger/
  info) + text tiers (primary/secondary/disabled) + a uniform SCRIM.
- 5-rung typography scale (display 40 / headline 26 / body-lg 18 /
  body 14 / caption 11).
- 4-multiple spacing scale (4/8/12/16/24/32/48), with VAL_SPACE_*
  Val::Px convenience constants.
- 3 border-radius rungs (sm 4 / md 8 / lg 16).
- Documented monotonically-increasing z-index hierarchy enforced
  by a unit test.
- All MOTION_* duration constants funnelled through scaled_duration()
  so AnimSpeed (Normal/Fast/Instant) applies to every animation,
  not just slide and cascade.

This commit is purely additive — no call sites change yet.
Subsequent commits in the overhaul migrate plugins to the tokens
one region at a time (HUD restructure, modal primitive, then per-
overlay conversions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:20:19 +00:00
funman300 6240156fee feat(engine): add Menu dropdown for Stats/Achievements/Profile/Settings/Leaderboard
CI / Test & Lint (push) Failing after 20s
CI / Release Build (push) Has been skipped
Continues the UI-first pass. The five informational overlays were
each behind a single-key shortcut (S/A/P/O/L) with no visible UI
affordance. Add a "Menu ▾" button to the action bar that toggles a
popover with one row per overlay. Each row dispatches the same code
path the keyboard accelerator uses by writing a new
`Toggle*RequestEvent`:

- Stats        → ToggleStatsRequestEvent
- Achievements → ToggleAchievementsRequestEvent
- Profile      → ToggleProfileRequestEvent
- Settings     → ToggleSettingsRequestEvent
- Leaderboard  → ToggleLeaderboardRequestEvent

Each plugin's existing toggle handler now reads either its key or
the matching request event so the spawn / despawn / fetch logic stays
in the owning plugin (the popover never duplicates that behaviour).

Action bar order is now (left → right):
  Menu ▾   Undo   Pause   Help   Modes ▾   New Game

Menu sits on the far left because it's a navigation aggregator;
New Game stays on the far right as the most consequential action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:55:43 +00:00
funman300 1d9fb1884a feat(engine): add Modes dropdown with Classic/Daily/Zen/Challenge/Time Attack
Continues the UI-first pass. The five game modes were each behind a
keyboard shortcut (N/Z/X/T/C) with no visible UI affordance, three of
them additionally gated by an unlock level the player has to discover
themselves.

Add a "Modes ▾" button to the action bar that toggles a popover panel
beneath. Each row dispatches the same code path the keyboard
accelerator uses by writing a new `Start*RequestEvent` (or
`NewGameRequestEvent` for Classic):

- Classic        → NewGameRequestEvent::default()
- Daily Challenge → StartDailyChallengeRequestEvent
- Zen            → StartZenRequestEvent
- Challenge      → StartChallengeRequestEvent
- Time Attack    → StartTimeAttackRequestEvent

The existing keyboard handlers in input_plugin (Z), challenge_plugin
(X), time_attack_plugin (T), and daily_challenge_plugin (C) now read
either their key or the matching request event, so level gates,
TimeAttackResource setup, daily seed lookup, and toast feedback for
locked modes all stay in their owning plugins — the popover never
duplicates that logic.

The popover only lists modes available to the player: Classic always
shows, Daily Challenge shows when DailyChallengeResource is loaded,
and Zen/Challenge/Time Attack show once the player reaches level 5
(the existing CHALLENGE_UNLOCK_LEVEL).

Click handler despawns the popover after dispatch; clicking the
Modes button again toggles it shut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:49:40 +00:00
funman300 97f38085e3 feat(engine): add Undo, Pause, Help UI buttons in HUD action bar
Continues the UI-first pass started by the New Game button. Per the
design principle in CLAUDE.md / ARCHITECTURE.md §1, every player action
must be reachable from a visible UI control with the keyboard shortcut
as an optional accelerator. Refactor the single New Game button into a
flex-row "action bar" anchored top-right with four buttons: Undo,
Pause, Help, New Game (left → right; New Game rightmost as the most
consequential action).

Plumbing:
- New `PauseRequestEvent` and `HelpRequestEvent` in events.rs.
- pause_plugin::toggle_pause reads either Esc or PauseRequestEvent so
  the button and the keyboard accelerator drive the same code path
  (with the existing drag / game-over / selection guards).
- help_plugin::toggle_help_screen reads either F1 or HelpRequestEvent;
  also fix the stale module-doc claim that H toggles help (it's F1 —
  H is bound to hint cycle in input_plugin).
- hud_plugin now spawns four ActionButton-marked buttons via a
  ChildSpawnerCommands helper, with one click handler per button
  firing its respective request event. A single
  paint_action_buttons system covers hover/pressed colour for all of
  them via the shared ActionButton marker. The click handlers
  defensively re-register their request events so the plugin works in
  isolation under MinimalPlugins (tests). add_message is idempotent.
- ARCHITECTURE.md HudPlugin row updated to call out the action bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:38:54 +00:00
funman300 ece2a55ffb chore(engine): re-export BackgroundImageSet from engine lib
The resource is defined in table_plugin and used by the rest of the
engine, but it was the only one of the prominent table_plugin types not
re-exported from lib.rs. Add it next to PileMarker / TableBackground so
downstream binaries can reference it without reaching into the module
path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:14:34 +00:00
funman300 abda354562 feat(engine): emit SyncCompleteEvent on pull resolve
ARCHITECTURE.md §5 lists SyncCompleteEvent(Result<SyncResponse, String>) as
a cross-system event, but it was never declared or fired. Add the message
to events.rs, register it in SyncPlugin, and emit it from poll_pull_result
on both the success path (carrying the merged payload + conflicts as
SyncResponse) and the failure path (carrying the user-facing error
message). UI/persistence systems can now react to sync completion without
polling SyncStatusResource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:14:21 +00:00
funman300 18ac5adef5 feat(engine): art pass — PNG assets, custom font, and keyring v4 upgrade
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>
2026-04-29 00:30:55 +00:00
funman300 2407686e13 fix(engine,gpgs,core,server): export CardFaceRevealedEvent, explicit gpgs stub, enum/constant docs
- 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>
2026-04-28 22:30:22 +00:00
funman300 ffc79447d4 fix+refactor+docs: P0–P3 todo list items
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>
2026-04-28 22:02:52 +00:00
funman300 8cd28cfb29 feat(engine): right-click highlight timer and visual hint glow (#5, #6)
Task #5: Add RightClickHighlightTimer(1.5 s) so destination highlights
auto-despawn after 1.5 s. Existing clear-on-state-change and
clear-on-pause logic still fires early when a move is made or the game
is paused. Three unit tests cover timer countdown behaviour.

Task #6: Add HintVisualEvent emitted on H key. Source card gets
HintHighlight + HintHighlightTimer(2 s) for a yellow glow. Destination
PileMarker gets HintPileHighlight with a gold tint (Color::srgb(1.0,
0.85, 0.1)) that restores the original colour when the 2 s timer
expires. Five unit tests cover timer expiry and colour invariants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:36:23 +00:00
funman300 03227f8c77 feat(engine): playability improvements — rounds 7–9 (#40–#64)
Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close

Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)

Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 02:35:15 +00:00
funman300 f32e53dd0b feat(engine): shake/settle/deal animations (#54, #55, #69)
Add FeedbackAnimPlugin with three card feedback animations:
- #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting
  destination pile cards; 0.3 s damped sine wave
- #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent);
  1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards
- #69 Deal animation: slides each card from stock position to its deal
  position on NewGameRequestEvent (move_count == 0), using existing
  CardAnim with 0.04 s per-card stagger

Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay
are public and covered by 6 unit tests. Fix pre-existing compile/clippy
errors: stubbed handle_confirm_input/handle_game_over_input, removed dead
CycleCardBack/CycleBackground variants, annotated ambient_handle field,
and fixed draw_mode.clone() in pause_plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:55:24 +00:00
funman300 c3ee7c45a7 feat(engine): card visual improvements — flip animation, foundation/tableau placeholders, drag shadow
Task #34: CardFlipAnim component + start_flip_anim/tick_flip_anim systems animate revealed
cards by squashing scale.x to 0 then expanding back to 1 (2×0.08 s). Skipped at Instant speed.

Task #35: spawn_pile_markers now adds a Text2d child (S/H/D/C, 45% alpha) on Foundation
markers so the suit is visible while the pile is empty.

Task #43: Tableau pile markers get a "K" Text2d child (35% alpha) indicating only Kings land
on empty columns.

Task #38: update_drag_shadow system maintains a single ShadowEntity while dragging — a
card_w+8 × card_h+8 dark semi-transparent sprite at z−1 behind the top dragged card.

Also fixed pre-existing clippy/compiler errors in hud_plugin, pause_plugin, stats_plugin,
cursor_plugin, and settings_plugin (missing imports, too-many-arguments, doc formatting).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:03:59 +00:00
root de840fb006 feat(engine,server): XP toast on win + display_name max-length validation
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>
2026-04-27 03:02:59 +00:00
root 11cb53ab29 feat(engine): InfoToastEvent — show locked-mode messages on-screen
Replaces silent info!() log calls with on-screen toasts when the player
presses Z/X/T without reaching the required unlock level. Any system
can now fire InfoToastEvent(message) to surface a brief text overlay
without depending on a specific plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:57:34 +00:00
root 4a33cbdc22 feat(engine): require N-key confirmation when abandoning an active game
Pressing N during an active game (move_count > 0, not won) now shows a
"Press N again to start a new game" toast and only starts a new game if
N is pressed a second time within 3 seconds. Starting a fresh game or
pressing N after a win still acts immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:53:28 +00:00
root fff8c66bf7 feat(engine): in-game HUD — score, move count, elapsed time, mode badge
Adds HudPlugin with a persistent top-left overlay that shows score,
move count, and elapsed time during every game. A mode badge highlights
DAILY, CHALLENGE, ZEN, or TIME ATTACK when the game is not in Classic
mode. HUD updates whenever GameStateResource changes (moves and per-second
time ticks) without a separate polling system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:50 +00:00
root d56abcd7a9 feat(engine): leaderboard screen (press L to toggle)
Press L to open a leaderboard overlay. On open, an async fetch is
dispatched against the active SyncProvider. Results cache in
LeaderboardResource; the panel rebuilds live when data arrives.
Closing during a fetch is safe (ClosedThisFrame flag prevents
re-spawning the panel in the same frame as the user's despawn command).
Format: ranked table with player name, best score, and fastest win time.
Non-authenticated / LocalOnly providers return an empty list gracefully.

- solitaire_data: add fetch_leaderboard() to SyncProvider trait
  (default → Ok([])) and implement in SolitaireServerClient
- solitaire_engine: new LeaderboardPlugin with 5 unit tests
- solitaire_app: register LeaderboardPlugin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:38:55 +00:00
root a7b781cd36 feat(engine): achievements screen (press A to toggle)
Adds a full-screen overlay listing all achievements with unlock status.
Unlocked achievements show in gold with a check mark; locked ones are
greyed out. Secret achievements that are still locked are hidden. Header
shows unlocked/total count. Press A again to dismiss.

Two new unit tests: spawns on first A press, dismisses on second.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:27:21 +00:00
root f7850c0075 feat(engine): auto-complete — cards auto-deal to foundations
When is_auto_completable flips true (stock/waste empty, all cards
face-up), AutoCompletePlugin fires MoveRequestEvent every 120 ms,
driving cards to the foundation one at a time without player input.
An "Auto-completing…" toast announces the sequence.

- solitaire_core: add next_auto_complete_move() to GameState with
  3 new unit tests
- solitaire_engine: new AutoCompletePlugin with detect + drive systems
  and 4 unit tests; animation_plugin shows one-shot toast on activation
- solitaire_app: register AutoCompletePlugin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:24:21 +00:00
root 00f0383867 feat(engine): in-progress game state persistence
Save game_state.json on app exit and on pause open so players can
resume interrupted sessions. Delete the file on win, loss, or new-game
start. Restore the saved game on launch if it exists and isn't won.

- solitaire_core: add pile_map_serde module so HashMap<PileType,Pile>
  round-trips through JSON (serialized as Vec of pairs)
- solitaire_data: add game_state_file_path, load_game_state_from,
  save_game_state_to, delete_game_state_at with 8 new unit tests
- solitaire_engine/GamePlugin: restore saved game on startup, expose
  GameStatePath resource, save on AppExit, delete on new-game and win
- solitaire_engine/PausePlugin: save on pause open (guards against
  OS-level kills while the overlay is showing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:17:47 +00:00