Compare commits

...

5 Commits

Author SHA1 Message Date
funman300 f6e7de1093 fix(core): make take_from_foundation true by default across all clients
Build and Deploy / build-and-push (push) Successful in 3m51s
Android Release / build-apk (push) Successful in 4m36s
The flag was modelled as an opt-in non-standard rule but moving a card
off a foundation is in fact standard Klondike — disabling it is the
non-standard variant.

Changing the core default to true means every client (desktop, Android,
web) gets correct behaviour without each having to independently patch
the value after construction. Clients that expose a settings toggle
(desktop/Android) can still disable it through SettingsResource.

- game_state.rs: flip default from false → true in new_with_mode
- game_state.rs: rename/update take_from_foundation_disabled_by_default
  test to reflect the new intended default
- solitaire_wasm/lib.rs: remove now-redundant override in new()
  (from_saved keeps its override to fix old saves that serialised false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:44:10 -07:00
Gitea CI b5a780ddf4 chore(deploy): bump image to 90eb5fd2 [skip ci] 2026-05-19 22:41:00 +00:00
funman300 3322fd4250 fix(wasm): enable take-from-foundation in web game client
Android Release / build-apk (push) Successful in 3m56s
GameState::new_with_mode defaults take_from_foundation=false (non-
standard; the flag exists so the desktop can offer it as a setting).
The WASM web client has no settings layer, so this flag was never
flipped on — every drag or double-click from a foundation pile was
silently rejected by the rules engine.

Set take_from_foundation=true in both SolitaireGame::new (fresh games)
and SolitaireGame::from_saved (restored games, which may have the old
default serialised).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:40:16 -07:00
funman300 90eb5fd207 feat(web): persist game state across page refreshes with resume dialog
Build and Deploy / build-and-push (push) Successful in 2m54s
Android Release / build-apk (push) Successful in 4m38s
- solitaire_wasm: add SolitaireGame::serialize() and from_saved() so JS
  can round-trip the full GameState through localStorage as JSON
- game.js: save {gameState, elapsedSecs, drawThree} to localStorage
  (key: fs_game_save) on every render(); clear the save on win
- game.js: on bootstrap, check for a saved game and show a resume
  dialog if one exists; Resume restores state + timer, New Game discards
  the save and starts fresh with a random seed
- game.html: add #resume-overlay markup (same pattern as win-overlay)
- game.css: add styles for the resume dialog and its secondary button

localStorage failures (private-browsing quota) are silently ignored so
they never block gameplay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:38:07 -07:00
funman300 76cf41e7a9 fix(ui): open sync-setup modal when Connect clicked from Settings
Android Release / build-apk (push) Successful in 3m49s
The sync-setup modal was silently blocked by its own guard:
other_modal_scrims checks for any ModalScrim without SyncSetupScreen,
but the Settings panel IS a ModalScrim, so clicking Connect from within
Settings always hit the guard and returned early.

Two fixes:
- handle_sync_buttons: set SettingsScreen.0 = false when ConnectSync
  is pressed so settings closes as the event is fired
- open_sync_setup_modal: exclude SettingsPanel from other_modal_scrims
  to handle the deferred-despawn timing window (settings scrim entity
  still exists in the world until command buffers flush at frame end)
- Make SettingsPanel pub so sync_setup_plugin can reference it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:32:14 -07:00
8 changed files with 208 additions and 14 deletions
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images:
- name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server
newTag: ea9dd848
newTag: 90eb5fd2
+3 -3
View File
@@ -193,7 +193,7 @@ impl GameState {
is_auto_completable: false,
undo_count: 0,
recycle_count: 0,
take_from_foundation: false,
take_from_foundation: true,
schema_version: GAME_STATE_SCHEMA_VERSION,
undo_stack: VecDeque::new(),
}
@@ -1408,9 +1408,9 @@ mod tests {
}
#[test]
fn take_from_foundation_disabled_by_default() {
fn take_from_foundation_enabled_by_default() {
let g = setup_take_from_foundation_game();
assert!(!g.take_from_foundation, "take_from_foundation is off by default (non-standard rule)");
assert!(g.take_from_foundation, "take_from_foundation is on by default (standard Klondike rule)");
}
#[test]
+8 -2
View File
@@ -94,7 +94,7 @@ pub struct SettingsChangedEvent(pub Settings);
/// Marker on the root Settings panel entity.
#[derive(Component, Debug)]
struct SettingsPanel;
pub struct SettingsPanel;
/// Marks the `Text` node showing the live SFX volume value.
#[derive(Component, Debug)]
@@ -1137,6 +1137,7 @@ fn handle_sync_buttons(
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
mut screen: ResMut<SettingsScreen>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
@@ -1144,7 +1145,12 @@ fn handle_sync_buttons(
}
match button {
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); }
SettingsButton::ConnectSync => {
// Close settings before the sync-setup modal opens so the
// guard in open_sync_setup_modal doesn't block on our own scrim.
screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent);
}
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
_ => {}
+6 -2
View File
@@ -52,7 +52,7 @@ use crate::events::{
SyncLogoutRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath};
use crate::resources::TokioRuntimeResource;
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{spawn_modal, ModalScrim};
@@ -205,10 +205,14 @@ impl Plugin for SyncSetupPlugin {
// ---------------------------------------------------------------------------
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
#[allow(clippy::type_complexity)]
fn open_sync_setup_modal(
mut events: MessageReader<SyncConfigureRequestEvent>,
existing: Query<(), With<SyncSetupScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>)>,
// Exclude SettingsPanel: the Connect button closes settings in the same
// frame it fires SyncConfigureRequestEvent, but Bevy despawns are deferred
// so the settings scrim still exists in the world during this system.
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>, Without<SettingsPanel>)>,
mut commands: Commands,
mut focused: ResMut<SyncFocusedField>,
font_res: Option<Res<FontResource>>,
+62
View File
@@ -230,6 +230,68 @@ main {
pointer-events: none;
}
/* ── Resume overlay ──────────────────────────────────────────────────── */
#resume-overlay {
position: fixed;
inset: 0;
background: rgba(21, 21, 21, 0.92);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
#resume-overlay.hidden { display: none; }
.resume-card {
background: var(--panel);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px;
padding: 40px 48px;
text-align: center;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
max-width: 360px;
}
.resume-title {
font-size: 28px;
font-weight: 700;
color: var(--accent);
}
.resume-detail {
font-size: 14px;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.resume-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 8px;
}
.resume-actions button {
padding: 12px 24px;
font-size: 15px;
}
.resume-actions button.secondary {
background: transparent;
border: 1px solid rgba(255,255,255,0.2);
color: var(--text-muted);
}
.resume-actions button.secondary:hover {
background: rgba(255,255,255,0.05);
}
/* ── Win overlay ─────────────────────────────────────────────────────── */
#win-overlay {
+11
View File
@@ -56,6 +56,17 @@
</section>
</main>
<div id="resume-overlay" class="hidden">
<div class="resume-card">
<div class="resume-title">Resume Game?</div>
<p class="resume-detail">You have an unfinished game saved. Would you like to continue where you left off?</p>
<div class="resume-actions">
<button id="btn-resume">↩ Resume</button>
<button id="btn-resume-new" class="secondary">↺ New Game</button>
</div>
</div>
</div>
<div id="win-overlay" class="hidden">
<div class="win-card">
<div class="win-title">You Won!</div>
+93 -6
View File
@@ -69,6 +69,34 @@ function preloadTheme(theme) {
preloadTheme("classic");
preloadTheme("dark");
// ── Persistence ──────────────────────────────────────────────────────────────
const LS_SAVE_KEY = "fs_game_save";
function saveState() {
if (!game) return;
try {
const gameState = game.serialize();
if (typeof gameState !== "string") return;
localStorage.setItem(LS_SAVE_KEY, JSON.stringify({ gameState, elapsedSecs, drawThree }));
} catch (e) {
// localStorage may be unavailable (private browsing quota, etc.) — never block gameplay.
console.warn("fs: save failed", e);
}
}
function clearSave() {
try { localStorage.removeItem(LS_SAVE_KEY); } catch { /* ignore */ }
}
function loadSave() {
try {
const raw = localStorage.getItem(LS_SAVE_KEY);
if (!raw) return null;
const save = JSON.parse(raw);
return save?.gameState ? save : null;
} catch { return null; }
}
// ── State ────────────────────────────────────────────────────────────────────
let game = null;
let snap = null; // last rendered GameSnapshot
@@ -138,16 +166,72 @@ async function bootstrap() {
await init();
syncThemeButton();
const params = new URLSearchParams(window.location.search);
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
drawThree = params.has("draw3");
chkDraw3.checked = drawThree;
buildSlots();
scaleBoard();
window.addEventListener("resize", scaleBoard);
startGame(urlSeed);
attachHandlers();
const saved = loadSave();
if (saved) {
showResumeDialog(saved);
} else {
const params = new URLSearchParams(window.location.search);
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
drawThree = params.has("draw3");
chkDraw3.checked = drawThree;
startGame(urlSeed);
}
}
function showResumeDialog(saved) {
const overlay = document.getElementById("resume-overlay");
if (overlay) overlay.classList.remove("hidden");
document.getElementById("btn-resume").onclick = () => {
if (overlay) overlay.classList.add("hidden");
resumeGame(saved);
};
document.getElementById("btn-resume-new").onclick = () => {
clearSave();
if (overlay) overlay.classList.add("hidden");
drawThree = false;
chkDraw3.checked = false;
startGame(randomSeed());
};
}
function resumeGame(saved) {
let restored;
try {
restored = SolitaireGame.from_saved(saved.gameState);
} catch (e) {
console.warn("fs: restore failed, starting new game", e);
clearSave();
startGame(randomSeed());
return;
}
game = restored;
drawThree = !!saved.drawThree;
elapsedSecs = saved.elapsedSecs || 0;
chkDraw3.checked = drawThree;
const displaySeed = Math.round(game.seed());
hudSeed.textContent = `seed ${displaySeed}`;
winOverlay.classList.add("hidden");
cardEls.clear();
board.querySelectorAll(".card, .recycle-label").forEach(el => el.remove());
const url = new URL(window.location);
url.searchParams.set("seed", displaySeed);
if (drawThree) url.searchParams.set("draw3", "");
else url.searchParams.delete("draw3");
history.replaceState(null, "", url);
const s = game.state();
snap = s;
render(s);
if (!s.is_won) startTimer();
}
function randomSeed() {
@@ -304,9 +388,12 @@ function render(s) {
acTimer = setInterval(doAutoCompleteStep, 380);
}
if (s.is_won) {
clearSave();
stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; }
showWin(s);
} else {
saveState();
}
}
+24
View File
@@ -422,6 +422,30 @@ impl SolitaireGame {
}
}
/// Serialise the full game state as a JSON string for `localStorage`.
///
/// Use [`SolitaireGame::from_saved`] to restore it. The returned string is
/// opaque — callers should treat it as a blob and store/restore it verbatim.
pub fn serialize(&self) -> Result<String, JsValue> {
serde_json::to_string(&self.game)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
///
/// Returns an error string if the JSON is malformed or describes a state
/// that can't be deserialised (e.g. from a future schema version).
pub fn from_saved(json: &str) -> Result<SolitaireGame, JsValue> {
serde_json::from_str::<GameState>(json)
.map(|mut game| {
// Older saves serialised with take_from_foundation=false (the core default).
// The web client has no settings layer, so enforce the standard rule here.
game.take_from_foundation = true;
SolitaireGame { game }
})
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Apply one auto-complete move (only valid when `is_auto_completable`).
///
/// If no card can go directly to a foundation this step, advances the