feat(server): web replay viewer (HTML/CSS + WASM bindings)
Wires the WASM module from the previous commit into a minimal web
viewer served at <server>/replays/<id>. Two new server routes:
- `GET /replays/:id` — returns the same embedded HTML page for any
id; the page itself reads the path from window.location in JS and
fetches the replay JSON via /api/replays/:id.
- `/web/*` — ServeDir for the static assets (replay.css, replay.js,
and the wasm-bindgen-generated pkg/).
Web layer:
- index.html — header, board, controls, status. Module script.
- replay.css — midnight-purple palette matching the desktop client,
dark felt board, CSS-grid pile layout, tableau fan via per-card
inline `top` offset.
- replay.js — fetches the replay, instantiates the wasm
ReplayPlayer, drives state(), step(). Controls: Restart, Play/Pause
toggle, Step. Auto-tick at 600 ms.
- pkg/ — generated by wasm-bindgen (committed so deployers don't
have to install wasm-bindgen-cli + the wasm32 target).
`tower-http = "0.6"` added to solitaire_server with the `fs` feature
for ServeDir.
To regenerate pkg/ after a solitaire_wasm change:
RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
cargo build -p solitaire_wasm \
--target wasm32-unknown-unknown --release
wasm-bindgen --target web \
--out-dir solitaire_server/web/pkg --no-typescript \
target/wasm32-unknown-unknown/release/solitaire_wasm.wasm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+33
@@ -4321,6 +4321,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -5280,6 +5286,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -7721,6 +7737,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
|
"tower-http",
|
||||||
"tower_governor",
|
"tower_governor",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -8783,14 +8800,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
"iri-string",
|
"iri-string",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9207,6 +9234,12 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ sqlx = { workspace = true }
|
|||||||
jsonwebtoken = { workspace = true }
|
jsonwebtoken = { workspace = true }
|
||||||
bcrypt = { workspace = true }
|
bcrypt = { workspace = true }
|
||||||
tower_governor = { workspace = true }
|
tower_governor = { workspace = true }
|
||||||
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod sync;
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
middleware as axum_middleware,
|
middleware as axum_middleware,
|
||||||
|
response::Html,
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
@@ -25,6 +26,7 @@ use tower_governor::{
|
|||||||
key_extractor::SmartIpKeyExtractor,
|
key_extractor::SmartIpKeyExtractor,
|
||||||
GovernorLayer,
|
GovernorLayer,
|
||||||
};
|
};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
/// Shared application state injected into every Axum handler via [`axum::extract::State`].
|
/// Shared application state injected into every Axum handler via [`axum::extract::State`].
|
||||||
///
|
///
|
||||||
@@ -104,10 +106,23 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
.route("/api/replays/{id}", get(replays::get_by_id))
|
.route("/api/replays/{id}", get(replays::get_by_id))
|
||||||
.route("/health", get(health));
|
.route("/health", get(health));
|
||||||
|
|
||||||
|
// Replay web UI: a single HTML page served at `/replays/:id` plus a
|
||||||
|
// ServeDir for the static assets (`web/index.html`, `web/replay.css`,
|
||||||
|
// and the wasm-bindgen-generated `web/pkg/`). The HTML page is the
|
||||||
|
// same regardless of `:id` — it reads the path from `location` in JS
|
||||||
|
// and fetches the replay JSON from `/api/replays/:id`.
|
||||||
|
let web = Router::new()
|
||||||
|
.route(
|
||||||
|
"/replays/{id}",
|
||||||
|
get(|| async { Html(include_str!("../web/index.html")) }),
|
||||||
|
)
|
||||||
|
.nest_service("/web", ServeDir::new("solitaire_server/web"));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(protected)
|
.merge(protected)
|
||||||
.merge(auth_routes)
|
.merge(auth_routes)
|
||||||
.merge(public)
|
.merge(public)
|
||||||
|
.merge(web)
|
||||||
// Reject request bodies larger than 1 MB.
|
// Reject request bodies larger than 1 MB.
|
||||||
.layer(DefaultBodyLimit::max(1024 * 1024))
|
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Solitaire Quest — Replay</title>
|
||||||
|
<link rel="stylesheet" href="/web/replay.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Solitaire Quest <span class="muted">— Replay</span></h1>
|
||||||
|
<div id="caption" class="muted">Loading…</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="board"></section>
|
||||||
|
|
||||||
|
<section id="controls">
|
||||||
|
<button id="btn-prev" disabled>⏮ Restart</button>
|
||||||
|
<button id="btn-play">▶ Play</button>
|
||||||
|
<button id="btn-step">⏭ Step</button>
|
||||||
|
<span id="progress" class="muted">step 0 / 0</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="status" class="muted">
|
||||||
|
<span id="score">Score 0</span>
|
||||||
|
<span id="moves">Moves 0</span>
|
||||||
|
<span id="result"></span>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/web/replay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* Browser-side replay state machine. Owns a live `GameState` and the
|
||||||
|
* replay's move list; each `step()` applies the next move.
|
||||||
|
*/
|
||||||
|
export class ReplayPlayer {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
ReplayPlayerFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_replayplayer_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Returns `true` once every move has been applied.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
is_finished() {
|
||||||
|
const ret = wasm.replayplayer_is_finished(this.__wbg_ptr);
|
||||||
|
return ret !== 0;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Construct from a raw replay JSON string.
|
||||||
|
* @param {string} replay_json
|
||||||
|
*/
|
||||||
|
constructor(replay_json) {
|
||||||
|
const ptr0 = passStringToWasm0(replay_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.replayplayer_new(ptr0, len0);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
this.__wbg_ptr = ret[0];
|
||||||
|
ReplayPlayerFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
state() {
|
||||||
|
const ret = wasm.replayplayer_state(this.__wbg_ptr);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Apply the next move; returns the post-step snapshot, or `null`
|
||||||
|
* once the move list is exhausted.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
step() {
|
||||||
|
const ret = wasm.replayplayer_step(this.__wbg_ptr);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 0-indexed position of the next move to apply.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
step_idx() {
|
||||||
|
const ret = wasm.replayplayer_step_idx(this.__wbg_ptr);
|
||||||
|
return ret >>> 0;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Total number of moves the replay contains.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
total_steps() {
|
||||||
|
const ret = wasm.replayplayer_total_steps(this.__wbg_ptr);
|
||||||
|
return ret >>> 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
|
||||||
|
function __wbg_get_imports() {
|
||||||
|
const import0 = {
|
||||||
|
__proto__: null,
|
||||||
|
__wbg_Error_3639a60ed15f87e7: function(arg0, arg1) {
|
||||||
|
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
||||||
|
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||||
|
},
|
||||||
|
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
||||||
|
let deferred0_0;
|
||||||
|
let deferred0_1;
|
||||||
|
try {
|
||||||
|
deferred0_0 = arg0;
|
||||||
|
deferred0_1 = arg1;
|
||||||
|
console.error(getStringFromWasm0(arg0, arg1));
|
||||||
|
} finally {
|
||||||
|
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
__wbg_new_227d7c05414eb861: function() {
|
||||||
|
const ret = new Error();
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_new_2fad8ca02fd00684: function() {
|
||||||
|
const ret = new Object();
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_new_3baa8d9866155c79: function() {
|
||||||
|
const ret = new Array();
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
|
||||||
|
arg0[arg1] = arg2;
|
||||||
|
},
|
||||||
|
__wbg_set_f614f6a0608d1d1d: function(arg0, arg1, arg2) {
|
||||||
|
arg0[arg1 >>> 0] = arg2;
|
||||||
|
},
|
||||||
|
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
|
||||||
|
const ret = arg1.stack;
|
||||||
|
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||||
|
},
|
||||||
|
__wbindgen_cast_0000000000000001: function(arg0) {
|
||||||
|
// Cast intrinsic for `F64 -> Externref`.
|
||||||
|
const ret = arg0;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
|
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||||
|
const ret = getStringFromWasm0(arg0, arg1);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_cast_0000000000000003: function(arg0) {
|
||||||
|
// Cast intrinsic for `U64 -> Externref`.
|
||||||
|
const ret = BigInt.asUintN(64, arg0);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_init_externref_table: function() {
|
||||||
|
const table = wasm.__wbindgen_externrefs;
|
||||||
|
const offset = table.grow(4);
|
||||||
|
table.set(0, undefined);
|
||||||
|
table.set(offset + 0, undefined);
|
||||||
|
table.set(offset + 1, null);
|
||||||
|
table.set(offset + 2, true);
|
||||||
|
table.set(offset + 3, false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
__proto__: null,
|
||||||
|
"./solitaire_wasm_bg.js": import0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
|
||||||
|
|
||||||
|
let cachedDataViewMemory0 = null;
|
||||||
|
function getDataViewMemory0() {
|
||||||
|
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||||
|
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachedDataViewMemory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringFromWasm0(ptr, len) {
|
||||||
|
return decodeText(ptr >>> 0, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedUint8ArrayMemory0 = null;
|
||||||
|
function getUint8ArrayMemory0() {
|
||||||
|
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||||
|
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachedUint8ArrayMemory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passStringToWasm0(arg, malloc, realloc) {
|
||||||
|
if (realloc === undefined) {
|
||||||
|
const buf = cachedTextEncoder.encode(arg);
|
||||||
|
const ptr = malloc(buf.length, 1) >>> 0;
|
||||||
|
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||||
|
WASM_VECTOR_LEN = buf.length;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = arg.length;
|
||||||
|
let ptr = malloc(len, 1) >>> 0;
|
||||||
|
|
||||||
|
const mem = getUint8ArrayMemory0();
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (; offset < len; offset++) {
|
||||||
|
const code = arg.charCodeAt(offset);
|
||||||
|
if (code > 0x7F) break;
|
||||||
|
mem[ptr + offset] = code;
|
||||||
|
}
|
||||||
|
if (offset !== len) {
|
||||||
|
if (offset !== 0) {
|
||||||
|
arg = arg.slice(offset);
|
||||||
|
}
|
||||||
|
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||||
|
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||||
|
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||||
|
|
||||||
|
offset += ret.written;
|
||||||
|
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
WASM_VECTOR_LEN = offset;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeFromExternrefTable0(idx) {
|
||||||
|
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||||
|
wasm.__externref_table_dealloc(idx);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||||
|
let numBytesDecoded = 0;
|
||||||
|
function decodeText(ptr, len) {
|
||||||
|
numBytesDecoded += len;
|
||||||
|
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||||
|
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
numBytesDecoded = len;
|
||||||
|
}
|
||||||
|
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedTextEncoder = new TextEncoder();
|
||||||
|
|
||||||
|
if (!('encodeInto' in cachedTextEncoder)) {
|
||||||
|
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||||
|
const buf = cachedTextEncoder.encode(arg);
|
||||||
|
view.set(buf);
|
||||||
|
return {
|
||||||
|
read: arg.length,
|
||||||
|
written: buf.length
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let WASM_VECTOR_LEN = 0;
|
||||||
|
|
||||||
|
let wasmModule, wasmInstance, wasm;
|
||||||
|
function __wbg_finalize_init(instance, module) {
|
||||||
|
wasmInstance = instance;
|
||||||
|
wasm = instance.exports;
|
||||||
|
wasmModule = module;
|
||||||
|
cachedDataViewMemory0 = null;
|
||||||
|
cachedUint8ArrayMemory0 = null;
|
||||||
|
wasm.__wbindgen_start();
|
||||||
|
return wasm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_load(module, imports) {
|
||||||
|
if (typeof Response === 'function' && module instanceof Response) {
|
||||||
|
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||||
|
try {
|
||||||
|
return await WebAssembly.instantiateStreaming(module, imports);
|
||||||
|
} catch (e) {
|
||||||
|
const validResponse = module.ok && expectedResponseType(module.type);
|
||||||
|
|
||||||
|
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||||
|
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||||
|
|
||||||
|
} else { throw e; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await module.arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(bytes, imports);
|
||||||
|
} else {
|
||||||
|
const instance = await WebAssembly.instantiate(module, imports);
|
||||||
|
|
||||||
|
if (instance instanceof WebAssembly.Instance) {
|
||||||
|
return { instance, module };
|
||||||
|
} else {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedResponseType(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'basic': case 'cors': case 'default': return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSync(module) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (module !== undefined) {
|
||||||
|
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||||
|
({module} = module)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
if (!(module instanceof WebAssembly.Module)) {
|
||||||
|
module = new WebAssembly.Module(module);
|
||||||
|
}
|
||||||
|
const instance = new WebAssembly.Instance(module, imports);
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_init(module_or_path) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (module_or_path !== undefined) {
|
||||||
|
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||||
|
({module_or_path} = module_or_path)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (module_or_path === undefined) {
|
||||||
|
module_or_path = new URL('solitaire_wasm_bg.wasm', import.meta.url);
|
||||||
|
}
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
|
||||||
|
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||||
|
module_or_path = fetch(module_or_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||||
|
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initSync, __wbg_init as default };
|
||||||
Binary file not shown.
@@ -0,0 +1,197 @@
|
|||||||
|
/* Solitaire Quest replay viewer — palette mirrors the desktop client's
|
||||||
|
midnight-purple Balatro tone (BG_BASE = #1A0F2E) and the dark felt
|
||||||
|
from the engine's TABLE_COLOUR. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f0a1f;
|
||||||
|
--felt: #0f4c30;
|
||||||
|
--panel: #1a0f2e;
|
||||||
|
--panel-hi: #2d1b69;
|
||||||
|
--text: #f5f0ff;
|
||||||
|
--text-muted: #b5a8d5;
|
||||||
|
--accent: #ffd23f;
|
||||||
|
--red: #cc3344;
|
||||||
|
--black: #1a0f2e;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--card-border: #ccc;
|
||||||
|
--card-w: 80px;
|
||||||
|
--card-h: 112px;
|
||||||
|
--gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#board {
|
||||||
|
background: var(--felt);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
width: min(100%, calc(7 * var(--card-w) + 8 * var(--gap)));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, var(--card-w));
|
||||||
|
grid-template-rows: var(--card-h) auto;
|
||||||
|
gap: var(--gap);
|
||||||
|
column-gap: var(--gap);
|
||||||
|
row-gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top row: stock, waste, [skip], 4 foundations. */
|
||||||
|
.pile-stock { grid-column: 1; grid-row: 1; }
|
||||||
|
.pile-waste { grid-column: 2; grid-row: 1; }
|
||||||
|
.pile-foundation-0 { grid-column: 4; grid-row: 1; }
|
||||||
|
.pile-foundation-1 { grid-column: 5; grid-row: 1; }
|
||||||
|
.pile-foundation-2 { grid-column: 6; grid-row: 1; }
|
||||||
|
.pile-foundation-3 { grid-column: 7; grid-row: 1; }
|
||||||
|
.pile-tableau-0 { grid-column: 1; grid-row: 2; }
|
||||||
|
.pile-tableau-1 { grid-column: 2; grid-row: 2; }
|
||||||
|
.pile-tableau-2 { grid-column: 3; grid-row: 2; }
|
||||||
|
.pile-tableau-3 { grid-column: 4; grid-row: 2; }
|
||||||
|
.pile-tableau-4 { grid-column: 5; grid-row: 2; }
|
||||||
|
.pile-tableau-5 { grid-column: 6; grid-row: 2; }
|
||||||
|
.pile-tableau-6 { grid-column: 7; grid-row: 2; }
|
||||||
|
|
||||||
|
.pile {
|
||||||
|
position: relative;
|
||||||
|
width: var(--card-w);
|
||||||
|
/* Tableau columns let cards stack downward. */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pile-empty {
|
||||||
|
width: var(--card-w);
|
||||||
|
height: var(--card-h);
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--card-w);
|
||||||
|
height: var(--card-h);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
transition: top 180ms ease, opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tableau fan: cards beneath the top one peek through ~28 px down. */
|
||||||
|
.pile-tableau-0 .card,
|
||||||
|
.pile-tableau-1 .card,
|
||||||
|
.pile-tableau-2 .card,
|
||||||
|
.pile-tableau-3 .card,
|
||||||
|
.pile-tableau-4 .card,
|
||||||
|
.pile-tableau-5 .card,
|
||||||
|
.pile-tableau-6 .card {
|
||||||
|
/* Per-card top set inline by JS (offset = idx * 28 px). */
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.face-down {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#482f97 0,
|
||||||
|
#482f97 6px,
|
||||||
|
#2d1b69 6px,
|
||||||
|
#2d1b69 12px
|
||||||
|
);
|
||||||
|
color: transparent;
|
||||||
|
border-color: #4a3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .corner {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .corner.top { top: 4px; left: 6px; }
|
||||||
|
.card .corner.bottom { bottom: 4px; right: 6px; transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls button {
|
||||||
|
background: var(--panel-hi);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls button:hover:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--black);
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status #result.win {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Solitaire Quest replay viewer.
|
||||||
|
//
|
||||||
|
// Pulls the replay JSON from `/api/replays/:id`, hands it to the
|
||||||
|
// `solitaire_wasm` ReplayPlayer (which owns a real solitaire_core
|
||||||
|
// `GameState` compiled to WebAssembly), and renders each step's pile
|
||||||
|
// snapshot as plain HTML cards. The WASM module is the single source
|
||||||
|
// of truth for the rules engine — we don't re-implement Klondike in JS.
|
||||||
|
|
||||||
|
import init, { ReplayPlayer } from "/web/pkg/solitaire_wasm.js";
|
||||||
|
|
||||||
|
const STEP_INTERVAL_MS = 600;
|
||||||
|
const FAN_OFFSET_PX = 28;
|
||||||
|
|
||||||
|
const SUIT_GLYPHS = {
|
||||||
|
clubs: "♣",
|
||||||
|
diamonds: "♦",
|
||||||
|
hearts: "♥",
|
||||||
|
spades: "♠",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RED_SUITS = new Set(["diamonds", "hearts"]);
|
||||||
|
|
||||||
|
const RANK_LABELS = [
|
||||||
|
"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
|
||||||
|
];
|
||||||
|
|
||||||
|
const board = document.getElementById("board");
|
||||||
|
const captionEl = document.getElementById("caption");
|
||||||
|
const progressEl = document.getElementById("progress");
|
||||||
|
const scoreEl = document.getElementById("score");
|
||||||
|
const movesEl = document.getElementById("moves");
|
||||||
|
const resultEl = document.getElementById("result");
|
||||||
|
const btnPlay = document.getElementById("btn-play");
|
||||||
|
const btnStep = document.getElementById("btn-step");
|
||||||
|
const btnPrev = document.getElementById("btn-prev");
|
||||||
|
|
||||||
|
let player = null;
|
||||||
|
let replayJson = null;
|
||||||
|
let playInterval = null;
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
// /replays/<id> — pull the id off the path so we can fetch the JSON.
|
||||||
|
const id = window.location.pathname.split("/").pop();
|
||||||
|
if (!id) {
|
||||||
|
captionEl.textContent = "No replay id in URL.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api/replays/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
captionEl.textContent = `Network error: ${e}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
captionEl.textContent = `Server returned ${response.status}.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const replay = await response.json();
|
||||||
|
replayJson = JSON.stringify(replay);
|
||||||
|
|
||||||
|
captionEl.textContent =
|
||||||
|
`Seed ${replay.seed} · ${replay.draw_mode} · ${replay.mode} ` +
|
||||||
|
`· ${formatDuration(replay.time_seconds)} win on ${replay.recorded_at} ` +
|
||||||
|
`· final score ${replay.final_score}`;
|
||||||
|
|
||||||
|
await init();
|
||||||
|
resetPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPlayer() {
|
||||||
|
if (playInterval) {
|
||||||
|
clearInterval(playInterval);
|
||||||
|
playInterval = null;
|
||||||
|
btnPlay.textContent = "▶ Play";
|
||||||
|
}
|
||||||
|
player = new ReplayPlayer(replayJson);
|
||||||
|
btnPrev.disabled = true;
|
||||||
|
btnStep.disabled = false;
|
||||||
|
btnPlay.disabled = false;
|
||||||
|
render(player.state());
|
||||||
|
}
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
const snap = player.step();
|
||||||
|
if (snap === null) {
|
||||||
|
finish();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
btnPrev.disabled = false;
|
||||||
|
render(snap);
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
if (playInterval) {
|
||||||
|
clearInterval(playInterval);
|
||||||
|
playInterval = null;
|
||||||
|
}
|
||||||
|
btnPlay.textContent = "▶ Play";
|
||||||
|
btnPlay.disabled = true;
|
||||||
|
btnStep.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(snap) {
|
||||||
|
if (!snap) return;
|
||||||
|
board.replaceChildren();
|
||||||
|
renderPile("stock", snap.stock, false);
|
||||||
|
renderPile("waste", snap.waste, false);
|
||||||
|
snap.foundations.forEach((cards, idx) =>
|
||||||
|
renderPile(`foundation-${idx}`, cards, false));
|
||||||
|
snap.tableaus.forEach((cards, idx) =>
|
||||||
|
renderPile(`tableau-${idx}`, cards, true));
|
||||||
|
|
||||||
|
progressEl.textContent = `step ${snap.step_idx} / ${snap.total_steps}`;
|
||||||
|
scoreEl.textContent = `Score ${snap.score}`;
|
||||||
|
movesEl.textContent = `Moves ${snap.move_count}`;
|
||||||
|
if (snap.is_won) {
|
||||||
|
resultEl.textContent = "✨ Won";
|
||||||
|
resultEl.classList.add("win");
|
||||||
|
} else {
|
||||||
|
resultEl.textContent = "";
|
||||||
|
resultEl.classList.remove("win");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPile(name, cards, fan) {
|
||||||
|
const pile = document.createElement("div");
|
||||||
|
pile.className = `pile pile-${name}`;
|
||||||
|
if (cards.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "pile-empty";
|
||||||
|
pile.appendChild(empty);
|
||||||
|
board.appendChild(pile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cards.forEach((card, idx) => {
|
||||||
|
const top = fan ? idx * FAN_OFFSET_PX : 0;
|
||||||
|
pile.appendChild(buildCard(card, top));
|
||||||
|
});
|
||||||
|
board.appendChild(pile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCard(card, top) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "card";
|
||||||
|
el.style.top = `${top}px`;
|
||||||
|
if (!card.face_up) {
|
||||||
|
el.classList.add("face-down");
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
el.classList.add(RED_SUITS.has(card.suit) ? "red" : "black");
|
||||||
|
const label = RANK_LABELS[card.rank] || "?";
|
||||||
|
const glyph = SUIT_GLYPHS[card.suit] || "?";
|
||||||
|
|
||||||
|
const top_corner = document.createElement("span");
|
||||||
|
top_corner.className = "corner top";
|
||||||
|
top_corner.textContent = `${label}\n${glyph}`;
|
||||||
|
el.appendChild(top_corner);
|
||||||
|
|
||||||
|
const center = document.createElement("span");
|
||||||
|
center.className = "center";
|
||||||
|
center.textContent = glyph;
|
||||||
|
el.appendChild(center);
|
||||||
|
|
||||||
|
const bottom_corner = document.createElement("span");
|
||||||
|
bottom_corner.className = "corner bottom";
|
||||||
|
bottom_corner.textContent = `${label}\n${glyph}`;
|
||||||
|
el.appendChild(bottom_corner);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnStep.addEventListener("click", () => {
|
||||||
|
if (player) step();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPlay.addEventListener("click", () => {
|
||||||
|
if (!player) return;
|
||||||
|
if (playInterval) {
|
||||||
|
clearInterval(playInterval);
|
||||||
|
playInterval = null;
|
||||||
|
btnPlay.textContent = "▶ Play";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btnPlay.textContent = "⏸ Pause";
|
||||||
|
playInterval = setInterval(() => {
|
||||||
|
const snap = step();
|
||||||
|
if (snap === null) finish();
|
||||||
|
}, STEP_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPrev.addEventListener("click", () => {
|
||||||
|
if (replayJson) resetPlayer();
|
||||||
|
});
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
Reference in New Issue
Block a user