Compare commits
40 Commits
77df2d2aef
...
6905f26b56
| Author | SHA1 | Date | |
|---|---|---|---|
| 6905f26b56 | |||
| 1b7c4d92aa | |||
| d685224ce6 | |||
| 539779d78b | |||
| f6506c57e5 | |||
| b88f3df119 | |||
| 0dcb783e94 | |||
| ea17f94b6c | |||
| d60dc18add | |||
| 38eefb22e8 | |||
| a579c25d5c | |||
| c40817d845 | |||
| c6c03b8bff | |||
| 5b3925a619 | |||
| 8485b3d1e0 | |||
| 8325bf6cf7 | |||
| ea58f5dd64 | |||
| c518255a2d | |||
| f5da9398f2 | |||
| b82573e7b1 | |||
| 40818f5bd2 | |||
| 228ebbad8a | |||
| 2b33feafc9 | |||
| f8c8c9158e | |||
| 9cc0837088 | |||
| b47462bd27 | |||
| 08d22c822a | |||
| feb581005c | |||
| 00f2d890f1 | |||
| 9533a7d420 | |||
| 5ec5ac1a19 | |||
| 86aea206b8 | |||
| 1bd1c0f927 | |||
| 7be7f4395c | |||
| 66c2907c25 | |||
| c2811fa661 | |||
| 933cc55ea9 | |||
| 58faae1911 | |||
| 96be1b85fb | |||
| bbf7709912 |
@@ -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
|
||||||
|
|||||||
@@ -14,4 +14,10 @@ data/
|
|||||||
# Android signing keystores — never commit
|
# Android signing keystores — never commit
|
||||||
*.jks
|
*.jks
|
||||||
*.jks.bak
|
*.jks.bak
|
||||||
|
*.jks.bak*
|
||||||
*.keystore
|
*.keystore
|
||||||
|
|
||||||
|
# Kubernetes secrets — apply manually, never commit
|
||||||
|
deploy/matomo-secret.yaml
|
||||||
|
deploy/*-secret.yaml
|
||||||
|
deploy/*-auth-secret.yaml
|
||||||
|
|||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+2
-2
@@ -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"
|
||||||
}
|
}
|
||||||
+12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -1,4 +1,4 @@
|
|||||||
# Solitaire Quest — Self-Hosting Guide
|
# Ferrous Solitaire — Self-Hosting Guide
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ 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:
|
||||||
server: https://kubernetes.default.svc
|
server: https://kubernetes.default.svc
|
||||||
namespace: solitaire
|
namespace: solitaire
|
||||||
|
# Secrets are applied manually and must not be pruned by ArgoCD.
|
||||||
|
ignoreDifferences:
|
||||||
|
- group: ""
|
||||||
|
kind: Secret
|
||||||
|
name: matomo-secret
|
||||||
|
namespace: solitaire
|
||||||
|
jsonPointers:
|
||||||
|
- /data
|
||||||
syncPolicy:
|
syncPolicy:
|
||||||
automated:
|
automated:
|
||||||
prune: true
|
prune: true
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,10 +7,17 @@ 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-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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: mariadb-data
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: matomo-data
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# DO NOT COMMIT THE REAL VERSION OF THIS FILE.
|
||||||
|
# deploy/matomo-secret.yaml is gitignored — apply it manually once:
|
||||||
|
#
|
||||||
|
# cp deploy/matomo-secret.yaml.example deploy/matomo-secret.yaml
|
||||||
|
# # edit the passwords below, then:
|
||||||
|
# kubectl apply -f deploy/matomo-secret.yaml
|
||||||
|
# kubectl annotate secret matomo-secret -n solitaire \
|
||||||
|
# argocd.argoproj.io/sync-options=Prune=false --overwrite
|
||||||
|
#
|
||||||
|
# Generate strong passwords with:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_urlsafe(18))"
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: matomo-secret
|
||||||
|
namespace: solitaire
|
||||||
|
stringData:
|
||||||
|
MYSQL_ROOT_PASSWORD: "CHANGE_ME"
|
||||||
|
MYSQL_DATABASE: matomo
|
||||||
|
MYSQL_USER: matomo
|
||||||
|
MYSQL_PASSWORD: "CHANGE_ME"
|
||||||
|
MATOMO_ADMIN_PASSWORD: "CHANGE_ME"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: matomo
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: matomo
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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
@@ -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);
|
||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">←</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">3–32 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">←</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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">♠</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">★</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">▶</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">☺</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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">←</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
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>
|
||||||
@@ -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.
@@ -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,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
|
||||||
|
|||||||
@@ -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">←</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
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 ▶</a></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
document.getElementById('table').style.display = 'table';
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user