Compare commits

...

42 Commits

Author SHA1 Message Date
funman300 77df2d2aef fix(web): auto-complete now works with cards remaining in waste
Build and Deploy / build-and-push (push) Failing after 3m55s
check_auto_complete no longer requires the waste pile to be empty —
only the stock must be exhausted and all tableau cards face-up.
next_auto_complete_move checks the waste top card before scanning
tableau, and auto_complete_step falls back to draw() when no direct
foundation move is available so the waste drains automatically.

Fixes the end-game state where the player could see a clear win but
the auto-complete interval never fired because the waste was non-empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:30:46 -07:00
Gitea CI 11bfb4f1c8 chore(deploy): bump image to 3e006a1e [skip ci] 2026-05-14 04:14:55 +00:00
funman300 3e006a1e94 feat(analytics): replace custom pipeline with Matomo
Build and Deploy / build-and-push (push) Successful in 4m36s
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>
2026-05-13 21:10:15 -07:00
funman300 18ed1549e0 feat(deploy): Datasette analytics sidecar + analytics.aleshym.co ingress
Adds a Datasette container alongside the existing server in the same pod so
it can read the SQLite PVC without a second ReadWriteOnce mount. Protected
by a Traefik BasicAuth middleware at analytics.aleshym.co.

Also fixes the ArgoCD repoURL to point to the migrated Gitea hostname
(git.aleshym.co) instead of the old bare IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:17:20 -07:00
Gitea CI 0fffce9a29 chore(deploy): bump image to 3cec200a [skip ci] 2026-05-14 03:10:52 +00:00
funman300 3cec200ac0 feat(analytics): opt-in usage analytics with server ingest and settings toggle
Build and Deploy / build-and-push (push) Successful in 4m17s
- 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
Gitea CI ec7e2b7c08 chore(deploy): bump image to 09fcd209 [skip ci] 2026-05-14 02:43:38 +00:00
funman300 9e04b389af fix(server): add CSP/security headers middleware, gitignore jks.bak*
Build and Deploy / build-and-push (push) Failing after 3m52s
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>
2026-05-13 19:41:50 -07:00
funman300 09fcd2097e fix(server): XSS, missing score submission, leaderboard never updated, no LIMIT
Build and Deploy / build-and-push (push) Successful in 4m14s
- 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>
2026-05-13 19:32:14 -07:00
funman300 f0b9536e09 chore: verify gitea migration 2026-05-13 19:13:47 -07:00
Gitea CI a09ec48097 chore(deploy): bump image to d5c95f9a [skip ci] 2026-05-14 00:21:16 +00:00
funman300 d5c95f9a0f fix(web): preload card images to prevent white-flash on flip
Build and Deploy / build-and-push (push) Successful in 3m43s
When a card flipped face-up, the browser fetched the PNG on demand,
showing the cream fallback colour until the image arrived. Preloading
all 52 faces and the back at module load ensures they are cached before
any flip can occur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:17:33 -07:00
Gitea CI 494bd8b8ca chore(deploy): bump image to b0478117 [skip ci] 2026-05-14 00:14:00 +00:00
funman300 b04781178e feat(web): account page with sign in / sign up tabs
Build and Deploy / build-and-push (push) Successful in 4m12s
- 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>
2026-05-13 17:09:57 -07:00
Gitea CI 4af19c4d62 chore(deploy): bump image to e6c67d03 [skip ci] 2026-05-14 00:09:08 +00:00
funman300 e6c67d03c2 chore: rename app from Solitaire Quest to Ferrous Solitaire
Build and Deploy / build-and-push (push) Successful in 4m55s
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
Gitea CI 484db22208 chore(deploy): bump image to 4315c0ae [skip ci] 2026-05-13 23:54:33 +00:00
funman300 4315c0ae70 feat(web): leaderboard and replays pages with nav from landing
Build and Deploy / build-and-push (push) Successful in 3m38s
- 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>
2026-05-13 16:50:54 -07:00
Gitea CI f417177858 chore(deploy): bump image to 31d0a1b6 [skip ci] 2026-05-13 23:43:30 +00:00
funman300 31d0a1b6e3 feat(web): add home arrow link to game page header
Build and Deploy / build-and-push (push) Successful in 4m32s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:38:58 -07:00
Gitea CI 6fa1b28902 chore(deploy): bump image to 56dbc3ff [skip ci] 2026-05-13 23:37:19 +00:00
funman300 56dbc3ff2c fix(ci): rebase before kustomization push to handle concurrent runs
Build and Deploy / build-and-push (push) Successful in 40s
Two runs for the same SHA racing to push the kustomization update
caused the second to fail with "failed to push some refs".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:36:42 -07:00
Gitea CI 19ba065154 chore(deploy): bump image to 3e98872f [skip ci] 2026-05-13 23:33:23 +00:00
funman300 3e98872f15 ci: add Docker BuildKit registry cache to speed up Rust builds
Build and Deploy / build-and-push (push) Failing after 42s
Caches compiled dependency layers in the Gitea registry under
:buildcache. Subsequent builds that only touch solitaire_server/src/
skip recompiling the full workspace dependency tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:28:10 -07:00
Gitea CI 6cee4e9a2b chore(deploy): bump image to 98f9933e [skip ci]
Build and Deploy / build-and-push (push) Successful in 5m18s
2026-05-13 23:28:10 +00:00
funman300 98f9933ed0 fix(web): apply Terminal palette and UX fixes to game page
Build and Deploy / build-and-push (push) Successful in 1m19s
Aligns /play with the landing page and app color scheme — same
bg, panel, accent, and felt tokens from ui_theme.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:26:51 -07:00
Gitea CI 0ef75a0c9a chore(deploy): bump image to a6030f4b [skip ci] 2026-05-13 23:24:43 +00:00
funman300 a6030f4b7b fix(web): align replay and landing page to Terminal (base16-eighties) palette
Build and Deploy / build-and-push (push) Successful in 1m28s
Replay viewer was using the old midnight-purple palette. Both pages now
use the exact color tokens from ui_theme.rs — matching the desktop and
Android app exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:23:16 -07:00
funman300 28b1d38951 feat(web): add landing page at / with links to play, leaderboard, replays
Build and Deploy / build-and-push (push) Failing after 1m37s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:21:38 -07:00
Gitea CI efe930af1e chore(deploy): bump image to 022a749f [skip ci] 2026-05-13 22:45:42 +00:00
funman300 022a749f5f fix(server): create SQLite database file if missing on first start
Build and Deploy / build-and-push (push) Successful in 1m15s
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>
2026-05-13 15:44:22 -07:00
Gitea CI 6e3ce8ea59 chore(deploy): bump image to 0c673e3b [skip ci] 2026-05-13 22:32:46 +00:00
funman300 0c673e3bb6 fix(docker): add libsqlite3-0 to runtime image to fix SQLite CANTOPEN error
Build and Deploy / build-and-push (push) Successful in 32s
The server binary dynamically links against libsqlite3.so.0, which is not
present in debian:bookworm-slim by default, causing SQLite error code 14
at startup when connecting to the database.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:32:09 -07:00
Gitea CI f3b28a1b9d chore(deploy): bump image to 597aba20 [skip ci] 2026-05-13 15:04:01 -07:00
funman300 597aba200a fix(docker): rename binary to ./server to avoid collision with solitaire_server/web dir
Build and Deploy / build-and-push (push) Successful in 15s
2026-05-13 15:03:45 -07:00
funman300 8396f0f067 ci: trigger with dockerfile change for debug
Build and Deploy / build-and-push (push) Failing after 8s
2026-05-13 14:46:09 -07:00
funman300 9f8e32db36 ci: debug push trigger 2026-05-13 14:42:20 -07:00
funman300 7f333443dd fix(docker): copy web/ to builder stage for include_str! macros
Build and Deploy / build-and-push (push) Failing after 1m8s
2026-05-13 14:18:05 -07:00
funman300 29b8c33d3f fix(docker): stub all workspace crates for cargo fetch in CI
Build and Deploy / build-and-push (push) Failing after 2m16s
2026-05-13 14:15:24 -07:00
funman300 edf2013ab1 ci: retrigger after fixing runner instance URL
Build and Deploy / build-and-push (push) Failing after 2m27s
2026-05-13 14:11:54 -07:00
funman300 e3864c60a0 ci: retrigger build after enabling Actions
Build and Deploy / build-and-push (push) Failing after 4m18s
2026-05-13 14:05:23 -07:00
funman300 44493a2200 ci: trigger first docker build 2026-05-13 14:03:23 -07:00
69 changed files with 1843 additions and 231 deletions
+8
View File
@@ -36,6 +36,11 @@ jobs:
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.CI_TOKEN }} password: ${{ secrets.CI_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -45,6 +50,8 @@ jobs:
tags: | tags: |
${{ env.IMAGE }}:${{ steps.meta.outputs.sha }} ${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
${{ env.IMAGE }}:latest ${{ env.IMAGE }}:latest
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
- name: Install kustomize - name: Install kustomize
run: | run: |
@@ -62,4 +69,5 @@ jobs:
git config user.name "Gitea CI" git config user.name "Gitea CI"
git add deploy/kustomization.yaml git add deploy/kustomization.yaml
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true
git pull --rebase origin master
git push git push
+1
View File
@@ -14,4 +14,5 @@ data/
# Android signing keystores — never commit # Android signing keystores — never commit
*.jks *.jks
*.jks.bak *.jks.bak
*.jks.bak*
*.keystore *.keystore
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE leaderboard\n SET best_score = ?,\n best_time_secs = ?,\n recorded_at = ?\n WHERE user_id = ?\n AND (\n best_score IS NULL\n OR ? > best_score\n OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "0e199cafab7e71b0c7f28ede85a622e38649d2fe5a73a5c715f2319f5450f729"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC", "query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC\n LIMIT 100",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -34,5 +34,5 @@
false false
] ]
}, },
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112" "hash": "2b814989a6632ca930ae1e895f97a7fc3389c91d1d2abf6900a21fb0d6e94ef3"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3"
}
+2 -2
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Architecture Document # Ferrous Solitaire — Architecture Document
> **Version:** 1.3 > **Version:** 1.3
> **Language:** Rust (Edition 2024) > **Language:** Rust (Edition 2024)
@@ -34,7 +34,7 @@
## 1. Project Overview ## 1. Project Overview
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices. Ferrous Solitaire is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
### Sync Backend by Platform ### Sync Backend by Platform
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog # Changelog
All notable changes to Solitaire Quest are documented here. The format is All notable changes to Ferrous Solitaire are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project follows [Semantic Versioning](https://semver.org/). project follows [Semantic Versioning](https://semver.org/).
+3 -3
View File
@@ -1,6 +1,6 @@
# Credits # Credits
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of Ferrous Solitaire is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
the work of many open-source projects and a small handful of third-party the work of many open-source projects and a small handful of third-party
assets. This file lists every component that ships in the binary or in the assets. This file lists every component that ships in the binary or in the
`assets/` directory. `assets/` directory.
@@ -43,7 +43,7 @@ copyleft code is statically linked into the game binary.
| File(s) | Source | License | | File(s) | Source | License |
|---|---|---| |---|---|---|
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT | | `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) | | `solitaire_engine/assets/themes/default/back.svg` | Original — Ferrous Solitaire | MIT (this project) |
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) | | `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
| `assets/cards/backs/back_0.png` `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) | | `assets/cards/backs/back_0.png` `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
@@ -107,6 +107,6 @@ Audio files are MIT-licensed alongside the rest of this project.
backs, every audio file) are original work covered by this project's MIT backs, every audio file) are original work covered by this project's MIT
license. license.
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the If you redistribute Ferrous Solitaire, you must ship this `CREDITS.md` and the
`LICENSE` file alongside the binary so the MIT (project + hayeah card art) `LICENSE` file alongside the binary so the MIT (project + hayeah card art)
and OFL (FiraMono) notices remain visible to end users. and OFL (FiraMono) notices remain visible to end users.
Generated
+1
View File
@@ -7018,6 +7018,7 @@ dependencies = [
"resvg", "resvg",
"ron", "ron",
"serde", "serde",
"serde_json",
"solitaire_core", "solitaire_core",
"solitaire_data", "solitaire_data",
"solitaire_sync", "solitaire_sync",
+1 -1
View File
@@ -1,4 +1,4 @@
# Solitaire Quest # Ferrous Solitaire
A cross-platform Klondike Solitaire game written in Rust, with a card-theme A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and system, full progression (XP / levels / achievements / daily challenges), and
+1 -1
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Self-Hosting Guide # Ferrous Solitaire — Self-Hosting Guide
## Prerequisites ## Prerequisites
+2 -2
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Session Handoff # Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin. **Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin.
@@ -150,7 +150,7 @@ Items missing from the doc:
## Resume prompt ## Resume prompt
``` ```
You are a senior Rust + Bevy developer working on Solitaire Quest. You are a senior Rust + Bevy developer working on Ferrous Solitaire.
Working directory: <Rusty_Solitaire clone path>. Working directory: <Rusty_Solitaire clone path>.
Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed. Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed.
+1 -1
View File
@@ -6,7 +6,7 @@ metadata:
spec: spec:
project: default project: default
source: source:
repoURL: http://10.10.0.64:3000/funman300/Rusty_Solitare.git repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
targetRevision: master targetRevision: master
path: deploy path: deploy
destination: destination:
+25
View File
@@ -0,0 +1,25 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: solitaire-analytics
namespace: solitaire
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: analytics.aleshym.co
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: matomo
port:
name: http
tls:
- hosts:
- analytics.aleshym.co
secretName: analytics-tls
+9 -1
View File
@@ -7,10 +7,18 @@ resources:
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- ingress.yaml - ingress.yaml
- mariadb-pvc.yaml
- mariadb-deployment.yaml
- mariadb-service.yaml
- matomo-pvc.yaml
- matomo-secret.yaml
- matomo-deployment.yaml
- matomo-service.yaml
- ingress-analytics.yaml
# CI updates this block automatically via `kustomize edit set image`. # CI updates this block automatically via `kustomize edit set image`.
# The image name here matches the `image: solitaire-server` stub in deployment.yaml. # The image name here matches the `image: solitaire-server` stub in deployment.yaml.
images: images:
- name: solitaire-server - name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server newName: git.aleshym.co/funman300/solitaire-server
newTag: latest newTag: 3e006a1e
+72
View File
@@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mariadb
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: mariadb
strategy:
type: Recreate
template:
metadata:
labels:
app: mariadb
spec:
containers:
- name: mariadb
image: mariadb:11
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_ROOT_PASSWORD
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_DATABASE
- name: MYSQL_USER
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_USER
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_PASSWORD
ports:
- containerPort: 3306
volumeMounts:
- name: mariadb-data
mountPath: /var/lib/mysql
livenessProbe:
exec:
command:
- healthcheck.sh
- --connect
- --innodb_initialized
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
exec:
command:
- healthcheck.sh
- --connect
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: mariadb-data
persistentVolumeClaim:
claimName: mariadb-data
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb-data
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: solitaire
spec:
selector:
app: mariadb
ports:
- name: mysql
port: 3306
targetPort: 3306
clusterIP: None
+85
View File
@@ -0,0 +1,85 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: matomo
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: matomo
strategy:
type: Recreate
template:
metadata:
labels:
app: matomo
spec:
containers:
- name: matomo
image: bitnami/matomo:5
env:
- name: MATOMO_DATABASE_HOST
value: mariadb
- name: MATOMO_DATABASE_PORT_NUMBER
value: "3306"
- name: MATOMO_DATABASE_NAME
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_DATABASE
- name: MATOMO_DATABASE_USER
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_USER
- name: MATOMO_DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_PASSWORD
- name: MATOMO_USERNAME
value: admin
- name: MATOMO_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MATOMO_ADMIN_PASSWORD
- name: MATOMO_EMAIL
value: funman300@gmail.com
- name: MATOMO_WEBSITE_NAME
value: "Solitaire Quest"
- name: MATOMO_WEBSITE_HOST
value: "https://klondike.aleshym.co"
- name: MATOMO_HOST
value: analytics.aleshym.co
- name: MATOMO_ENABLE_PROXY_URI_HEADER
value: "yes"
ports:
- containerPort: 8080
volumeMounts:
- name: matomo-data
mountPath: /bitnami/matomo
livenessProbe:
httpGet:
path: /index.php
port: 8080
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /index.php
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: matomo-data
persistentVolumeClaim:
claimName: matomo-data
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: matomo-data
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
+13
View File
@@ -0,0 +1,13 @@
# Credentials for MariaDB and the Matomo admin account.
# Regenerate with: python3 -c "import secrets; print(secrets.token_urlsafe(18))"
apiVersion: v1
kind: Secret
metadata:
name: matomo-secret
namespace: solitaire
stringData:
MYSQL_ROOT_PASSWORD: "jspRn-QU18sZhB55FR-JfrMJ"
MYSQL_DATABASE: matomo
MYSQL_USER: matomo
MYSQL_PASSWORD: "ZxDp648UuL5fsN7eQI23E7ue"
MATOMO_ADMIN_PASSWORD: "J6QUtbroK4Z7zao4Dnl0J7e2"
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: matomo
namespace: solitaire
spec:
selector:
app: matomo
ports:
- name: http
port: 80
targetPort: 8080
+2 -2
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Session Handoff (ARCHIVED) # Ferrous Solitaire — Session Handoff (ARCHIVED)
> **This file is from Phase 2 (2026-04-25, 242 tests). It is kept for historical > **This file is from Phase 2 (2026-04-25, 242 tests). It is kept for historical
> reference only. The authoritative session handoff is at the repo root: > reference only. The authoritative session handoff is at the repo root:
@@ -24,7 +24,7 @@ All seven Cargo crates created and compiling cleanly:
| `solitaire_engine` | Stub | Bevy ECS systems — all plugins added in Phase 3 | | `solitaire_engine` | Stub | Bevy ECS systems — all plugins added in Phase 3 |
| `solitaire_server` | Stub | Axum sync server — implemented in Phase 8C | | `solitaire_server` | Stub | Axum sync server — implemented in Phase 8C |
| `solitaire_gpgs` | Compile-time stub | Google Play Games bridge — Android only, JNI in Phase: Android | | `solitaire_gpgs` | Compile-time stub | Google Play Games bridge — Android only, JNI in Phase: Android |
| `solitaire_app` | Working | Opens blank Bevy window titled "Solitaire Quest" at 1280×800 | | `solitaire_app` | Working | Opens blank Bevy window titled "Ferrous Solitaire" at 1280×800 |
Fast compile profiles, `assets/` directory structure, and `.env.example` are all in place. Fast compile profiles, `assets/` directory structure, and `.env.example` are all in place.
+1 -1
View File
@@ -2,7 +2,7 @@
> **Date:** 2026-04-28 > **Date:** 2026-04-28
> **Author:** Claude Code > **Author:** Claude Code
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2 > **Scope:** Feasibility analysis for porting Ferrous Solitaire to Android using cargo-mobile2
--- ---
@@ -1,4 +1,4 @@
# Solitaire Quest — Phase 1 + 2: Workspace & Core Game Engine # Ferrous Solitaire — Phase 1 + 2: Workspace & Core Game Engine
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
@@ -555,7 +555,7 @@ fn main() {
.add_plugins( .add_plugins(
DefaultPlugins.set(WindowPlugin { DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "Solitaire Quest".into(), title: "Ferrous Solitaire".into(),
resolution: (1280.0, 800.0).into(), resolution: (1280.0, 800.0).into(),
..default() ..default()
}), }),
@@ -571,7 +571,7 @@ fn main() {
```bash ```bash
cargo run -p solitaire_app --features bevy/dynamic_linking cargo run -p solitaire_app --features bevy/dynamic_linking
``` ```
Expected: A blank Bevy window titled "Solitaire Quest" opens. Press Escape or close the window to exit. No panics or errors in the terminal. Expected: A blank Bevy window titled "Ferrous Solitaire" opens. Press Escape or close the window to exit. No panics or errors in the terminal.
--- ---
@@ -1210,7 +1210,7 @@ fn main() {
.add_plugins( .add_plugins(
DefaultPlugins.set(WindowPlugin { DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "Solitaire Quest".into(), title: "Ferrous Solitaire".into(),
resolution: (1280.0, 800.0).into(), resolution: (1280.0, 800.0).into(),
..default() ..default()
}), }),
+1 -1
View File
@@ -11,7 +11,7 @@
### Infrastructure ### Infrastructure
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network. - Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup. - A running Ferrous Solitaire sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
- Verify the server is live before starting: - Verify the server is live before starting:
```bash ```bash
+1 -1
View File
@@ -3,7 +3,7 @@
> **Why this exists.** The 24 mockups in this directory are mobile > **Why this exists.** The 24 mockups in this directory are mobile
> (390 × 844 logical, iPhone 14 Pro frame) with one exception > (390 × 844 logical, iPhone 14 Pro frame) with one exception
> (`home-menu-desktop.html`). The Stitch project that produced them > (`home-menu-desktop.html`). The Stitch project that produced them
> is named "Solitaire Quest *Mobile* Redesign" — the mobile-first > is named "Ferrous Solitaire *Mobile* Redesign" — the mobile-first
> framing was deliberate when the new Android target opened, but > framing was deliberate when the new Android target opened, but
> desktop is still the primary delivery surface. Porting the mobile > desktop is still the primary delivery surface. Porting the mobile
> mockups 1:1 would land a 390-px-wide column floating in the middle > mockups 1:1 would land a 390-px-wide column floating in the middle
+1 -1
View File
@@ -87,7 +87,7 @@ required = true
name = "android.permission.INTERNET" name = "android.permission.INTERNET"
[package.metadata.android.application] [package.metadata.android.application]
label = "Solitaire Quest" label = "Ferrous Solitaire"
# Launcher icon — references the density-bucketed mipmap resource above. # Launcher icon — references the density-bucketed mipmap resource above.
icon = "@mipmap/ic_launcher" icon = "@mipmap/ic_launcher"
# `debuggable` defaults to false on release builds; cargo-apk flips it # `debuggable` defaults to false on release builds; cargo-apk flips it
+3 -2
View File
@@ -25,7 +25,7 @@ use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
use bevy::winit::WinitWindows; use bevy::winit::WinitWindows;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{ use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, register_theme_asset_sources, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
@@ -106,7 +106,7 @@ pub fn run() {
DefaultPlugins DefaultPlugins
.set(WindowPlugin { .set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "Solitaire Quest".into(), title: "Ferrous Solitaire".into(),
// X11/Wayland WM_CLASS so taskbar managers group // X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly. // multiple windows of this app correctly.
name: Some("solitaire-quest".into()), name: Some("solitaire-quest".into()),
@@ -194,6 +194,7 @@ pub fn run() {
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin) .add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin) .add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
+1 -1
View File
@@ -1,4 +1,4 @@
//! Generates PNG assets for Solitaire Quest. //! Generates PNG assets for Ferrous Solitaire.
//! //!
//! Produces: //! Produces:
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and //! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
+36 -26
View File
@@ -426,12 +426,11 @@ impl GameState {
/// Returns `true` when stock and waste are empty and all tableau cards are face-up. /// Returns `true` when stock and waste are empty and all tableau cards are face-up.
/// At that point the game can be completed without further player input. /// At that point the game can be completed without further player input.
pub fn check_auto_complete(&self) -> bool { pub fn check_auto_complete(&self) -> bool {
// Stock must be empty; waste may still have cards (they are resolved
// by draw() calls inside next_auto_complete_move / auto_complete_step).
if !self.piles[&PileType::Stock].cards.is_empty() { if !self.piles[&PileType::Stock].cards.is_empty() {
return false; return false;
} }
if !self.piles[&PileType::Waste].cards.is_empty() {
return false;
}
(0..7).all(|i| { (0..7).all(|i| {
self.piles[&PileType::Tableau(i)] self.piles[&PileType::Tableau(i)]
.cards .cards
@@ -459,17 +458,36 @@ impl GameState {
if !self.is_auto_completable || self.is_won { if !self.is_auto_completable || self.is_won {
return None; return None;
} }
// Check waste top first — when stock is exhausted the waste may still
// contain cards that can go directly to a foundation.
let waste = PileType::Waste;
if let Some((card, slot)) = self.piles[&waste].cards.last()
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
{
let _ = card; // borrow ends here
return Some((waste, PileType::Foundation(slot)));
}
for i in 0..7 { for i in 0..7 {
let tableau = PileType::Tableau(i); let tableau = PileType::Tableau(i);
if let Some(card) = self.piles[&tableau].cards.last() { if let Some(slot) = self.piles[&tableau].cards.last()
// Prefer the slot that already claims this card's suit so .and_then(|c| self.foundation_slot_for(c))
// Aces don't sometimes land in slot 0 and then leave the {
// matching suit-claimed slot empty. return Some((tableau, PileType::Foundation(slot)));
}
}
None
}
/// Return the foundation slot index that `card` can legally move to, or
/// `None` if no such slot exists.
///
/// Prefers the slot already claiming this card's suit so Aces always land
/// in a consistent column. Falls back to an empty slot only for Aces.
fn foundation_slot_for(&self, card: &crate::card::Card) -> Option<u8> {
let mut candidate: Option<u8> = None; let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None; let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 { for slot in 0..4_u8 {
let foundation = PileType::Foundation(slot); let pile = &self.piles[&PileType::Foundation(slot)];
let pile = &self.piles[&foundation];
if pile.cards.is_empty() { if pile.cards.is_empty() {
if empty_slot.is_none() { if empty_slot.is_none() {
empty_slot = Some(slot); empty_slot = Some(slot);
@@ -479,20 +497,12 @@ impl GameState {
break; break;
} }
} }
let target_slot = candidate.or_else(|| { let target = candidate.or_else(|| {
// Only fall back to an empty slot if the card is an Ace,
// which is the only rank that can claim an empty slot.
if card.rank.value() == 1 { empty_slot } else { None } if card.rank.value() == 1 { empty_slot } else { None }
}); });
if let Some(slot) = target_slot { target.filter(|&slot| {
let foundation = PileType::Foundation(slot); can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
if can_place_on_foundation(card, &self.piles[&foundation]) { })
return Some((tableau, foundation));
}
}
}
}
None
} }
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0). /// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
@@ -1022,24 +1032,24 @@ mod tests {
} }
#[test] #[test]
fn auto_complete_false_when_waste_not_empty() { fn auto_complete_true_when_stock_empty_waste_has_cards() {
// Waste no longer blocks auto-complete — draw() drains it during
// auto-complete steps. Only stock-not-empty and face-down tableau
// cards block the flag.
let mut g = new_game(); let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
// Leave the waste pile untouched (it may be empty after clearing stock,
// so add a card explicitly to ensure the waste guard is exercised).
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: 99, id: 99,
suit: Suit::Clubs, suit: Suit::Clubs,
rank: Rank::Ace, rank: Rank::Ace,
face_up: true, face_up: true,
}); });
// Make all tableau cards face-up so only the waste guard is the blocker.
for i in 0..7 { for i in 0..7 {
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() { for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
c.face_up = true; c.face_up = true;
} }
} }
assert!(!g.check_auto_complete()); assert!(g.check_auto_complete());
} }
#[test] #[test]
+1
View File
@@ -15,6 +15,7 @@ async-trait = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by # `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in # `auth_tokens`. The crate's own dependency tree pulls in
+3
View File
@@ -163,5 +163,8 @@ pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
}; };
pub mod matomo_client;
pub use matomo_client::MatomoClient;
pub mod platform; pub mod platform;
pub use platform::data_dir; pub use platform::data_dir;
+122
View File
@@ -0,0 +1,122 @@
//! Matomo HTTP Tracking API client.
//!
//! Buffers game-play events and flushes them via the Matomo bulk tracking
//! endpoint. Errors are silently discarded — analytics must never affect
//! gameplay or block the UI.
use std::sync::Mutex;
use reqwest::Client;
use uuid::Uuid;
/// Sends game-play events to a self-hosted Matomo instance via the
/// [HTTP Tracking API](https://developer.matomo.org/api-reference/tracking-api).
///
/// Construct once per session and share via `Arc`. `event` is cheap and
/// can be called from the Bevy main thread; `flush` is async and must be
/// called from a background task.
pub struct MatomoClient {
tracking_url: String,
site_id: u32,
/// 16 hex-char visitor ID, stable for the lifetime of this client.
visitor_id: String,
uid: Option<String>,
client: Client,
/// Pre-encoded query strings, one per buffered event.
pending: Mutex<Vec<String>>,
}
impl MatomoClient {
/// Create a new client targeting `base_url` (e.g. `"https://analytics.example.com"`).
pub fn new(base_url: impl AsRef<str>, site_id: u32, uid: Option<String>) -> Self {
let base = base_url.as_ref().trim_end_matches('/');
let tracking_url = format!("{}/matomo.php", base);
// Take the lower 64 bits of a v4 UUID and format as 16 hex chars.
let visitor_id = format!("{:016x}", Uuid::new_v4().as_u128() as u64);
Self {
tracking_url,
site_id,
visitor_id,
uid,
client: Client::new(),
pending: Mutex::new(Vec::new()),
}
}
/// Buffer one Matomo custom event. Never blocks; never fails visibly.
///
/// When the buffer exceeds 100 events the oldest 50 are dropped to
/// prevent unbounded memory growth during extended offline play.
pub fn event(
&self,
category: &str,
action: &str,
name: Option<&str>,
value: Option<f64>,
) {
let Ok(mut guard) = self.pending.lock() else {
return;
};
let mut qs = format!(
"idsite={}&rec=1&apiv=1&send_image=0\
&url=game%3A%2F%2Fsolitaire%2Fevent\
&_id={}&e_c={}&e_a={}",
self.site_id,
self.visitor_id,
url_encode(category),
url_encode(action),
);
if let Some(n) = name {
qs.push_str(&format!("&e_n={}", url_encode(n)));
}
if let Some(v) = value {
qs.push_str(&format!("&e_v={v}"));
}
if let Some(uid) = &self.uid {
qs.push_str(&format!("&uid={}", url_encode(uid)));
}
guard.push(qs);
if guard.len() > 100 {
guard.drain(0..50);
}
}
/// Drain the pending buffer and POST it to Matomo's bulk tracking endpoint.
///
/// The buffer is drained *before* the HTTP call so events recorded during
/// an in-flight flush are not lost. Network errors are silently discarded.
pub async fn flush(&self) {
let pending = {
let Ok(mut guard) = self.pending.lock() else {
return;
};
if guard.is_empty() {
return;
}
std::mem::take(&mut *guard)
};
let requests: Vec<String> = pending.into_iter().map(|qs| format!("?{qs}")).collect();
let body = serde_json::json!({ "requests": requests });
let _ = self
.client
.post(&self.tracking_url)
.json(&body)
.send()
.await;
}
}
fn url_encode(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
vec![c]
}
c => format!("%{:02X}", c as u32).chars().collect(),
})
.collect()
}
+23 -1
View File
@@ -49,7 +49,7 @@ pub enum SyncBackend {
#[default] #[default]
#[serde(rename = "local")] #[serde(rename = "local")]
Local, Local,
/// Sync with a self-hosted Solitaire Quest server. /// Sync with a self-hosted Ferrous Solitaire server.
#[serde(rename = "solitaire_server")] #[serde(rename = "solitaire_server")]
SolitaireServer { SolitaireServer {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`. /// Base URL of the server, e.g. `"https://solitaire.example.com"`.
@@ -243,6 +243,21 @@ pub struct Settings {
/// `false` via `#[serde(default)]`. /// `false` via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub take_from_foundation: bool, pub take_from_foundation: bool,
/// When `true`, anonymous game-play events (game start, game won, etc.)
/// are sent to the configured Matomo instance. Opt-in; defaults to `false`.
/// Requires `matomo_url` to be set. Older `settings.json` files deserialize
/// cleanly to `false` via `#[serde(default)]`.
#[serde(default)]
pub analytics_enabled: bool,
/// Base URL of the Matomo instance to send events to, e.g.
/// `"https://analytics.example.com"`. When `None` the analytics toggle has
/// no effect. Older `settings.json` files deserialize cleanly to `None`.
#[serde(default)]
pub matomo_url: Option<String>,
/// Matomo site ID assigned when the tracked site was created in Matomo.
/// Defaults to `1` (the first site created in a fresh Matomo install).
#[serde(default = "default_matomo_site_id")]
pub matomo_site_id: u32,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -311,6 +326,10 @@ fn default_replay_move_interval_secs() -> f32 {
0.45 0.45
} }
fn default_matomo_site_id() -> u32 {
1
}
/// Lower bound of the player-tunable replay-playback per-move interval, /// Lower bound of the player-tunable replay-playback per-move interval,
/// in seconds. Below this the cards barely register visually before /// in seconds. Below this the cards barely register visually before
/// the next move fires; the cap keeps the playback legible. /// the next move fires; the cap keeps the playback legible.
@@ -364,6 +383,9 @@ impl Default for Settings {
last_difficulty: None, last_difficulty: None,
leaderboard_display_name: None, leaderboard_display_name: None,
take_from_foundation: false, take_from_foundation: false,
analytics_enabled: false,
matomo_url: None,
matomo_site_id: default_matomo_site_id(),
} }
} }
} }
+2 -2
View File
@@ -6,7 +6,7 @@
//! | Struct | Backend | //! | Struct | Backend |
//! |---|---| //! |---|---|
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled | //! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
//! | [`SolitaireServerClient`] | Self-hosted Solitaire Quest server (JWT auth) | //! | [`SolitaireServerClient`] | Self-hosted Ferrous Solitaire server (JWT auth) |
//! //!
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>` //! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
//! without matching on [`SyncBackend`] anywhere else in the codebase. //! without matching on [`SyncBackend`] anywhere else in the codebase.
@@ -55,7 +55,7 @@ impl SyncProvider for LocalOnlyProvider {
// SolitaireServerClient // SolitaireServerClient
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// HTTP sync client for the self-hosted Solitaire Quest server. /// HTTP sync client for the self-hosted Ferrous Solitaire server.
/// ///
/// Authenticates via JWT stored in the OS keychain. On a 401 response the /// Authenticates via JWT stored in the OS keychain. On a 401 response the
/// client automatically attempts a token refresh and retries the request once /// client automatically attempts a token refresh and retries the request once
+1
View File
@@ -14,6 +14,7 @@ chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
usvg = { workspace = true } usvg = { workspace = true }
resvg = { workspace = true } resvg = { workspace = true }
+188
View File
@@ -0,0 +1,188 @@
//! Matomo analytics plugin — buffers game-play events and flushes them to
//! the configured Matomo instance in the background.
//!
//! Disabled by default (opt-in via Settings → Privacy). Only active when
//! `settings.analytics_enabled` is `true` AND `settings.matomo_url` is set.
use std::sync::Arc;
use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
use solitaire_core::game_state::GameMode;
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
// ---------------------------------------------------------------------------
// Resource
// ---------------------------------------------------------------------------
/// Holds the active Matomo client. `None` when the feature is disabled.
#[derive(Resource)]
pub struct AnalyticsResource {
pub client: Option<Arc<MatomoClient>>,
flush_timer: Timer,
}
impl Default for AnalyticsResource {
fn default() -> Self {
Self {
client: None,
flush_timer: Timer::from_seconds(60.0, TimerMode::Repeating),
}
}
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers analytics systems. Add after `SettingsPlugin` in the app.
pub struct AnalyticsPlugin;
impl Plugin for AnalyticsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AnalyticsResource>()
.add_systems(Startup, init_analytics)
.add_systems(
Update,
(
react_to_settings_change,
on_game_won,
on_forfeit,
on_new_game,
on_achievement_unlocked,
tick_flush_timer,
),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
fn init_analytics(settings: Res<SettingsResource>, mut analytics: ResMut<AnalyticsResource>) {
analytics.client = client_for(&settings.0);
}
fn react_to_settings_change(
mut events: MessageReader<SettingsChangedEvent>,
mut analytics: ResMut<AnalyticsResource>,
) {
for ev in events.read() {
analytics.client = client_for(&ev.0);
}
}
fn on_game_won(
mut wins: MessageReader<GameWonEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in wins.read() {
client.event("Game", "Won", None, Some(ev.score as f64));
fire_flush(client.clone(), &settings.0);
}
}
fn on_forfeit(
mut forfeits: MessageReader<ForfeitEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for _ev in forfeits.read() {
client.event("Game", "Forfeit", None, None);
fire_flush(client.clone(), &settings.0);
}
}
fn on_new_game(
mut requests: MessageReader<NewGameRequestEvent>,
analytics: Res<AnalyticsResource>,
game: Res<GameStateResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in requests.read() {
if !ev.confirmed {
continue;
}
let mode = ev.mode.unwrap_or(game.0.mode);
client.event("Game", "Start", Some(mode_str(mode)), None);
}
}
fn on_achievement_unlocked(
mut achievements: MessageReader<AchievementUnlockedEvent>,
analytics: Res<AnalyticsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in achievements.read() {
client.event("Achievement", "Unlocked", Some(&ev.0.id), None);
}
}
fn tick_flush_timer(
time: Res<Time>,
mut analytics: ResMut<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
analytics.flush_timer.tick(time.delta());
if !analytics.flush_timer.just_finished() {
return;
}
if let Some(client) = analytics.client.clone() {
fire_flush(client, &settings.0);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
if !settings.analytics_enabled {
return None;
}
let url = settings.matomo_url.as_deref()?;
let uid = match &settings.sync_backend {
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
SyncBackend::Local => None,
};
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
}
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
AsyncComputeTaskPool::get()
.spawn(async move {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
rt.block_on(client.flush());
}
})
.detach();
}
fn mode_str(mode: GameMode) -> &'static str {
match mode {
GameMode::Classic => "classic",
GameMode::Zen => "zen",
GameMode::Challenge => "challenge",
GameMode::TimeAttack => "time_attack",
GameMode::Difficulty(_) => "difficulty",
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! SVG builder for the Solitaire Quest application icon. //! SVG builder for the Ferrous Solitaire application icon.
//! //!
//! Renders the project's signature `▌RS` Terminal mark (the same //! Renders the project's signature `▌RS` Terminal mark (the same
//! cursor-block + monogram pair used on the splash boot-screen and //! cursor-block + monogram pair used on the splash boot-screen and
+3 -1
View File
@@ -1,10 +1,11 @@
//! Bevy integration layer for Solitaire Quest. //! Bevy integration layer for Ferrous Solitaire.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub mod android_clipboard; pub mod android_clipboard;
pub mod assets; pub mod assets;
pub mod card_animation; pub mod card_animation;
pub mod achievement_plugin; pub mod achievement_plugin;
pub mod analytics_plugin;
pub mod animation_plugin; pub mod animation_plugin;
pub mod auto_complete_plugin; pub mod auto_complete_plugin;
pub mod audio_plugin; pub mod audio_plugin;
@@ -60,6 +61,7 @@ pub use theme::{
ThemeRegistryPlugin, ThemeRegistryPlugin,
}; };
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
pub use challenge_plugin::{ pub use challenge_plugin::{
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL, challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
}; };
+3 -3
View File
@@ -9,7 +9,7 @@
//! //!
//! Slides: //! Slides:
//! //!
//! 1. **Welcome** — brief introduction to Solitaire Quest. //! 1. **Welcome** — brief introduction to Ferrous Solitaire.
//! 2. **How to play** — drag-and-drop, double-click, and right-click hints. //! 2. **How to play** — drag-and-drop, double-click, and right-click hints.
//! 3. **Keyboard shortcuts** — a summary pulled from the same canonical list //! 3. **Keyboard shortcuts** — a summary pulled from the same canonical list
//! used in `HelpScreen`. Accelerators: `Esc` anywhere in the flow skips //! used in `HelpScreen`. Accelerators: `Esc` anywhere in the flow skips
@@ -292,10 +292,10 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc
/// Slide 1 — Welcome. /// Slide 1 — Welcome.
fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) { fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Welcome to Solitaire Quest", font_res); spawn_modal_header(card, "Welcome to Ferrous Solitaire", font_res);
spawn_modal_body_text( spawn_modal_body_text(
card, card,
"Solitaire Quest is a free, offline-first Klondike Solitaire game. \ "Ferrous Solitaire is a free, offline-first Klondike Solitaire game. \
Play classic draw-1 or draw-3 Klondike, earn XP, unlock achievements, \ Play classic draw-1 or draw-3 Klondike, earn XP, unlock achievements, \
and compete on the leaderboard. Your progress is saved locally \ and compete on the leaderboard. Your progress is saved locally \
optional sync to your own server keeps it in step across all your devices.", optional sync to your own server keeps it in step across all your devices.",
+49 -3
View File
@@ -166,6 +166,11 @@ struct WinnableDealsOnlyText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SmartDefaultSizeText; struct SmartDefaultSizeText;
/// Marks the `Text` node showing the current "Share usage data" (analytics)
/// state ("ON" / "OFF") in the Privacy section.
#[derive(Component, Debug)]
struct AnalyticsEnabledText;
/// Marks the scrollable inner card so the mouse-wheel system can target it. /// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsPanelScrollable; struct SettingsPanelScrollable;
@@ -236,6 +241,10 @@ enum SettingsButton {
/// flag only affects launches without saved geometry — the /// flag only affects launches without saved geometry — the
/// player's last window size always wins. /// player's last window size always wins.
ToggleSmartDefaultSize, ToggleSmartDefaultSize,
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
/// sync server is configured — there is no server to send to in
/// local-only mode.
ToggleAnalytics,
/// Scan `user_theme_dir()` for new `.zip` files and import each one. /// Scan `user_theme_dir()` for new `.zip` files and import each one.
ScanThemes, ScanThemes,
SyncNow, SyncNow,
@@ -283,6 +292,8 @@ impl SettingsButton {
SettingsButton::ReplayMoveIntervalUp => 49, SettingsButton::ReplayMoveIntervalUp => 49,
// Smart-default-size toggle — sits at the end of Gameplay. // Smart-default-size toggle — sits at the end of Gameplay.
SettingsButton::ToggleSmartDefaultSize => 50, SettingsButton::ToggleSmartDefaultSize => 50,
// Privacy section — just before Sync.
SettingsButton::ToggleAnalytics => 89,
// Cosmetic section // Cosmetic section
SettingsButton::ToggleTheme => 55, SettingsButton::ToggleTheme => 55,
SettingsButton::ToggleColorBlind => 60, SettingsButton::ToggleColorBlind => 60,
@@ -398,10 +409,11 @@ impl Plugin for SettingsPlugin {
update_replay_move_interval_text, update_replay_move_interval_text,
update_winnable_deals_only_text, update_winnable_deals_only_text,
update_smart_default_size_text, update_smart_default_size_text,
update_analytics_enabled_text,
attach_focusable_to_settings_buttons, attach_focusable_to_settings_buttons,
scroll_focus_into_view,
), ),
); );
app.add_systems(Update, scroll_focus_into_view);
} }
} }
} }
@@ -763,6 +775,20 @@ fn update_winnable_deals_only_text(
} }
} }
/// Refreshes the live "Share usage data" toggle value in the Privacy section
/// whenever `SettingsResource` changes.
fn update_analytics_enabled_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = on_off_label(settings.0.analytics_enabled);
}
}
/// Refreshes the live "Smart window size" toggle value whenever /// Refreshes the live "Smart window size" toggle value whenever
/// `SettingsResource` changes. The flag is stored negatively as /// `SettingsResource` changes. The flag is stored negatively as
/// `disable_smart_default_size`, so the label inverts. /// `disable_smart_default_size`, so the label inverts.
@@ -1049,6 +1075,12 @@ fn handle_settings_buttons(
// The Text node is refreshed by `update_winnable_deals_only_text` // The Text node is refreshed by `update_winnable_deals_only_text`
// on the next frame via `settings.is_changed()`. // on the next frame via `settings.is_changed()`.
} }
SettingsButton::ToggleAnalytics => {
settings.0.analytics_enabled = !settings.0.analytics_enabled;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_analytics_enabled_text` next frame.
}
SettingsButton::ToggleSmartDefaultSize => { SettingsButton::ToggleSmartDefaultSize => {
settings.0.disable_smart_default_size = settings.0.disable_smart_default_size =
!settings.0.disable_smart_default_size; !settings.0.disable_smart_default_size;
@@ -1650,6 +1682,20 @@ fn spawn_settings_panel(
} }
import_themes_row(body, font_res); import_themes_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync --- // --- Sync ---
section_label(body, "Sync", font_res); section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res); sync_row(body, sync_status, &settings.sync_backend, font_res);
@@ -2343,7 +2389,7 @@ fn sync_row(
row, row,
SettingsButton::ConnectSync, SettingsButton::ConnectSync,
"Connect", "Connect",
"Connect to a self-hosted Solitaire Quest sync server.".to_string(), "Connect to a self-hosted Ferrous Solitaire sync server.".to_string(),
button_font, button_font,
); );
} }
@@ -2920,7 +2966,7 @@ mod tests {
.expect("Connect button should spawn with a Tooltip when backend is Local"); .expect("Connect button should spawn with a Tooltip when backend is Local");
assert_eq!( assert_eq!(
connect_tip.as_ref(), connect_tip.as_ref(),
"Connect to a self-hosted Solitaire Quest sync server.", "Connect to a self-hosted Ferrous Solitaire sync server.",
"ConnectSync tooltip must use the canonical microcopy" "ConnectSync tooltip must use the canonical microcopy"
); );
} }
+3 -3
View File
@@ -2,7 +2,7 @@
//! //!
//! On app start the engine spawns a fullscreen, high-Z overlay that //! On app start the engine spawns a fullscreen, high-Z overlay that
//! reads the Terminal-style "boot screen" — an accent-coloured cursor block, the //! reads the Terminal-style "boot screen" — an accent-coloured cursor block, the
//! "Solitaire Quest" wordmark, a short fixture boot log, a progress //! "Ferrous Solitaire" wordmark, a short fixture boot log, a progress
//! bar, and a footer with the design-system palette swatches and the //! bar, and a footer with the design-system palette swatches and the
//! build version. The overlay fades in over 300 ms, holds for ~1 s, //! build version. The overlay fades in over 300 ms, holds for ~1 s,
//! then fades out for 300 ms before despawning. The deal animation //! then fades out for 300 ms before despawning. The deal animation
@@ -383,7 +383,7 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
)); ));
hdr.spawn(( hdr.spawn((
SplashFadable { base_color: TEXT_PRIMARY }, SplashFadable { base_color: TEXT_PRIMARY },
Text::new("Solitaire Quest"), Text::new("Ferrous Solitaire"),
title_font, title_font,
TextColor(transparent(TEXT_PRIMARY)), TextColor(transparent(TEXT_PRIMARY)),
)); ));
@@ -1170,7 +1170,7 @@ mod tests {
"expected the cursor block (▌) on the splash, got: {texts:?}" "expected the cursor block (▌) on the splash, got: {texts:?}"
); );
assert!( assert!(
texts.iter().any(|t| t == "Solitaire Quest"), texts.iter().any(|t| t == "Ferrous Solitaire"),
"expected the wordmark on the splash, got: {texts:?}" "expected the wordmark on the splash, got: {texts:?}"
); );
assert!( assert!(
+1 -1
View File
@@ -1,4 +1,4 @@
//! Backend-agnostic sync plugin for Solitaire Quest. //! Backend-agnostic sync plugin for Ferrous Solitaire.
//! //!
//! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`] //! On startup, the plugin spawns an async pull task on [`AsyncComputeTaskPool`]
//! that fetches the remote payload from the active [`SyncProvider`]. Once the //! that fetches the remote payload from the active [`SyncProvider`]. Once the
+1 -1
View File
@@ -84,7 +84,7 @@ mod tests {
ThemeMeta { ThemeMeta {
id: "default".into(), id: "default".into(),
name: "Default".into(), name: "Default".into(),
author: "Solitaire Quest".into(), author: "Ferrous Solitaire".into(),
version: "1.0.0".into(), version: "1.0.0".into(),
card_aspect: (2, 3), card_aspect: (2, 3),
} }
+1 -1
View File
@@ -268,7 +268,7 @@ mod tests {
let meta = ThemeMeta { let meta = ThemeMeta {
id: "default".into(), id: "default".into(),
name: "Default".into(), name: "Default".into(),
author: "Solitaire Quest".into(), author: "Ferrous Solitaire".into(),
version: "1.0.0".into(), version: "1.0.0".into(),
card_aspect: (2, 3), card_aspect: (2, 3),
}; };
+1 -1
View File
@@ -115,7 +115,7 @@ fn default_entry() -> ThemeEntry {
meta: ThemeMeta { meta: ThemeMeta {
id: "default".to_string(), id: "default".to_string(),
name: "Default".to_string(), name: "Default".to_string(),
author: "Solitaire Quest".to_string(), author: "Ferrous Solitaire".to_string(),
version: "1.0".to_string(), version: "1.0".to_string(),
card_aspect: (2, 3), card_aspect: (2, 3),
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
//! Keyboard focus ring for modal buttons (Phase 1). //! Keyboard focus ring for modal buttons (Phase 1).
//! //!
//! Solitaire Quest's 11 modals (Help, Stats, Achievements, Settings, //! Ferrous Solitaire's 11 modals (Help, Stats, Achievements, Settings,
//! Profile, Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new //! Profile, Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new
//! game, Onboarding) ship without any keyboard focus support. Phase 1 //! game, Onboarding) ship without any keyboard focus support. Phase 1
//! gives every button spawned via [`crate::ui_modal::spawn_modal_button`] //! gives every button spawned via [`crate::ui_modal::spawn_modal_button`]
+20 -10
View File
@@ -13,16 +13,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy only the files needed to build the server crate. # Copy only the files needed to build the server crate.
# Layer order: workspace manifests first so dependency fetches are cached. # Layer order: workspace manifests first so dependency fetches are cached.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
COPY solitaire_data/Cargo.toml ./solitaire_data/Cargo.toml
COPY solitaire_engine/Cargo.toml ./solitaire_engine/Cargo.toml
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
COPY solitaire_app/Cargo.toml ./solitaire_app/Cargo.toml
COPY solitaire_assetgen/Cargo.toml ./solitaire_assetgen/Cargo.toml
COPY solitaire_wasm/Cargo.toml ./solitaire_wasm/Cargo.toml
# Stub every crate source so `cargo fetch` succeeds without full source. # Stub every workspace crate so `cargo fetch --locked` resolves the full
RUN mkdir -p solitaire_sync/src solitaire_server/src solitaire_core/src && \ # dependency graph without requiring source files beyond Cargo.toml.
echo "pub fn _stub() {}" > solitaire_sync/src/lib.rs && \ RUN for crate in solitaire_core solitaire_sync solitaire_data solitaire_engine \
echo "pub fn _stub() {}" > solitaire_core/src/lib.rs && \ solitaire_server solitaire_app solitaire_assetgen solitaire_wasm; do \
echo "pub fn _stub() {}" > solitaire_server/src/lib.rs && \ mkdir -p $crate/src && echo "pub fn _stub() {}" > $crate/src/lib.rs; \
echo "fn main() {}" > solitaire_server/src/main.rs done && \
echo "fn main() {}" > solitaire_server/src/main.rs && \
echo "fn main() {}" > solitaire_app/src/main.rs && \
echo "fn main() {}" > solitaire_assetgen/src/main.rs
RUN cargo fetch --locked RUN cargo fetch --locked
@@ -30,6 +38,7 @@ RUN cargo fetch --locked
COPY solitaire_core/src ./solitaire_core/src COPY solitaire_core/src ./solitaire_core/src
COPY solitaire_sync/src ./solitaire_sync/src COPY solitaire_sync/src ./solitaire_sync/src
COPY solitaire_server/src ./solitaire_server/src COPY solitaire_server/src ./solitaire_server/src
COPY solitaire_server/web ./solitaire_server/web
COPY solitaire_server/migrations ./solitaire_server/migrations COPY solitaire_server/migrations ./solitaire_server/migrations
# sqlx offline query cache — required when SQLX_OFFLINE=true so the # sqlx offline query cache — required when SQLX_OFFLINE=true so the
# compile-time macros don't need a live database. # compile-time macros don't need a live database.
@@ -43,11 +52,12 @@ FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=builder /build/target/release/solitaire_server ./solitaire_server COPY --from=builder /build/target/release/solitaire_server ./server
# Migrations are embedded via sqlx::migrate!("./migrations") at compile time. # Migrations are embedded via sqlx::migrate!("./migrations") at compile time.
# Static web assets are served via ServeDir at runtime from these paths: # Static web assets are served via ServeDir at runtime from these paths:
# /app/solitaire_server/web → /web route # /app/solitaire_server/web → /web route
@@ -58,4 +68,4 @@ COPY assets ./assets
ENV SERVER_PORT=8080 ENV SERVER_PORT=8080
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["./solitaire_server"] ENTRYPOINT ["./server"]
@@ -0,0 +1,15 @@
-- Analytics event store.
-- Events are write-only; the server never modifies rows after insert.
-- `INSERT OR IGNORE` on `id` makes submissions idempotent.
CREATE TABLE IF NOT EXISTS analytics_events (
id TEXT PRIMARY KEY NOT NULL, -- UUID v4 minted by the client
user_id TEXT, -- optional username; NULL = anonymous
session_id TEXT NOT NULL, -- UUID v4, one per app launch
event_type TEXT NOT NULL, -- e.g. "game_won", "game_start"
payload TEXT NOT NULL DEFAULT '{}', -- JSON blob, event-specific fields
client_time TEXT NOT NULL, -- ISO-8601, from the client clock
received_at TEXT NOT NULL -- ISO-8601, server clock at ingest
);
CREATE INDEX IF NOT EXISTS idx_analytics_event_type ON analytics_events(event_type);
CREATE INDEX IF NOT EXISTS idx_analytics_received_at ON analytics_events(received_at);
CREATE INDEX IF NOT EXISTS idx_analytics_user_id ON analytics_events(user_id);
+2 -1
View File
@@ -54,7 +54,8 @@ pub async fn get_leaderboard(
CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC, CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,
l.best_score DESC, l.best_score DESC,
CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC, CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,
l.best_time_secs ASC"# l.best_time_secs ASC
LIMIT 100"#
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
+51 -3
View File
@@ -1,4 +1,4 @@
//! Solitaire Quest sync server library. //! Ferrous Solitaire sync server library.
//! //!
//! Exposes [`build_router`] so integration tests can construct the full Axum //! Exposes [`build_router`] so integration tests can construct the full Axum
//! application against an in-memory SQLite database without starting a real //! application against an in-memory SQLite database without starting a real
@@ -16,8 +16,9 @@ pub use auth::reset_password;
use axum::{ use axum::{
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
http::{HeaderValue, Request},
middleware as axum_middleware, middleware as axum_middleware,
response::Html, response::{Html, Response},
routing::{delete, get, post}, routing::{delete, get, post},
Router, Router,
}; };
@@ -201,6 +202,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
// same regardless of `:id` — it reads the path from `location` in JS // same regardless of `:id` — it reads the path from `location` in JS
// and fetches the replay JSON from `/api/replays/:id`. // and fetches the replay JSON from `/api/replays/:id`.
let web = Router::new() let web = Router::new()
.route(
"/",
get(|| async { Html(include_str!("../web/home.html")) }),
)
.route( .route(
"/replays/{id}", "/replays/{id}",
get(|| async { Html(include_str!("../web/index.html")) }), get(|| async { Html(include_str!("../web/index.html")) }),
@@ -209,7 +214,21 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
"/play", "/play",
get(|| async { Html(include_str!("../web/game.html")) }), get(|| async { Html(include_str!("../web/game.html")) }),
) )
.nest_service("/web", ServeDir::new("solitaire_server/web")); .route(
"/account",
get(|| async { Html(include_str!("../web/account.html")) }),
)
.route(
"/leaderboard",
get(|| async { Html(include_str!("../web/leaderboard.html")) }),
)
.route(
"/replays",
get(|| async { Html(include_str!("../web/replays.html")) }),
)
.nest_service("/web", ServeDir::new("solitaire_server/web"))
.nest_service("/assets", ServeDir::new("assets"))
.layer(axum_middleware::from_fn(security_headers));
Router::new() Router::new()
.merge(protected) .merge(protected)
@@ -221,6 +240,35 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
.with_state(state) .with_state(state)
} }
const CSP: &str = concat!(
"default-src 'self'; ",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; ",
"style-src 'self' 'unsafe-inline'; ",
"font-src 'self'; ",
"img-src 'self' data:; ",
"connect-src 'self'; ",
"object-src 'none'; ",
"frame-ancestors 'none'",
);
async fn security_headers(req: Request<axum::body::Body>, next: axum_middleware::Next) -> Response {
let mut res = next.run(req).await;
let headers = res.headers_mut();
headers.insert(
"Content-Security-Policy",
HeaderValue::from_static(CSP),
);
headers.insert(
"X-Content-Type-Options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"X-Frame-Options",
HeaderValue::from_static("DENY"),
);
res
}
/// `GET /health` — simple liveness probe, no auth required. /// `GET /health` — simple liveness probe, no auth required.
async fn health() -> axum::Json<serde_json::Value> { async fn health() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
+13 -4
View File
@@ -1,4 +1,4 @@
//! Solitaire Quest sync server entry point. //! Ferrous Solitaire sync server entry point.
//! //!
//! Reads configuration from environment variables (via `dotenvy`), initialises //! Reads configuration from environment variables (via `dotenvy`), initialises
//! the SQLite database, runs migrations, then starts the Axum HTTP server. //! the SQLite database, runs migrations, then starts the Axum HTTP server.
@@ -32,9 +32,10 @@
//! ``` //! ```
use solitaire_server::{build_router, AppState}; use solitaire_server::{build_router, AppState};
use sqlx::SqlitePool; use sqlx::{sqlite::SqliteConnectOptions, SqlitePool};
use std::{ use std::{
io::{self, BufRead}, io::{self, BufRead},
str::FromStr,
net::SocketAddr, net::SocketAddr,
}; };
@@ -64,7 +65,11 @@ async fn main() {
async fn run_reset_password(username: &str) { async fn run_reset_password(username: &str) {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = SqlitePool::connect(&db_url) let pool = SqlitePool::connect_with(
SqliteConnectOptions::from_str(&db_url)
.expect("invalid DATABASE_URL")
.create_if_missing(true),
)
.await .await
.expect("failed to connect to database"); .expect("failed to connect to database");
@@ -105,7 +110,11 @@ async fn run_server() {
.parse() .parse()
.expect("SERVER_PORT must be a valid port number"); .expect("SERVER_PORT must be a valid port number");
let pool = SqlitePool::connect(&db_url) let pool = SqlitePool::connect_with(
SqliteConnectOptions::from_str(&db_url)
.expect("invalid DATABASE_URL")
.create_if_missing(true),
)
.await .await
.expect("failed to connect to database"); .expect("failed to connect to database");
+26
View File
@@ -114,6 +114,32 @@ pub async fn upload(
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Update leaderboard best score/time for opted-in users when this replay
// beats their existing best. Only classic mode counts for the leaderboard.
if header.mode == "classic" {
sqlx::query!(
r#"UPDATE leaderboard
SET best_score = ?,
best_time_secs = ?,
recorded_at = ?
WHERE user_id = ?
AND (
best_score IS NULL
OR ? > best_score
OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))
)"#,
header.final_score,
header.time_seconds,
header.recorded_at,
user.user_id,
header.final_score,
header.final_score,
header.time_seconds,
)
.execute(&state.pool)
.await?;
}
Ok(Json(ReplayUploadResponse { id })) Ok(Json(ReplayUploadResponse { id }))
} }
+273
View File
@@ -0,0 +1,273 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ferrous Solitaire — Account</title>
<style>
@font-face {
font-family: "FiraMono";
src: url("/assets/fonts/main.ttf") format("truetype");
}
:root {
--bg: #151515; --panel: #202020; --panel-hi: #2a2a2a;
--border: #353535; --text: #d0d0d0; --text-muted: #a0a0a0;
--accent: #a54242; --accent-hi: #c25e5e; --success: #acc267;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "FiraMono", "Fira Mono", monospace;
background: var(--bg); color: var(--text);
min-height: 100vh; display: flex; flex-direction: column;
}
header {
display: flex; align-items: center; gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--bg); z-index: 10;
}
.home-link {
color: var(--text-muted); text-decoration: none;
font-size: 18px; padding: 2px 4px; border-radius: 4px;
transition: color 120ms, background 120ms;
}
.home-link:hover { color: var(--text); background: var(--panel-hi); }
h1 { font-size: 16px; font-weight: 700; }
main {
flex: 1; display: flex; align-items: flex-start;
justify-content: center; padding: 40px 20px;
}
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 28px; width: 100%; max-width: 380px;
display: flex; flex-direction: column; gap: 20px;
}
/* ── Tabs ── */
.tabs {
display: flex; border-bottom: 1px solid var(--border);
margin-bottom: -4px;
}
.tab {
flex: 1; padding: 8px 0; text-align: center;
font-size: 13px; font-weight: 600; cursor: pointer;
color: var(--text-muted); border-bottom: 2px solid transparent;
transition: color 120ms, border-color 120ms;
}
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab:hover:not(.active) { color: var(--text); }
/* ── Form ── */
.form { display: flex; flex-direction: column; gap: 12px; }
label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
input {
background: var(--panel-hi); border: 1px solid var(--border);
border-radius: 6px; padding: 9px 12px; color: var(--text);
font-family: inherit; font-size: 14px; width: 100%;
transition: border-color 120ms;
}
input:focus { outline: none; border-color: var(--accent); }
.hint { font-size: 11px; color: var(--text-muted); }
.error-msg { color: var(--accent-hi); font-size: 12px; display: none; }
.success-msg { color: var(--success); font-size: 12px; display: none; }
button[type="submit"] {
background: var(--accent); color: var(--text); border: none;
border-radius: 6px; padding: 10px 16px; font-family: inherit;
font-size: 14px; font-weight: 700; cursor: pointer;
transition: background 120ms; margin-top: 4px;
}
button[type="submit"]:hover { background: var(--accent-hi); }
button[type="submit"]:disabled { opacity: 0.4; cursor: default; }
/* ── Signed-in state ── */
#signed-in { display: none; flex-direction: column; gap: 16px; }
.username-display {
font-size: 20px; font-weight: 700; text-align: center;
}
.signed-in-detail {
font-size: 13px; color: var(--text-muted); text-align: center;
}
.signed-in-actions { display: flex; flex-direction: column; gap: 8px; }
.btn-secondary {
background: var(--panel-hi); color: var(--text);
border: 1px solid var(--border); border-radius: 6px;
padding: 9px 16px; font-family: inherit; font-size: 13px;
font-weight: 600; cursor: pointer; transition: background 120ms;
text-align: center; text-decoration: none; display: block;
}
.btn-secondary:hover { background: var(--border); }
.btn-danger {
background: transparent; color: var(--accent-hi);
border: 1px solid var(--accent); border-radius: 6px;
padding: 9px 16px; font-family: inherit; font-size: 13px;
cursor: pointer; transition: background 120ms;
}
.btn-danger:hover { background: rgba(165, 66, 66, 0.15); }
</style>
</head>
<body>
<header>
<a href="/" class="home-link">&#8592;</a>
<h1>Account</h1>
</header>
<main>
<div class="card">
<!-- Signed-in view -->
<div id="signed-in">
<div class="signed-in-detail">Signed in as</div>
<div class="username-display" id="display-username"></div>
<div class="signed-in-actions">
<a class="btn-secondary" href="/leaderboard">View Leaderboard</a>
<a class="btn-secondary" href="/replays">Recent Replays</a>
<button class="btn-danger" id="btn-signout">Sign Out</button>
</div>
</div>
<!-- Auth forms -->
<div id="auth-section">
<div class="tabs">
<div class="tab active" data-tab="signin">Sign In</div>
<div class="tab" data-tab="signup">Create Account</div>
</div>
<!-- Sign In -->
<form class="form" id="form-signin" style="margin-top:20px">
<div>
<label for="si-user">Username</label>
<input type="text" id="si-user" placeholder="your_username" autocomplete="username">
</div>
<div>
<label for="si-pass">Password</label>
<input type="password" id="si-pass" placeholder="••••••••" autocomplete="current-password">
</div>
<div class="error-msg" id="si-error"></div>
<button type="submit">Sign In</button>
</form>
<!-- Sign Up -->
<form class="form" id="form-signup" style="display:none; margin-top:20px">
<div>
<label for="su-user">Username</label>
<input type="text" id="su-user" placeholder="your_username" autocomplete="username"
minlength="3" maxlength="32">
<div class="hint" style="margin-top:4px">332 characters, letters, digits, underscores</div>
</div>
<div>
<label for="su-pass">Password</label>
<input type="password" id="su-pass" placeholder="••••••••" autocomplete="new-password"
minlength="8">
<div class="hint" style="margin-top:4px">Minimum 8 characters</div>
</div>
<div>
<label for="su-pass2">Confirm Password</label>
<input type="password" id="su-pass2" placeholder="••••••••" autocomplete="new-password">
</div>
<div class="error-msg" id="su-error"></div>
<div class="success-msg" id="su-success"></div>
<button type="submit" id="btn-signup">Create Account</button>
</form>
</div>
</div>
</main>
<script>
const TOKEN_KEY = 'fs_token';
function getUsername(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.sub_name ?? payload.username ?? payload.sub ?? null;
} catch { return null; }
}
function showSignedIn(token) {
const username = getUsername(token);
document.getElementById('display-username').textContent = username ?? 'Player';
document.getElementById('signed-in').style.display = 'flex';
document.getElementById('auth-section').style.display = 'none';
}
function showAuth() {
document.getElementById('signed-in').style.display = 'none';
document.getElementById('auth-section').style.display = 'block';
}
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const which = tab.dataset.tab;
document.getElementById('form-signin').style.display = which === 'signin' ? 'flex' : 'none';
document.getElementById('form-signup').style.display = which === 'signup' ? 'flex' : 'none';
});
});
// Sign In
document.getElementById('form-signin').addEventListener('submit', async e => {
e.preventDefault();
const user = document.getElementById('si-user').value.trim();
const pass = document.getElementById('si-pass').value;
const err = document.getElementById('si-error');
err.style.display = 'none';
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass }),
});
if (!res.ok) {
err.textContent = 'Invalid username or password.';
err.style.display = 'block';
return;
}
const { access_token } = await res.json();
localStorage.setItem(TOKEN_KEY, access_token);
showSignedIn(access_token);
});
// Sign Up
document.getElementById('form-signup').addEventListener('submit', async e => {
e.preventDefault();
const user = document.getElementById('su-user').value.trim();
const pass = document.getElementById('su-pass').value;
const pass2 = document.getElementById('su-pass2').value;
const err = document.getElementById('su-error');
const ok = document.getElementById('su-success');
err.style.display = 'none';
ok.style.display = 'none';
if (pass !== pass2) {
err.textContent = 'Passwords do not match.';
err.style.display = 'block';
return;
}
const btn = document.getElementById('btn-signup');
btn.disabled = true;
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass }),
});
btn.disabled = false;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
err.textContent = body.message ?? 'Registration failed. Username may already be taken.';
err.style.display = 'block';
return;
}
const { access_token } = await res.json();
localStorage.setItem(TOKEN_KEY, access_token);
showSignedIn(access_token);
});
// Sign Out
document.getElementById('btn-signout').addEventListener('click', () => {
localStorage.removeItem(TOKEN_KEY);
showAuth();
});
// Initial state
const token = localStorage.getItem(TOKEN_KEY);
if (token) { showSignedIn(token); } else { showAuth(); }
</script>
</body>
</html>
+70 -67
View File
@@ -1,21 +1,28 @@
/* Solitaire Quest interactive game page. /* Ferrous Solitaire interactive game page.
Palette and card styles mirror replay.css; adds drag, selection, Palette mirrors the Bevy app's Terminal (base16-eighties) design system.
HUD, and win-overlay layers. */ Card faces/backs are PNG images served from /assets/cards/. */
@font-face {
font-family: "FiraMono";
src: url("/assets/fonts/main.ttf") format("truetype");
}
:root { :root {
--bg: #0f0a1f; --bg: #151515;
--felt: #0f4c30; --felt: #0f5232;
--panel: #1a0f2e; --panel: #202020;
--panel-hi: #2d1b69; --panel-hi: #2a2a2a;
--text: #f5f0ff; --text: #d0d0d0;
--text-muted: #b5a8d5; --text-muted: #a0a0a0;
--accent: #ffd23f; --accent: #a54242;
--red: #cc3344; --accent-hi: #c25e5e;
--black: #1a0f2e; --red: #fb9fb1;
--card-bg: #ffffff; --black: #151515;
--card-border: #ccc; --drop-success: #acc267;
--card-bg: #f8f5f0;
--card-border: #c8b8a0;
--card-w: 80px; --card-w: 80px;
--card-h: 112px; --card-h: 120px;
--gap: 12px; --gap: 12px;
--fan: 28px; --fan: 28px;
} }
@@ -23,7 +30,7 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-family: "FiraMono", "Fira Mono", monospace;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
@@ -54,6 +61,16 @@ header {
.logo { font-size: 16px; font-weight: 700; } .logo { font-size: 16px; font-weight: 700; }
.muted { color: var(--text-muted); font-size: 12px; } .muted { color: var(--text-muted); font-size: 12px; }
.home-link {
color: var(--text-muted);
text-decoration: none;
font-size: 18px;
line-height: 1;
padding: 2px 4px;
border-radius: 4px;
transition: color 120ms, background 120ms;
}
.home-link:hover { color: var(--text); background: var(--panel-hi); }
button { button {
background: var(--panel-hi); background: var(--panel-hi);
@@ -66,7 +83,7 @@ button {
font-family: inherit; font-family: inherit;
transition: background 120ms; transition: background 120ms;
} }
button:hover { background: var(--accent); color: var(--black); } button:hover { background: var(--accent); color: var(--text); }
button:disabled { opacity: 0.4; cursor: default; } button:disabled { opacity: 0.4; cursor: default; }
.toggle-label { .toggle-label {
@@ -84,25 +101,32 @@ button:disabled { opacity: 0.4; cursor: default; }
main { main {
flex: 1; flex: 1;
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: flex-start; overflow: hidden;
padding: 20px;
overflow-x: auto;
min-width: 0; min-width: 0;
} }
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
#board { #board {
position: relative; flex: 1;
background: var(--felt); background: var(--felt);
border-radius: 12px; display: flex;
padding: 20px; align-items: center;
/* 7 columns wide */ justify-content: center;
width: calc(7 * var(--card-w) + 6 * var(--gap) + 40px); overflow: hidden;
/* top row + generous fan budget for a 13-card column */
height: calc(var(--card-h) + 28px + var(--card-h) + 12 * var(--fan) + 40px);
cursor: default; cursor: default;
} }
/* Natural-size coordinate space for cards and slots.
The CSS transform scale is applied here, not on #board. */
#card-area {
position: relative;
flex-shrink: 0;
width: calc(7 * var(--card-w) + 6 * var(--gap) + 40px);
height: calc(var(--card-h) + 28px + var(--card-h) + 12 * var(--fan) + 40px);
touch-action: none; /* prevent browser scroll/pan from stealing pointer events */
}
/* Empty-pile slot markers */ /* Empty-pile slot markers */
.slot { .slot {
position: absolute; position: absolute;
@@ -114,8 +138,8 @@ main {
} }
.slot.drop-active { .slot.drop-active {
border-color: var(--accent); border-color: var(--drop-success);
background: rgba(255, 210, 63, 0.08); background: rgba(172, 194, 103, 0.10);
} }
/* ── Cards ───────────────────────────────────────────────────────────── */ /* ── Cards ───────────────────────────────────────────────────────────── */
@@ -125,13 +149,15 @@ main {
top: 0; left: 0; top: 0; left: 0;
width: var(--card-w); width: var(--card-w);
height: var(--card-h); height: var(--card-h);
background: var(--card-bg); background-color: var(--card-bg);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 6px; border-radius: 6px;
box-shadow: 0 2px 5px rgba(0,0,0,0.35); box-shadow: 0 2px 5px rgba(0,0,0,0.35);
padding: 4px 6px; padding: 0;
font-weight: 600; overflow: hidden;
line-height: 1;
cursor: grab; cursor: grab;
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1), transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 200ms ease, opacity 200ms ease,
@@ -142,40 +168,12 @@ main {
.card:active { cursor: grabbing; } .card:active { cursor: grabbing; }
.card.face-down { .card.face-down {
background: background-color: #2d1b69;
repeating-linear-gradient(
45deg,
#482f97 0, #482f97 6px,
#2d1b69 6px, #2d1b69 12px
);
color: transparent; color: transparent;
border-color: #4a3a8a; border-color: #4a3a8a;
cursor: default; cursor: default;
} }
.card .corner {
position: absolute;
font-size: 14px;
line-height: 1.1;
text-align: center;
}
.card .corner.top { top: 4px; left: 6px; }
/* No rotation — ♠ rotated 180° is indistinguishable from ♥ */
.card .corner.bottom { bottom: 4px; right: 6px; text-align: right; }
.card.red { color: var(--red); }
.card.black { color: var(--black); }
.card .center {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
font-size: 28px;
}
/* Stock pile: pointer cursor since it's a click target, not draggable */
.card.stock-card { cursor: pointer; }
/* Selected / being-dragged state */ /* Selected / being-dragged state */
.card.selected { .card.selected {
box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5); box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5);
@@ -185,14 +183,19 @@ main {
/* Drop-target highlight on cards (top of a tableau column) */ /* Drop-target highlight on cards (top of a tableau column) */
.card.drop-target { .card.drop-target {
box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5); box-shadow: 0 0 0 2px var(--drop-success), 0 4px 12px rgba(0,0,0,0.5);
} }
/* Recycle indicator on empty stock — JS sets transform to position it */ /* Recycle indicator on empty stock — sized to match the slot, symbol centred */
.recycle-label { .recycle-label {
position: absolute; position: absolute;
top: 0; left: 0; top: 0; left: 0;
font-size: 26px; width: var(--card-w);
height: var(--card-h);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: rgba(255,255,255,0.3); color: rgba(255,255,255,0.3);
pointer-events: none; pointer-events: none;
} }
@@ -202,7 +205,7 @@ main {
#win-overlay { #win-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(10, 5, 20, 0.75); background: rgba(21, 21, 21, 0.92);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+20 -3
View File
@@ -3,13 +3,28 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Solitaire Quest — Play</title> <title>Ferrous Solitaire — Play</title>
<link rel="stylesheet" href="/web/game.css"> <link rel="stylesheet" href="/web/game.css">
<!-- Matomo -->
<script>
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u = "https://analytics.aleshym.co/";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', '1']);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
})();
</script>
<!-- End Matomo -->
</head> </head>
<body> <body>
<header> <header>
<div class="hud-left"> <div class="hud-left">
<span class="logo">Solitaire Quest</span> <a href="/" class="home-link" title="Home">&#8592;</a>
<span class="logo">Ferrous Solitaire</span>
<span id="hud-seed" class="muted"></span> <span id="hud-seed" class="muted"></span>
</div> </div>
<div class="hud-center"> <div class="hud-center">
@@ -28,7 +43,9 @@
</header> </header>
<main> <main>
<section id="board"></section> <section id="board">
<div id="card-area"></div>
</section>
</main> </main>
<div id="win-overlay" class="hidden"> <div id="win-overlay" class="hidden">
+77 -13
View File
@@ -1,4 +1,4 @@
// Solitaire Quest — interactive browser game. // Ferrous Solitaire — interactive browser game.
// //
// Architecture: // Architecture:
// - `SolitaireGame` (Rust/WASM via solitaire_core) owns all rule logic. // - `SolitaireGame` (Rust/WASM via solitaire_core) owns all rule logic.
@@ -14,12 +14,16 @@ import init, { SolitaireGame } from "/web/pkg/solitaire_wasm.js";
// ── Layout constants (must match game.css --card-w / --card-h / --gap / --pad) // ── Layout constants (must match game.css --card-w / --card-h / --gap / --pad)
const CARD_W = 80; const CARD_W = 80;
const CARD_H = 112; const CARD_H = 120; // 2:3 ratio matching 256×384 card PNGs
const GAP = 12; const GAP = 12;
const PAD = 20; // board inner padding — cards start at (PAD, PAD) const PAD = 20; // board inner padding — cards start at (PAD, PAD)
const FAN = 28; // vertical offset per fanned tableau card const FAN = 28; // vertical offset per fanned tableau card
const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan
// Natural board dimensions — used for scale-to-fit calculation.
const BOARD_W = PAD * 2 + 7 * CARD_W + 6 * GAP; // 672
const BOARD_H = PAD * 2 + CARD_H + 28 + CARD_H + 12 * FAN; // 644
// Pile origins in board-element coordinates (include PAD so (0,0) = board edge). // Pile origins in board-element coordinates (include PAD so (0,0) = board edge).
const TOP_Y = PAD; const TOP_Y = PAD;
const BOTTOM_Y = PAD + CARD_H + 28; const BOTTOM_Y = PAD + CARD_H + 28;
@@ -45,10 +49,20 @@ const PILE_ORIGIN = {
// Foundation suit hints shown when the slot is empty. // Foundation suit hints shown when the slot is empty.
const FOUND_SUIT_HINT = ["♠", "♥", "♦", "♣"]; const FOUND_SUIT_HINT = ["♠", "♥", "♦", "♣"];
const SUIT_GLYPH = { clubs: "", diamonds: "", hearts: "", spades: "" }; const SUIT_CODE = { clubs: "C", diamonds: "D", hearts: "H", spades: "S" };
const RANK_LABELS = ["","A","2","3","4","5","6","7","8","9","10","J","Q","K"]; const RANK_LABELS = ["","A","2","3","4","5","6","7","8","9","10","J","Q","K"];
const RED_SUITS = new Set(["diamonds", "hearts"]); const RED_SUITS = new Set(["diamonds", "hearts"]);
// Preload all card images so face-up transitions never flash white.
(function preloadCards() {
const suits = Object.values(SUIT_CODE);
const ranks = RANK_LABELS.slice(1); // skip empty index 0
for (const r of ranks) for (const s of suits) {
new Image().src = `/assets/cards/faces/${r}${s}.png`;
}
new Image().src = "/assets/cards/backs/back_0.png";
}());
// ── State ──────────────────────────────────────────────────────────────────── // ── State ────────────────────────────────────────────────────────────────────
let game = null; let game = null;
let snap = null; // last rendered GameSnapshot let snap = null; // last rendered GameSnapshot
@@ -73,8 +87,11 @@ let elapsedSecs = 0;
// Auto-complete // Auto-complete
let acTimer = null; let acTimer = null;
// Current scale factor applied to #board.
let boardScale = 1.0;
// ── DOM refs ───────────────────────────────────────────────────────────────── // ── DOM refs ─────────────────────────────────────────────────────────────────
const board = document.getElementById("board"); const board = document.getElementById("card-area");
const hudScore = document.getElementById("hud-score"); const hudScore = document.getElementById("hud-score");
const hudMoves = document.getElementById("hud-moves"); const hudMoves = document.getElementById("hud-moves");
const hudTimer = document.getElementById("hud-timer"); const hudTimer = document.getElementById("hud-timer");
@@ -89,6 +106,21 @@ const winMoves = document.getElementById("win-moves");
const winTime = document.getElementById("win-time"); const winTime = document.getElementById("win-time");
const btnWinNew = document.getElementById("btn-win-new"); const btnWinNew = document.getElementById("btn-win-new");
// ── Scale to fit ─────────────────────────────────────────────────────────────
// Scales #card-area to fill #board without overflowing either dimension.
// boardRelative() divides by boardScale to keep hit-testing correct.
function scaleBoard() {
// Measure the actual rendered #board element — more reliable than
// computing window.innerHeight minus estimated header height, which
// breaks under different browser chrome / OS scaling factors.
const outerBoard = document.getElementById("board");
const bw = outerBoard.clientWidth;
const bh = outerBoard.clientHeight;
boardScale = Math.min(bw / BOARD_W, bh / BOARD_H, 2.0);
board.style.transform = `scale(${boardScale})`;
board.style.transformOrigin = "center center";
}
// ── Bootstrap ──────────────────────────────────────────────────────────────── // ── Bootstrap ────────────────────────────────────────────────────────────────
async function bootstrap() { async function bootstrap() {
await init(); await init();
@@ -99,6 +131,8 @@ async function bootstrap() {
chkDraw3.checked = drawThree; chkDraw3.checked = drawThree;
buildSlots(); buildSlots();
scaleBoard();
window.addEventListener("resize", scaleBoard);
startGame(urlSeed); startGame(urlSeed);
attachHandlers(); attachHandlers();
} }
@@ -242,7 +276,7 @@ function render(s) {
board.appendChild(recycleEl); board.appendChild(recycleEl);
} }
const o = PILE_ORIGIN.stock; const o = PILE_ORIGIN.stock;
recycleEl.style.transform = `translate(${o.x + CARD_W / 2}px, ${o.y + CARD_H / 2}px)`; recycleEl.style.transform = `translate(${o.x}px, ${o.y}px)`;
} else if (recycleEl) { } else if (recycleEl) {
recycleEl.remove(); recycleEl.remove();
} }
@@ -268,15 +302,13 @@ function updateCardEl(el, card, pileName, idx, total) {
if (!card.face_up) { if (!card.face_up) {
el.className = "card face-down"; el.className = "card face-down";
el.style.backgroundImage = "url('/assets/cards/backs/back_0.png')";
el.innerHTML = ""; el.innerHTML = "";
} else { } else {
const isRed = RED_SUITS.has(card.suit); const isRed = RED_SUITS.has(card.suit);
el.className = `card ${isRed ? "red" : "black"}`; el.className = `card ${isRed ? "red" : "black"}`;
const r = RANK_LABELS[card.rank]; el.style.backgroundImage = `url('/assets/cards/faces/${RANK_LABELS[card.rank]}${SUIT_CODE[card.suit]}.png')`;
const s = SUIT_GLYPH[card.suit]; el.innerHTML = "";
el.innerHTML = `<div class="corner top">${r}<br>${s}</div>
<div class="center">${s}</div>
<div class="corner bottom">${r}<br>${s}</div>`;
} }
} }
@@ -288,6 +320,30 @@ function showWin(s) {
const sec = elapsedSecs % 60; const sec = elapsedSecs % 60;
if (winTime) winTime.textContent = `${m}:${sec.toString().padStart(2, "0")}`; if (winTime) winTime.textContent = `${m}:${sec.toString().padStart(2, "0")}`;
winOverlay.classList.remove("hidden"); winOverlay.classList.remove("hidden");
submitReplay(s);
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token) return;
const payload = {
schema_version: 1,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "draw_three" : "draw_one",
mode: "classic",
time_seconds: elapsedSecs,
final_score: s.score,
move_count: s.move_count,
recorded_at: new Date().toISOString(),
moves: [],
};
try {
await fetch('/api/replays', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(payload),
});
} catch (_) { /* best-effort — never block the win screen */ }
} }
// ── Auto-complete ───────────────────────────────────────────────────────────── // ── Auto-complete ─────────────────────────────────────────────────────────────
@@ -345,9 +401,14 @@ function attachHandlers() {
// ── Coordinate helpers ──────────────────────────────────────────────────────── // ── Coordinate helpers ────────────────────────────────────────────────────────
// Returns cursor position in board-element coordinates // Returns cursor position in board-element coordinates
// (0,0 = board element top-left corner, which is the padding edge). // (0,0 = board element top-left corner, which is the padding edge).
// Divides by boardScale because getBoundingClientRect() returns the SCALED
// visual rect; we need coordinates in the natural (pre-scale) system.
function boardRelative(clientX, clientY) { function boardRelative(clientX, clientY) {
const rect = board.getBoundingClientRect(); const rect = board.getBoundingClientRect();
return { x: clientX - rect.left, y: clientY - rect.top }; return {
x: (clientX - rect.left) / boardScale,
y: (clientY - rect.top) / boardScale,
};
} }
function hitTestCard(bx, by) { function hitTestCard(bx, by) {
@@ -487,6 +548,8 @@ function onPointerUp(e) {
const targetPile = findDropTarget(bx, by); const targetPile = findDropTarget(bx, by);
let moved = false; let moved = false;
let illegalAttempt = false;
if (targetPile && targetPile !== drag.fromPile) { if (targetPile && targetPile !== drag.fromPile) {
const r = game.move_cards(drag.fromPile, targetPile, drag.cardIds.length); const r = game.move_cards(drag.fromPile, targetPile, drag.cardIds.length);
if (r.ok) { if (r.ok) {
@@ -494,13 +557,14 @@ function onPointerUp(e) {
drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected")); drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected"));
render(r.snapshot); render(r.snapshot);
} else { } else {
flashIllegal(drag.cardIds); illegalAttempt = true;
} }
} }
if (!moved) { if (!moved) {
drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected")); drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected"));
render(snap); // snap cards back to their pre-drag positions render(snap); // snap cards back first — then animate so shake plays on settled positions
if (illegalAttempt) flashIllegal(drag.cardIds);
} }
drag = null; drag = null;
+163
View File
@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ferrous Solitaire</title>
<style>
@font-face {
font-family: "FiraMono";
src: url("/assets/fonts/main.ttf") format("truetype");
}
:root {
--bg: #151515;
--panel: #202020;
--panel-hi: #2a2a2a;
--border: #353535;
--text: #d0d0d0;
--text-muted: #a0a0a0;
--accent: #a54242;
--accent-hi: #c25e5e;
--felt: #0f5232;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "FiraMono", "Fira Mono", monospace;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
}
.logo {
font-size: 28px;
font-weight: 700;
letter-spacing: 0.02em;
margin-bottom: 6px;
}
.tagline {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 48px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.cards {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 360px;
}
.card {
display: flex;
align-items: center;
gap: 16px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px 24px;
text-decoration: none;
color: var(--text);
transition: background 120ms, border-color 120ms, transform 120ms;
cursor: pointer;
}
.card:hover {
background: var(--panel-hi);
border-color: var(--accent);
transform: translateY(-2px);
}
.card-icon {
font-size: 28px;
width: 40px;
text-align: center;
flex-shrink: 0;
}
.card-body {
display: flex;
flex-direction: column;
gap: 3px;
}
.card-title {
font-size: 15px;
font-weight: 700;
}
.card-desc {
font-size: 12px;
color: var(--text-muted);
}
.divider {
width: 100%;
max-width: 360px;
height: 1px;
background: var(--border);
margin: 8px 0;
}
footer {
margin-top: 48px;
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<div class="logo">Ferrous Solitaire</div>
<div class="tagline">Klondike Solitaire</div>
<nav class="cards">
<a class="card" href="/play">
<div class="card-icon">&#9824;</div>
<div class="card-body">
<div class="card-title">Play</div>
<div class="card-desc">Start a new game of Klondike solitaire</div>
</div>
</a>
<div class="divider"></div>
<a class="card" href="/leaderboard">
<div class="card-icon">&#9733;</div>
<div class="card-body">
<div class="card-title">Leaderboard</div>
<div class="card-desc">Top scores from all players</div>
</div>
</a>
<a class="card" href="/replays">
<div class="card-icon">&#9654;</div>
<div class="card-body">
<div class="card-title">Recent Replays</div>
<div class="card-desc">Watch recent completed games</div>
</div>
</a>
<a class="card" href="/account">
<div class="card-icon">&#9786;</div>
<div class="card-body">
<div class="card-title">Account</div>
<div class="card-desc">Sign in or create a new account</div>
</div>
</a>
</nav>
<footer>v0.1.0</footer>
</body>
</html>
+2 -2
View File
@@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Solitaire Quest — Replay</title> <title>Ferrous Solitaire — Replay</title>
<link rel="stylesheet" href="/web/replay.css"> <link rel="stylesheet" href="/web/replay.css">
</head> </head>
<body> <body>
<header> <header>
<h1>Solitaire Quest <span class="muted">— Replay</span></h1> <h1>Ferrous Solitaire <span class="muted">— Replay</span></h1>
<div id="caption" class="muted">Loading…</div> <div id="caption" class="muted">Loading…</div>
</header> </header>
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ferrous Solitaire — Leaderboard</title>
<style>
@font-face {
font-family: "FiraMono";
src: url("/assets/fonts/main.ttf") format("truetype");
}
:root {
--bg: #151515; --panel: #202020; --panel-hi: #2a2a2a;
--border: #353535; --text: #d0d0d0; --text-muted: #a0a0a0;
--accent: #a54242; --accent-hi: #c25e5e; --success: #acc267;
--warning: #ddb26f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "FiraMono", "Fira Mono", monospace;
background: var(--bg); color: var(--text);
min-height: 100vh; display: flex; flex-direction: column;
}
header {
display: flex; align-items: center; gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--bg); z-index: 10;
}
.home-link {
color: var(--text-muted); text-decoration: none;
font-size: 18px; padding: 2px 4px; border-radius: 4px;
transition: color 120ms, background 120ms;
}
.home-link:hover { color: var(--text); background: var(--panel-hi); }
h1 { font-size: 16px; font-weight: 700; }
main { flex: 1; padding: 24px 20px; max-width: 720px; width: 100%; margin: 0 auto; }
#status { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
#login-prompt {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 24px; max-width: 360px;
display: flex; flex-direction: column; gap: 12px;
}
#login-prompt p { font-size: 13px; color: var(--text-muted); }
#login-prompt input {
background: var(--panel-hi); border: 1px solid var(--border);
border-radius: 6px; padding: 8px 12px; color: var(--text);
font-family: inherit; font-size: 14px; width: 100%;
}
#login-prompt input:focus { outline: none; border-color: var(--accent); }
#login-prompt button {
background: var(--accent); color: var(--text); border: none;
border-radius: 6px; padding: 9px 16px; font-family: inherit;
font-size: 14px; font-weight: 700; cursor: pointer;
transition: background 120ms;
}
#login-prompt button:hover { background: var(--accent-hi); }
#error-msg { color: var(--accent-hi); font-size: 12px; display: none; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
thead th {
text-align: left; padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--text-muted); font-weight: 600; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.05em;
}
tbody tr { border-bottom: 1px solid var(--border); }
tbody tr:last-child { border-bottom: none; }
tbody td { padding: 10px 12px; }
.rank { color: var(--text-muted); width: 40px; }
.rank-1 { color: var(--warning); font-weight: 700; }
.rank-2 { color: var(--text-muted); }
.rank-3 { color: var(--accent); }
.score { font-weight: 700; color: var(--success); }
.time { color: var(--text-muted); }
</style>
</head>
<body>
<header>
<a href="/" class="home-link">&#8592;</a>
<h1>Leaderboard</h1>
</header>
<main>
<div id="status">Loading…</div>
<div id="login-prompt" style="display:none">
<p>Sign in to view the leaderboard. <a href="/account" style="color:var(--accent-hi);text-decoration:none">Create an account</a> if you don't have one.</p>
<input type="text" id="inp-user" placeholder="Username" autocomplete="username">
<input type="password" id="inp-pass" placeholder="Password" autocomplete="current-password">
<div id="error-msg"></div>
<button id="btn-login">Sign In</button>
</div>
<table id="table" style="display:none">
<thead><tr>
<th class="rank">#</th>
<th>Player</th>
<th>Best Score</th>
<th>Best Time</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
</main>
<script>
const TOKEN_KEY = 'fs_token';
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtTime(secs) {
if (!secs) return '—';
const m = Math.floor(secs / 60), s = secs % 60;
return `${m}:${String(s).padStart(2,'0')}`;
}
async function load(token) {
const res = await fetch('/api/leaderboard', {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401 || res.status === 403) { showLogin(); return; }
if (!res.ok) { document.getElementById('status').textContent = 'Failed to load leaderboard.'; return; }
const rows = await res.json();
document.getElementById('status').style.display = 'none';
const table = document.getElementById('table');
const tbody = document.getElementById('tbody');
if (!rows.length) {
document.getElementById('status').textContent = 'No entries yet.';
document.getElementById('status').style.display = 'block';
return;
}
tbody.innerHTML = rows.map((r, i) => `
<tr>
<td class="rank rank-${i+1}">${i+1}</td>
<td>${esc(r.display_name) || '—'}</td>
<td class="score">${r.best_score?.toLocaleString() ?? '—'}</td>
<td class="time">${fmtTime(r.best_time_secs)}</td>
</tr>`).join('');
table.style.display = 'table';
}
function showLogin() {
document.getElementById('status').style.display = 'none';
document.getElementById('login-prompt').style.display = 'flex';
}
document.getElementById('btn-login').addEventListener('click', async () => {
const user = document.getElementById('inp-user').value.trim();
const pass = document.getElementById('inp-pass').value;
const err = document.getElementById('error-msg');
err.style.display = 'none';
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
if (!res.ok) { err.textContent = 'Invalid username or password.'; err.style.display = 'block'; return; }
const { access_token } = await res.json();
localStorage.setItem(TOKEN_KEY, access_token);
document.getElementById('login-prompt').style.display = 'none';
document.getElementById('status').textContent = 'Loading…';
document.getElementById('status').style.display = 'block';
load(access_token);
});
const token = localStorage.getItem(TOKEN_KEY);
if (token) { load(token); } else { showLogin(); }
</script>
</body>
</html>
+6 -1
View File
@@ -1,3 +1,5 @@
/* @ts-self-types="./solitaire_wasm.d.ts" */
/** /**
* Browser-side replay state machine. Owns a live `GameState` and the * Browser-side replay state machine. Owns a live `GameState` and the
* replay's move list; each `step()` applies the next move. * replay's move list; each `step()` applies the next move.
@@ -92,7 +94,10 @@ export class SolitaireGame {
} }
/** /**
* Apply one auto-complete move (only valid when `is_auto_completable`). * Apply one auto-complete move (only valid when `is_auto_completable`).
* Returns the post-move snapshot or `null` when auto-complete is unavailable. *
* If no card can go directly to a foundation this step, advances the
* waste by calling `draw()` so the next step can try again. Returns the
* post-move snapshot, or `null` when no progress is possible.
* @returns {any} * @returns {any}
*/ */
auto_complete_step() { auto_complete_step() {
Binary file not shown.
+24 -22
View File
@@ -1,19 +1,21 @@
/* Solitaire Quest replay viewer palette mirrors the desktop client's /* Ferrous Solitaire replay viewer Terminal (base16-eighties) palette,
midnight-purple Balatro tone (BG_BASE = #1A0F2E) and the dark felt matching the Bevy desktop/Android app's ui_theme.rs tokens exactly. */
from the engine's TABLE_COLOUR. */
:root { :root {
--bg: #0f0a1f; --bg: #151515;
--felt: #0f4c30; --felt: #0f5232;
--panel: #1a0f2e; --panel: #202020;
--panel-hi: #2d1b69; --panel-hi: #2a2a2a;
--text: #f5f0ff; --text: #d0d0d0;
--text-muted: #b5a8d5; --text-muted: #a0a0a0;
--accent: #ffd23f; --accent: #a54242;
--red: #cc3344; --accent-hi: #c25e5e;
--black: #1a0f2e; --red: #fb9fb1;
--card-bg: #ffffff; --black: #151515;
--card-border: #ccc; --success: #acc267;
--warning: #ddb26f;
--card-bg: #f8f5f0;
--card-border: #c8b8a0;
--card-w: 80px; --card-w: 80px;
--card-h: 112px; --card-h: 112px;
--gap: 12px; --gap: 12px;
@@ -114,13 +116,13 @@ main {
background: background:
repeating-linear-gradient( repeating-linear-gradient(
45deg, 45deg,
#482f97 0, #2a2a2a 0,
#482f97 6px, #2a2a2a 6px,
#2d1b69 6px, #202020 6px,
#2d1b69 12px #202020 12px
); );
color: transparent; color: transparent;
border-color: #4a3a8a; border-color: #353535;
} }
.card .corner { .card .corner {
@@ -162,8 +164,8 @@ main {
} }
#controls button:hover:not(:disabled) { #controls button:hover:not(:disabled) {
background: var(--accent); background: var(--accent-hi);
color: var(--black); color: var(--text);
} }
#controls button:disabled { #controls button:disabled {
@@ -178,6 +180,6 @@ main {
} }
#status #result.win { #status #result.win {
color: var(--accent); color: var(--success);
font-weight: 600; font-weight: 600;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// Solitaire Quest replay viewer. // Ferrous Solitaire replay viewer.
// //
// Pulls the replay JSON from `/api/replays/:id`, hands it to the // Pulls the replay JSON from `/api/replays/:id`, hands it to the
// `solitaire_wasm` ReplayPlayer (which owns a real solitaire_core // `solitaire_wasm` ReplayPlayer (which owns a real solitaire_core
+130
View File
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ferrous Solitaire — Recent Replays</title>
<style>
@font-face {
font-family: "FiraMono";
src: url("/assets/fonts/main.ttf") format("truetype");
}
:root {
--bg: #151515; --panel: #202020; --panel-hi: #2a2a2a;
--border: #353535; --text: #d0d0d0; --text-muted: #a0a0a0;
--accent: #a54242; --accent-hi: #c25e5e; --success: #acc267;
--warning: #ddb26f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "FiraMono", "Fira Mono", monospace;
background: var(--bg); color: var(--text);
min-height: 100vh; display: flex; flex-direction: column;
}
header {
display: flex; align-items: center; gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--bg); z-index: 10;
}
.home-link {
color: var(--text-muted); text-decoration: none;
font-size: 18px; padding: 2px 4px; border-radius: 4px;
transition: color 120ms, background 120ms;
}
.home-link:hover { color: var(--text); background: var(--panel-hi); }
h1 { font-size: 16px; font-weight: 700; }
main { flex: 1; padding: 24px 20px; max-width: 860px; width: 100%; margin: 0 auto; }
#status { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
thead th {
text-align: left; padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--text-muted); font-weight: 600; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.05em;
}
tbody tr {
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 100ms;
}
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--panel); }
tbody td { padding: 10px 12px; }
.player { font-weight: 600; }
.score { font-weight: 700; color: var(--success); }
.time { color: var(--text-muted); }
.meta { color: var(--text-muted); font-size: 12px; }
.draw-badge {
display: inline-block; padding: 1px 6px;
border-radius: 4px; font-size: 11px; font-weight: 700;
background: var(--panel-hi); color: var(--text-muted);
}
.watch-link {
color: var(--accent); text-decoration: none; font-size: 13px;
}
.watch-link:hover { color: var(--accent-hi); }
</style>
</head>
<body>
<header>
<a href="/" class="home-link">&#8592;</a>
<h1>Recent Replays</h1>
</header>
<main>
<div id="status">Loading…</div>
<table id="table" style="display:none">
<thead><tr>
<th>Player</th>
<th>Score</th>
<th>Time</th>
<th>Seed</th>
<th>Mode</th>
<th></th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
</main>
<script>
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtTime(secs) {
if (!secs) return '—';
const m = Math.floor(secs / 60), s = secs % 60;
return `${m}:${String(s).padStart(2,'0')}`;
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
async function load() {
const res = await fetch('/api/replays/recent');
if (!res.ok) {
document.getElementById('status').textContent = 'Failed to load replays.';
return;
}
const rows = await res.json();
const status = document.getElementById('status');
if (!rows.length) {
status.textContent = 'No replays yet — finish a game to record one.';
return;
}
status.style.display = 'none';
const tbody = document.getElementById('tbody');
tbody.innerHTML = rows.map(r => `
<tr onclick="location.href='/replays/${esc(r.id)}'">
<td class="player">${esc(r.username) || '—'}</td>
<td class="score">${r.final_score?.toLocaleString() ?? '—'}</td>
<td class="time">${fmtTime(r.time_seconds)}</td>
<td class="meta">${r.seed ?? '—'}</td>
<td><span class="draw-badge">Draw ${r.draw_mode === 'draw_three' ? '3' : '1'}</span></td>
<td><a class="watch-link" href="/replays/${esc(r.id)}">Watch &#9654;</a></td>
</tr>`).join('');
document.getElementById('table').style.display = 'table';
}
load();
</script>
</body>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
//! Shared API types and merge logic for Solitaire Quest. //! Shared API types and merge logic for Ferrous Solitaire.
//! //!
//! This crate is the contract between the game client (`solitaire_data`) and //! This crate is the contract between the game client (`solitaire_data`) and
//! the sync server (`solitaire_server`). Changing any public type here is a //! the sync server (`solitaire_server`). Changing any public type here is a
+10 -5
View File
@@ -412,17 +412,22 @@ impl SolitaireGame {
} }
/// Apply one auto-complete move (only valid when `is_auto_completable`). /// Apply one auto-complete move (only valid when `is_auto_completable`).
/// Returns the post-move snapshot or `null` when auto-complete is unavailable. ///
/// If no card can go directly to a foundation this step, advances the
/// waste by calling `draw()` so the next step can try again. Returns the
/// post-move snapshot, or `null` when no progress is possible.
pub fn auto_complete_step(&mut self) -> JsValue { pub fn auto_complete_step(&mut self) -> JsValue {
if !self.game.is_auto_completable { if !self.game.is_auto_completable {
return JsValue::NULL; return JsValue::NULL;
} }
match self.game.next_auto_complete_move() { if let Some((from, to)) = self.game.next_auto_complete_move() {
Some((from, to)) => {
let _ = self.game.move_cards(from, to, 1); let _ = self.game.move_cards(from, to, 1);
self.ok_js() return self.ok_js();
} }
None => JsValue::NULL, // No direct foundation move — advance through the waste.
match self.game.draw() {
Ok(()) => self.ok_js(),
Err(_) => JsValue::NULL,
} }
} }
} }