This commit is contained in:
parent
46e10d8c8b
commit
1cec986ab1
16 changed files with 820 additions and 1673 deletions
117
Cargo.lock
generated
117
Cargo.lock
generated
|
@ -153,6 +153,7 @@ checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
|
@ -171,8 +172,10 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper 1.0.1",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower 0.4.13",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
@ -212,6 +215,26 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-routing-htmx"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-macros",
|
||||
"axum-routing-htmx-macros",
|
||||
"dyn-fmt",
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-routing-htmx-macros"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.73"
|
||||
|
@ -233,6 +256,12 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
|
@ -548,6 +577,24 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-fmt"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c992f591dfce792a9bc2d1880ab67ffd4acc04551f8e551ca3b6233efb322f00"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
|
@ -587,6 +634,12 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
|
@ -916,13 +969,14 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "htmx-ssh-games"
|
||||
name = "htmx-ssh-netcode-test"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-macros",
|
||||
"axum-routing-htmx",
|
||||
"bitvec",
|
||||
"clap",
|
||||
"futures",
|
||||
|
@ -935,6 +989,7 @@ dependencies = [
|
|||
"reqwest",
|
||||
"russh",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"termsize",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
@ -1106,6 +1161,15 @@ version = "1.70.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.11"
|
||||
|
@ -1148,6 +1212,12 @@ version = "0.4.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
|
@ -1182,8 +1252,6 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
|||
[[package]]
|
||||
name = "maud"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"http",
|
||||
|
@ -1194,8 +1262,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "maud_macros"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18"
|
||||
dependencies = [
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
|
@ -1778,7 +1844,7 @@ version = "0.12.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
|
@ -2012,7 +2078,7 @@ version = "2.1.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
|
@ -2520,6 +2586,18 @@ dependencies = [
|
|||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.12"
|
||||
|
@ -2639,6 +2717,25 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.17.0"
|
||||
|
@ -2693,6 +2790,12 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
|
|
37
Cargo.toml
37
Cargo.toml
|
@ -1,32 +1,33 @@
|
|||
[package]
|
||||
authors = ["Bad Manners <me@badmanners.xyz>"]
|
||||
description = "A few silly games I made while I learn about Axum, SSH (with Russh), and HTMX."
|
||||
name = "htmx-ssh-games"
|
||||
name = "htmx-ssh-netcode-test"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
axum = "0.7.5"
|
||||
axum-macros = "0.4.1"
|
||||
bitvec = "1.0.1"
|
||||
clap = { version = "4.5.17", features = ["derive"] }
|
||||
futures = "0.3.30"
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
axum-macros = "0.4"
|
||||
axum-routing-htmx = { path = "../axum-routing-htmx/axum-routing-htmx" }
|
||||
bitvec = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
hyper = { version = "1", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
maud = { version = "0.26.0", features = ["axum"] }
|
||||
rand = "0.8.5"
|
||||
random_color = "0.8.0"
|
||||
regex = "1.10.6"
|
||||
reqwest = "0.12.7"
|
||||
maud = { path = "../maud/maud", features = ["axum"] }
|
||||
rand = "0.8"
|
||||
random_color = "0.8"
|
||||
regex = "1"
|
||||
reqwest = "0.12"
|
||||
russh = "0.45"
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
termsize = "0.1.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
termsize = "0.1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.15", features = ["net", "sync"] }
|
||||
tokio-util = "0.7.11"
|
||||
tokio-stream = { version = "0.1", features = ["net", "sync"] }
|
||||
tokio-util = "0.7"
|
||||
tower = "0.5.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter", "std"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter", "std"] }
|
||||
|
|
14
README.md
14
README.md
|
@ -1,11 +1,9 @@
|
|||
# htmx-ssh-games
|
||||
# htmx-ssh-netcode-test
|
||||
|
||||
A few silly games I made while I learn about Axum, SSH (with Russh), and HTMX.
|
||||
WIP
|
||||
|
||||
## checkbox.rs
|
||||
## Development command
|
||||
|
||||
A poor man's clone of A Million Checkboxes.
|
||||
|
||||
## multipaint_by_numbers.rs
|
||||
|
||||
A multiplayer Picross/Nonogram, inspired by the project above.
|
||||
```sh
|
||||
RUST_LOG=info cargo watch -- cargo run -- ssh -R test -i /path/to/.ssh/id_ed25519 sish.top
|
||||
```
|
||||
|
|
5
src/http/alpine.js
Normal file
5
src/http/alpine.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,124 +0,0 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{delete, get, put},
|
||||
Router,
|
||||
};
|
||||
use bitvec::{array::BitArray, order::Lsb0, BitArr};
|
||||
use hyper::StatusCode;
|
||||
use maud::{html, Markup, DOCTYPE};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
checkboxes: Arc<Mutex<BitArr!(for CHECKBOX_WIDTH*CHECKBOX_HEIGHT, in usize, Lsb0)>>,
|
||||
}
|
||||
|
||||
const CHECKBOX_WIDTH: usize = 20;
|
||||
const CHECKBOX_HEIGHT: usize = 20;
|
||||
|
||||
/// A lazily-created Router, to be used by the SSH client tunnels.
|
||||
pub fn get_router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/checkboxes", get(all_checkboxes))
|
||||
.route("/checkbox/:id", put(mark_checkbox))
|
||||
.route("/checkbox/:id", delete(unmark_checkbox))
|
||||
.with_state(AppState {
|
||||
checkboxes: Arc::new(Mutex::new(BitArray::ZERO)),
|
||||
})
|
||||
}
|
||||
|
||||
fn style() -> &'static str {
|
||||
r#"
|
||||
body {
|
||||
width: fit-content;
|
||||
}
|
||||
ul {
|
||||
display: grid;
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
li {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
"#
|
||||
}
|
||||
|
||||
fn head() -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
title { (CHECKBOX_WIDTH*CHECKBOX_HEIGHT) " Checkboxes" }
|
||||
script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous" {}
|
||||
style { (style()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn index() -> Markup {
|
||||
html! {
|
||||
(head())
|
||||
body {
|
||||
h1 { (CHECKBOX_WIDTH*CHECKBOX_HEIGHT) " Checkboxes" }
|
||||
div hx-get="/checkboxes" hx-trigger="load" hx-swap="outerHTML" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn all_checkboxes(State(state): State<AppState>) -> Markup {
|
||||
html! {
|
||||
ul hx-get="/checkboxes" hx-trigger="every 3s" style=(format!("grid-template-columns: repeat({}, minmax(0, 1fr));", CHECKBOX_WIDTH)) hx-swap="outerHTML" {
|
||||
@for (id, checkbox) in state.checkboxes.lock().unwrap()[..CHECKBOX_WIDTH*CHECKBOX_HEIGHT].iter().by_vals().enumerate() {
|
||||
li {
|
||||
@if checkbox {
|
||||
(checked(id))
|
||||
} @else {
|
||||
(unchecked(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checked(id: usize) -> Markup {
|
||||
html! {
|
||||
input id=(format!("cb-{}", id)) type="checkbox" hx-delete=(format!("/checkbox/{}", id)) hx-trigger="click" checked {}
|
||||
}
|
||||
}
|
||||
|
||||
fn unchecked(id: usize) -> Markup {
|
||||
html! {
|
||||
input id=(format!("cb-{}", id)) type="checkbox" hx-put=(format!("/checkbox/{}", id)) hx-trigger="click" {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_checkbox(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<usize>,
|
||||
) -> Result<Markup, StatusCode> {
|
||||
match state.checkboxes.lock().unwrap().get_mut(id) {
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
Some(mut checkbox) => {
|
||||
*checkbox = true;
|
||||
Ok(checked(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn unmark_checkbox(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<usize>,
|
||||
) -> Result<Markup, StatusCode> {
|
||||
match state.checkboxes.lock().unwrap().get_mut(id) {
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
Some(mut checkbox) => {
|
||||
*checkbox = false;
|
||||
Ok(unchecked(id))
|
||||
}
|
||||
}
|
||||
}
|
467
src/http/htmx-ws.js
Normal file
467
src/http/htmx-ws.js
Normal file
|
@ -0,0 +1,467 @@
|
|||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
/** @type {import("./htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('ws', {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("./htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// Store reference to internal API
|
||||
api = apiRef
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = 'full-jitter'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
// Try to close the socket when elements are removed
|
||||
case 'htmx:beforeCleanupElement':
|
||||
|
||||
var internalData = api.getInternalData(parent)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close()
|
||||
}
|
||||
return
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case 'htmx:beforeProcessNode':
|
||||
|
||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
|
||||
ensureWebSocket(child)
|
||||
})
|
||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
|
||||
ensureWebSocketSend(child)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/)
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue)
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/)
|
||||
if (value[0] === 'connect') {
|
||||
return value[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
|
||||
|
||||
if (wssSource == null || wssSource === '') {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt)
|
||||
if (legacySource == null) {
|
||||
return
|
||||
} else {
|
||||
wssSource = legacySource
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf('/') === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '')
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = 'wss://' + base_part + wssSource
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = 'ws://' + base_part + wssSource
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function() {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
})
|
||||
|
||||
socketWrapper.addEventListener('message', function(event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
var response = event.data
|
||||
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function(extension) {
|
||||
response = extension.transformResponse(response, null, socketElt)
|
||||
})
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt)
|
||||
var fragment = api.makeFragment(response)
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children)
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks)
|
||||
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
})
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function(event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler)
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
|
||||
this.events[event].push(handler)
|
||||
},
|
||||
|
||||
sendImmediately: function(message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message)
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function(message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message, sendElt })
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt)
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function() {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
|
||||
this.messageQueue.shift()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function() {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc()
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
|
||||
|
||||
this.socket = socket
|
||||
|
||||
socket.onopen = function(e) {
|
||||
wrapper.retryCount = 0
|
||||
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
|
||||
wrapper.handleQueuedMessages()
|
||||
}
|
||||
|
||||
socket.onclose = function(e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
|
||||
setTimeout(function() {
|
||||
wrapper.retryCount += 1
|
||||
wrapper.init()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
|
||||
}
|
||||
|
||||
socket.onerror = function(e) {
|
||||
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
|
||||
maybeCloseWebSocketSource(socketElt)
|
||||
}
|
||||
|
||||
var events = this.events
|
||||
Object.keys(events).forEach(function(k) {
|
||||
events[k].forEach(function(e) {
|
||||
socket.addEventListener(k, e)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
close: function() {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init()
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
}
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt)
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt)
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt)
|
||||
triggerSpecs.forEach(function(ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
|
||||
var results = api.getInputValues(sendElt, 'post')
|
||||
var errors = results.errors
|
||||
var rawParameters = Object.assign({}, results.values)
|
||||
var expressionVars = api.getExpressionVars(sendElt)
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars)
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt)
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers,
|
||||
errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
}
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors)
|
||||
return
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters)
|
||||
if (sendConfig.headers) { toSend.HEADERS = headers }
|
||||
body = JSON.stringify(toSend)
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt)
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount)
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6)
|
||||
var maxDelay = 1000 * Math.pow(2, exp)
|
||||
return maxDelay * Math.random()
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
api.getInternalData(elt).webSocket.close()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, [])
|
||||
sock.binaryType = htmx.config.wsBinaryType
|
||||
return sock
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
|
||||
result.push(elt)
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
|
@ -2,8 +2,7 @@ use std::sync::OnceLock;
|
|||
|
||||
use axum::Router;
|
||||
|
||||
pub mod checkbox;
|
||||
pub mod multipaint_by_numbers;
|
||||
pub mod netcode;
|
||||
|
||||
/// A lazily-created Router, to be used by the SSH client tunnels or directly by the HTTP server.
|
||||
pub static ROUTER: OnceLock<Router> = OnceLock::new();
|
||||
|
|
|
@ -1,762 +0,0 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
hash::Hash,
|
||||
mem,
|
||||
sync::{Arc, LazyLock, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, post, put},
|
||||
Form, Router,
|
||||
};
|
||||
use bitvec::{order::Lsb0, slice::BitSlice};
|
||||
use hyper::{HeaderMap, StatusCode};
|
||||
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
||||
use rand::{seq::SliceRandom, thread_rng, Rng};
|
||||
use random_color::{Luminosity, RandomColor};
|
||||
use serde::Deserialize;
|
||||
use tokio::{
|
||||
sync::watch::{self, Receiver, Sender},
|
||||
task::JoinHandle,
|
||||
time::{sleep, Instant},
|
||||
};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::nonogram::nonogrammed::{get_puzzle_data, NonogrammedPuzzle, NONOGRAMMED_PUZZLE_LIST};
|
||||
|
||||
/* Type defintions */
|
||||
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
enum NonogramState {
|
||||
Unsolved,
|
||||
Solved(Duration),
|
||||
Failed,
|
||||
}
|
||||
|
||||
struct Timer {
|
||||
start: Instant,
|
||||
duration: Duration,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
enum CheckboxState {
|
||||
Empty,
|
||||
Flagged,
|
||||
Marked,
|
||||
}
|
||||
|
||||
struct Nonogram {
|
||||
puzzle_list: Vec<u32>,
|
||||
state: NonogramState,
|
||||
puzzle_sender: Sender<NonogrammedPuzzle>,
|
||||
checkboxes: Vec<CheckboxState>,
|
||||
timer: Timer,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Copy, Clone)]
|
||||
struct CursorPosition(i32, i32);
|
||||
|
||||
#[derive(PartialEq, PartialOrd, Eq, Hash, Copy, Clone)]
|
||||
struct CursorId(u64);
|
||||
|
||||
struct Cursor {
|
||||
id: CursorId,
|
||||
modified_at: Instant,
|
||||
position: CursorPosition,
|
||||
color: [u8; 3],
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
fn new(id: CursorId, position: CursorPosition) -> Self {
|
||||
let color = RandomColor::new()
|
||||
.luminosity(Luminosity::Light)
|
||||
.seed(id.0)
|
||||
.to_rgb_array();
|
||||
Cursor {
|
||||
id,
|
||||
modified_at: Instant::now(),
|
||||
position,
|
||||
color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct CursorsPayload {
|
||||
id: u64,
|
||||
#[serde(rename = "mouseX")]
|
||||
mouse_x: i32,
|
||||
#[serde(rename = "mouseY")]
|
||||
mouse_y: i32,
|
||||
}
|
||||
|
||||
static VERSION: LazyLock<u32> = LazyLock::new(|| {
|
||||
let mut rng = rand::thread_rng();
|
||||
rng.gen()
|
||||
});
|
||||
|
||||
/* Router definition */
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
nonogram: Arc<Mutex<Nonogram>>,
|
||||
puzzle: Arc<Receiver<NonogrammedPuzzle>>,
|
||||
cursors: Arc<Mutex<HashMap<CursorId, Cursor>>>,
|
||||
}
|
||||
|
||||
/// A lazily-created Router, to be used by the SSH client tunnels.
|
||||
pub async fn get_router() -> Router {
|
||||
let mut puzzle_vec = NONOGRAMMED_PUZZLE_LIST.to_vec();
|
||||
puzzle_vec.shuffle(&mut thread_rng());
|
||||
let first_puzzle = loop {
|
||||
match puzzle_vec.pop() {
|
||||
None => {
|
||||
puzzle_vec.extend_from_slice(&NONOGRAMMED_PUZZLE_LIST);
|
||||
puzzle_vec.shuffle(&mut thread_rng());
|
||||
}
|
||||
Some(puzzle_id) => {
|
||||
let puzzle = get_puzzle(puzzle_id).await;
|
||||
if let Ok(puzzle) = puzzle {
|
||||
break puzzle;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let rows = first_puzzle.rows.len();
|
||||
let columns = first_puzzle.columns.len();
|
||||
let (tx, rx) = watch::channel(first_puzzle);
|
||||
let duration = get_duration_for_puzzle(rows, columns);
|
||||
let state = AppState {
|
||||
puzzle: Arc::new(rx),
|
||||
nonogram: Arc::new(Mutex::new(Nonogram {
|
||||
puzzle_list: puzzle_vec,
|
||||
checkboxes: vec![CheckboxState::Empty; rows * columns],
|
||||
timer: Timer {
|
||||
start: Instant::now(),
|
||||
duration,
|
||||
join_handle: None,
|
||||
},
|
||||
state: NonogramState::Unsolved,
|
||||
puzzle_sender: tx,
|
||||
})),
|
||||
cursors: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
let state_clone = state.clone();
|
||||
let join_handle = tokio::spawn(async move {
|
||||
sleep(duration).await;
|
||||
let mut nonogram = state_clone.nonogram.lock().unwrap();
|
||||
if nonogram.state == NonogramState::Unsolved {
|
||||
nonogram.state = NonogramState::Failed;
|
||||
}
|
||||
wait_and_start_new_puzzle(state_clone.clone());
|
||||
});
|
||||
state.nonogram.lock().unwrap().timer.join_handle = Some(join_handle);
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/htmx.js", get(htmx_minified))
|
||||
.route("/nonogram", get(nonogram))
|
||||
.route("/cursor", post(cursor))
|
||||
.route("/flag/:id", put(flag_checkbox).delete(unflag_checkbox))
|
||||
.route("/checkbox/:id", put(mark_checkbox).delete(unmark_checkbox))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/* Main page elements */
|
||||
|
||||
static STYLE: &str = r#"
|
||||
body {
|
||||
color: #06060c;
|
||||
background-color: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a {
|
||||
color: #22e;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
h2#congratulations {
|
||||
color: #060;
|
||||
}
|
||||
hr {
|
||||
margin-top: 28px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
}
|
||||
tr:nth-child(5n - 3) {
|
||||
border-top: 1pt solid;
|
||||
border-top-color: #000;
|
||||
}
|
||||
tr th:nth-child(5n - 3), tr td:nth-child(5n - 3) {
|
||||
border-left: 1pt solid;
|
||||
border-left-color: #000;
|
||||
}
|
||||
th[scope="col"] {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
th[scope="col"] > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
}
|
||||
th[scope="row"] {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
column-gap: 6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #ff9;
|
||||
}
|
||||
td, th {
|
||||
position: relative;
|
||||
}
|
||||
td:hover::after, th:hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: #ff9;
|
||||
left: 0;
|
||||
top: -5023px;
|
||||
height: 13337px;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
.checkbox {
|
||||
position: relative;
|
||||
}
|
||||
.checkbox div {
|
||||
pointer-events: none;
|
||||
}
|
||||
.checkbox .mark {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
table:not(.solved) .checkbox.flagged .mark {
|
||||
background: #c76;
|
||||
border-radius: 2px;
|
||||
}
|
||||
table.solved .checkbox.marked .mark {
|
||||
background: #111;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
z-index: 1;
|
||||
transform: scale(1.4);
|
||||
}
|
||||
#cursors {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
svg.cursor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0.9;
|
||||
transition-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.hint {
|
||||
z-index: 4;
|
||||
}
|
||||
@media(prefers-color-scheme: dark) {
|
||||
body {
|
||||
color: #ccc;
|
||||
background-color: #111;
|
||||
}
|
||||
a {
|
||||
color: #4df;
|
||||
}
|
||||
h2#congratulations {
|
||||
color: #7d7;
|
||||
}
|
||||
tr:hover, td:hover::after, th:hover::after {
|
||||
background-color: #663;
|
||||
}
|
||||
tr:nth-child(5n - 3) {
|
||||
border-top-color: #fff;
|
||||
}
|
||||
tr th:nth-child(5n - 3), tr td:nth-child(5n - 3) {
|
||||
border-left-color: #fff;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
static SCRIPT: &str = r#"
|
||||
document.addEventListener("contextmenu", (e) => {
|
||||
if (e.target.closest("td")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
let multipaintVersion = null;
|
||||
document.addEventListener("multipaintVersion", (e) => {
|
||||
if (multipaintVersion === null) {
|
||||
multipaintVersion = e.detail.value;
|
||||
} else if (multipaintVersion !== e.detail.value) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
function isTouchDevice() {
|
||||
return hasTouch;
|
||||
}
|
||||
let hasTouch = (navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0);
|
||||
if (!hasTouch) {
|
||||
document.addEventListener("touchstart", (e) => {
|
||||
hasTouch = true;
|
||||
}, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
let id = crypto.getRandomValues(new BigUint64Array(1))[0];
|
||||
let table = null;
|
||||
let cursors = null;
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (table === null || cursors === null) {
|
||||
table = document.querySelector("table");
|
||||
cursors = document.getElementById("cursors");
|
||||
}
|
||||
let tableBbox = table.getBoundingClientRect();
|
||||
mouseX = e.pageX - tableBbox.left;
|
||||
mouseY = e.pageY - tableBbox.top;
|
||||
cursors.style.top = tableBbox.top;
|
||||
cursors.style.left = tableBbox.left;
|
||||
});
|
||||
|
||||
let baseTimestamp = document.timeline.currentTime;
|
||||
let nonogramTimeLeft = null;
|
||||
document.addEventListener("nonogramTimeLeft", (e) => {
|
||||
baseTimestamp = document.timeline.currentTime;
|
||||
nonogramTimeLeft = e.detail.value;
|
||||
});
|
||||
function updateFrame(currentTimestamp) {
|
||||
if (Number.isInteger(nonogramTimeLeft)) {
|
||||
let timerElapsed = document.getElementById("timer-elapsed");
|
||||
let timerDone = document.getElementById("timer-done");
|
||||
let timeLeft = nonogramTimeLeft + baseTimestamp - currentTimestamp;
|
||||
if (timeLeft <= 0) {
|
||||
if (timerElapsed) {
|
||||
timerElapsed.classList.add("hidden");
|
||||
}
|
||||
if (timerDone) {
|
||||
timerDone.classList.remove("hidden");
|
||||
}
|
||||
} else {
|
||||
if (timerElapsed) {
|
||||
let minutes = Math.floor(timeLeft / 60000);
|
||||
let seconds = Math.floor((timeLeft % 60000) / 1000);
|
||||
timerElapsed.innerText = "Time left: " + minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
|
||||
timerElapsed.classList.remove("hidden");
|
||||
}
|
||||
if (timerDone) {
|
||||
timerDone.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(updateFrame);
|
||||
}
|
||||
requestAnimationFrame(updateFrame);
|
||||
"#;
|
||||
|
||||
async fn htmx_minified() -> &'static [u8] {
|
||||
include_bytes!("../htmx.min.js")
|
||||
}
|
||||
|
||||
async fn index() -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
title { "Multipaint by Numbers" }
|
||||
meta property="og:title" content="Multipaint by Numbers" {}
|
||||
meta property="og:url" content="https://multipaint.sish.top" {}
|
||||
meta property="og:description" content="Multiplayer picross/nonogram, powered by htmx." {}
|
||||
// script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous" {}
|
||||
// script src="https://unpkg.com/htmx.org@2.0.2/dist/htmx.js" integrity="sha384-yZq+5izaUBKcRgFbxgkRYwpHhHHCpp5nseXp0MEQ1A4MTWVMnqkmcuFez8x5qfxr" crossorigin="anonymous" {}
|
||||
script src="/htmx.js" {}
|
||||
style { (PreEscaped(STYLE)) }
|
||||
script { (PreEscaped(SCRIPT)) }
|
||||
}
|
||||
body {
|
||||
#cursors hx-post="/cursor" hx-trigger="load, mousemove delay:500ms, every 1500ms" hx-vals="javascript:{id: id, mouseX: mouseX, mouseY: mouseY}" {}
|
||||
h1 { "Multipaint by Numbers" }
|
||||
hr {}
|
||||
main {
|
||||
#nonogram hx-get="/nonogram" hx-trigger="load, every 2s" {}
|
||||
}
|
||||
hr {}
|
||||
p { "Click or touch to mark, right-click or long-touch to flag. Dragging with a cursor also works." }
|
||||
p {
|
||||
"Puzzles from "
|
||||
a href="https://nonogrammed.com/" target="_blank" {
|
||||
"Nonogrammed"
|
||||
}
|
||||
". The source code for this website is "
|
||||
a href="https://github.com/BadMannersXYZ/htmx-ssh-games" target="_blank" {
|
||||
"on Github"
|
||||
}
|
||||
". I know it's jank :^)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* HTMX components */
|
||||
|
||||
fn timer(puzzle_state: NonogramState, time_left: Duration) -> Markup {
|
||||
if let NonogramState::Solved(success) = puzzle_state {
|
||||
let secs = success.as_secs();
|
||||
return html! {
|
||||
p #timer {
|
||||
"Solved in " (format!("{}:{:02}", secs / 60, secs % 60)) "!"
|
||||
}
|
||||
};
|
||||
};
|
||||
let secs = time_left.as_secs();
|
||||
html! {
|
||||
p #timer {
|
||||
span #timer-elapsed .hidden[time_left == Duration::ZERO] {
|
||||
"Time left: " (format!("{}:{:02}", secs / 60, secs % 60))
|
||||
}
|
||||
span #timer-done .hidden[time_left > Duration::ZERO] {
|
||||
"Time's up!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn nonogram(State(state): State<AppState>) -> (HeaderMap, Markup) {
|
||||
let mut headers = HeaderMap::new();
|
||||
let nonogram = state.nonogram.lock().unwrap();
|
||||
let checkboxes = &nonogram.checkboxes.clone();
|
||||
let time_left = nonogram
|
||||
.timer
|
||||
.duration
|
||||
.saturating_sub(nonogram.timer.start.elapsed());
|
||||
let puzzle_state = nonogram.state;
|
||||
drop(nonogram);
|
||||
headers.insert(
|
||||
"HX-Trigger",
|
||||
format!(
|
||||
"{{\"nonogramTimeLeft\": {}, \"multipaintVersion\": {}}}",
|
||||
time_left.as_millis(),
|
||||
*VERSION
|
||||
)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let puzzle = state.puzzle.borrow();
|
||||
let rows = &puzzle.rows;
|
||||
let columns = &puzzle.columns;
|
||||
let columns_len = columns.len();
|
||||
(
|
||||
headers,
|
||||
html! {
|
||||
@if matches!(puzzle_state, NonogramState::Solved(_)) {
|
||||
h2 #congratulations {
|
||||
"Congratulations!!"
|
||||
}
|
||||
}
|
||||
@if let Some(title) = &puzzle.title {
|
||||
h3 {
|
||||
"Puzzle: " (title) " (#" (puzzle.id) ")"
|
||||
}
|
||||
}
|
||||
@if let Some(copyright) = &puzzle.copyright {
|
||||
p {
|
||||
em .copyright {
|
||||
(PreEscaped(copyright))
|
||||
}
|
||||
}
|
||||
}
|
||||
(timer(
|
||||
puzzle_state,
|
||||
time_left,
|
||||
))
|
||||
table #nonogram-table .solved[matches!(puzzle_state, NonogramState::Solved(_))] {
|
||||
tbody {
|
||||
tr {
|
||||
td {}
|
||||
@for column in columns {
|
||||
th scope="col" {
|
||||
div {
|
||||
@for value in column.iter() {
|
||||
.hint {
|
||||
(value.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@for (i, row) in rows.iter().enumerate() {
|
||||
tr {
|
||||
th scope="row" {
|
||||
@for value in row.iter() {
|
||||
.hint {
|
||||
(value.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@let id_range = i * columns_len..(i + 1) * columns_len;
|
||||
@let slice = &checkboxes[id_range.clone()];
|
||||
@for (id, &state) in id_range.zip(slice) {
|
||||
td.checkbox-cell {
|
||||
(checkbox(id, puzzle_state != NonogramState::Unsolved, &state))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn cursor_item(cursor: &Cursor) -> Markup {
|
||||
let style = format!(
|
||||
"transform: translate({}px, {}px); color: rgb({}, {}, {});",
|
||||
cursor.position.0, cursor.position.1, cursor.color[0], cursor.color[1], cursor.color[2],
|
||||
);
|
||||
html! {
|
||||
svg .cursor id=(format!("cursor-{}", cursor.id.0)) style=(style) width="9.6014509" height="16.11743" viewBox="0 0 2.5403839 4.2644034" {
|
||||
path style="fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.26;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" d="M 0.11675524,0.11673874 V 3.7065002 L 0.96455178,3.1233122 1.5307982,4.1165827 2.0934927,3.7711802 1.5414863,2.8366035 2.3925647,2.3925482 Z" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cursor(State(state): State<AppState>, Form(payload): Form<CursorsPayload>) -> Markup {
|
||||
let position = CursorPosition(payload.mouse_x, payload.mouse_y);
|
||||
let cursor_id = CursorId(payload.id);
|
||||
let mut cursors = state.cursors.lock().unwrap();
|
||||
cursors
|
||||
.entry(cursor_id)
|
||||
.and_modify(|cursor| {
|
||||
cursor.position = position;
|
||||
cursor.modified_at = Instant::now();
|
||||
})
|
||||
.or_insert_with_key(|id| Cursor::new(*id, position));
|
||||
cursors.retain(|_, cursor| {
|
||||
Instant::now().duration_since(cursor.modified_at) <= Duration::from_secs(20)
|
||||
});
|
||||
html! {
|
||||
@for cursor_data in cursors.iter().filter(|(&id, _)| id != cursor_id) {
|
||||
(cursor_item(cursor_data.1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checkbox(id: usize, disabled: bool, state: &CheckboxState) -> Markup {
|
||||
match state {
|
||||
CheckboxState::Marked => html! {
|
||||
.checkbox.marked {
|
||||
input id=(format!("checkbox-{id}")) type="checkbox" disabled[disabled] checked {}
|
||||
.mark {}
|
||||
div hx-delete=(format!("/checkbox/{id}")) hx-trigger=(format!("mousedown[buttons==1] from:#checkbox-{id}, mouseenter[buttons==1] from:#checkbox-{id}")) hx-swap="outerHTML" hx-target="closest .checkbox" {}
|
||||
}
|
||||
},
|
||||
CheckboxState::Flagged if !disabled => html! {
|
||||
.checkbox.flagged hx-delete=(format!("/flag/{id}")) hx-trigger="contextmenu[pointerType=='touch']" hx-swap="outerHTML" {
|
||||
input id=(format!("checkbox-{id}")) type="checkbox" disabled[disabled] {}
|
||||
.mark {}
|
||||
div hx-put=(format!("/checkbox/{id}")) hx-trigger=(format!("mousedown[buttons==1] from:#checkbox-{id}, mouseenter[buttons==1] from:#checkbox-{id}")) hx-swap="outerHTML" hx-target="closest .checkbox" {}
|
||||
div hx-delete=(format!("/flag/{id}")) hx-trigger=(format!("mousedown[buttons==2] from:#checkbox-{id}, mouseenter[buttons==2] from:#checkbox-{id}, contextmenu[isTouchDevice()] from:#checkbox-{id}")) hx-swap="outerHTML" hx-target="closest .checkbox" {}
|
||||
}
|
||||
},
|
||||
_ => html! {
|
||||
.checkbox.empty {
|
||||
input id=(format!("checkbox-{id}")) type="checkbox" disabled[disabled] {}
|
||||
.mark {}
|
||||
div hx-put=(format!("/checkbox/{id}")) hx-trigger=(format!("mousedown[buttons==1] from:#checkbox-{id}, mouseenter[buttons==1] from:#checkbox-{id}")) hx-swap="outerHTML" hx-target="closest .checkbox" {}
|
||||
div hx-put=(format!("/flag/{id}")) hx-trigger=(format!("mousedown[buttons==2] from:#checkbox-{id}, mouseenter[buttons==2] from:#checkbox-{id}, contextmenu[isTouchDevice()] from:#checkbox-{id}")) hx-swap="outerHTML" hx-target="closest .checkbox" {}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn flag_checkbox(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<usize>,
|
||||
) -> std::result::Result<Markup, StatusCode> {
|
||||
let mut nonogram = state.nonogram.lock().unwrap();
|
||||
let puzzle_state = nonogram.state;
|
||||
let checkboxes = &mut nonogram.checkboxes;
|
||||
if checkboxes.get(id).is_none() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
if puzzle_state == NonogramState::Unsolved && checkboxes[id] == CheckboxState::Empty {
|
||||
let _ = std::mem::replace(&mut checkboxes[id], CheckboxState::Flagged);
|
||||
Ok(checkbox(id, false, &CheckboxState::Flagged))
|
||||
} else {
|
||||
Ok(checkbox(id, true, &checkboxes[id]))
|
||||
}
|
||||
}
|
||||
|
||||
async fn unflag_checkbox(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<usize>,
|
||||
) -> std::result::Result<Markup, StatusCode> {
|
||||
let mut nonogram = state.nonogram.lock().unwrap();
|
||||
let puzzle_state = nonogram.state;
|
||||
let checkboxes = &mut nonogram.checkboxes;
|
||||
if checkboxes.get(id).is_none() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
if puzzle_state == NonogramState::Unsolved && checkboxes[id] == CheckboxState::Flagged {
|
||||
let _ = std::mem::replace(&mut checkboxes[id], CheckboxState::Empty);
|
||||
Ok(checkbox(id, false, &CheckboxState::Empty))
|
||||
} else {
|
||||
Ok(checkbox(id, true, &checkboxes[id]))
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_checkbox(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<usize>,
|
||||
) -> std::result::Result<Markup, StatusCode> {
|
||||
let mut nonogram = state.nonogram.lock().unwrap();
|
||||
let puzzle_state = nonogram.state;
|
||||
let timer_start = &nonogram.timer.start.clone();
|
||||
let checkboxes = &mut nonogram.checkboxes;
|
||||
if checkboxes.get(id).is_none() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
if puzzle_state == NonogramState::Unsolved && checkboxes[id] != CheckboxState::Marked {
|
||||
let _ = std::mem::replace(&mut checkboxes[id], CheckboxState::Marked);
|
||||
let checkboxes = &checkboxes.clone();
|
||||
drop(nonogram);
|
||||
if check_if_solved(&state.puzzle.borrow().solution, checkboxes, state.clone()) {
|
||||
state.nonogram.lock().unwrap().state = NonogramState::Solved(timer_start.elapsed());
|
||||
Ok(checkbox(id, true, &CheckboxState::Marked))
|
||||
} else {
|
||||
Ok(checkbox(id, false, &CheckboxState::Marked))
|
||||
}
|
||||
} else {
|
||||
Ok(checkbox(id, false, &nonogram.checkboxes[id]))
|
||||
}
|
||||
}
|
||||
|
||||
async fn unmark_checkbox(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<usize>,
|
||||
) -> std::result::Result<Markup, StatusCode> {
|
||||
let mut nonogram = state.nonogram.lock().unwrap();
|
||||
let puzzle_state = nonogram.state;
|
||||
let timer_start = &nonogram.timer.start.clone();
|
||||
let checkboxes = &mut nonogram.checkboxes;
|
||||
if checkboxes.get(id).is_none() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
if puzzle_state == NonogramState::Unsolved && checkboxes[id] == CheckboxState::Marked {
|
||||
let _ = std::mem::replace(&mut checkboxes[id], CheckboxState::Empty);
|
||||
let checkboxes = &checkboxes.clone();
|
||||
drop(nonogram);
|
||||
if check_if_solved(&state.puzzle.borrow().solution, checkboxes, state.clone()) {
|
||||
state.nonogram.lock().unwrap().state = NonogramState::Solved(timer_start.elapsed());
|
||||
Ok(checkbox(id, true, &CheckboxState::Empty))
|
||||
} else {
|
||||
Ok(checkbox(id, false, &CheckboxState::Empty))
|
||||
}
|
||||
} else {
|
||||
Ok(checkbox(id, false, &nonogram.checkboxes[id]))
|
||||
}
|
||||
}
|
||||
|
||||
/* Logic handlers */
|
||||
|
||||
async fn get_puzzle(puzzle_id: u32) -> Result<NonogrammedPuzzle> {
|
||||
match get_puzzle_data(puzzle_id).await {
|
||||
Err(e) => {
|
||||
warn!(error = ?e, id = puzzle_id, "Invalid puzzle.");
|
||||
Err(e)
|
||||
}
|
||||
Ok(puzzle) => {
|
||||
debug!(id = puzzle_id, "Valid puzzle.");
|
||||
Ok(puzzle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5 x 5: 367s
|
||||
// 10 x 10: 685s
|
||||
// 20 x 20: 1277s
|
||||
fn get_duration_for_puzzle(rows: usize, columns: usize) -> Duration {
|
||||
Duration::from_secs(f32::powf(20_000f32 * rows as f32 * columns as f32, 0.45) as u64)
|
||||
}
|
||||
|
||||
fn check_if_solved(
|
||||
solution: &BitSlice<usize, Lsb0>,
|
||||
checkboxes: &[CheckboxState],
|
||||
state: AppState,
|
||||
) -> bool {
|
||||
let wrong_squares = solution
|
||||
.iter()
|
||||
.zip(checkboxes.iter())
|
||||
.filter(|(solution, &state)| solution.ne(&(state == CheckboxState::Marked)))
|
||||
.count();
|
||||
let is_solved = wrong_squares == 0;
|
||||
if is_solved {
|
||||
let state_clone = state.clone();
|
||||
wait_and_start_new_puzzle(state_clone);
|
||||
} else {
|
||||
debug!("There are {wrong_squares} wrong squares!");
|
||||
}
|
||||
is_solved
|
||||
}
|
||||
|
||||
fn wait_and_start_new_puzzle(state: AppState) {
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
// Fetch next puzzle (this is a bit inneficient)
|
||||
let next_puzzle = loop {
|
||||
let puzzle_id = state.nonogram.lock().unwrap().puzzle_list.pop().clone();
|
||||
match puzzle_id {
|
||||
None => {
|
||||
let puzzle_vec = &mut state.nonogram.lock().unwrap().puzzle_list;
|
||||
puzzle_vec.extend_from_slice(&NONOGRAMMED_PUZZLE_LIST);
|
||||
puzzle_vec.shuffle(&mut thread_rng());
|
||||
}
|
||||
Some(puzzle_id) => {
|
||||
let puzzle = get_puzzle(puzzle_id).await;
|
||||
if let Ok(puzzle) = puzzle {
|
||||
break puzzle;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut nonogram = state.nonogram.lock().unwrap();
|
||||
let _ = mem::replace(
|
||||
&mut nonogram.checkboxes,
|
||||
vec![CheckboxState::Empty; next_puzzle.rows.len() * next_puzzle.columns.len()],
|
||||
);
|
||||
let duration = get_duration_for_puzzle(next_puzzle.rows.len(), next_puzzle.columns.len());
|
||||
nonogram.puzzle_sender.send_replace(next_puzzle);
|
||||
nonogram.timer.duration = duration;
|
||||
nonogram.timer.start = Instant::now();
|
||||
nonogram.state = NonogramState::Unsolved;
|
||||
let state_clone = state.clone();
|
||||
let join_handle = nonogram.timer.join_handle.replace(tokio::spawn(async move {
|
||||
sleep(duration).await;
|
||||
let state = state_clone.clone();
|
||||
let mut nonogram = state.nonogram.lock().unwrap();
|
||||
if nonogram.state == NonogramState::Unsolved {
|
||||
nonogram.state = NonogramState::Failed;
|
||||
}
|
||||
wait_and_start_new_puzzle(state.clone());
|
||||
}));
|
||||
join_handle.inspect(|handle| handle.abort());
|
||||
});
|
||||
}
|
208
src/http/netcode.rs
Normal file
208
src/http/netcode.rs
Normal file
|
@ -0,0 +1,208 @@
|
|||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
State, WebSocketUpgrade,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
||||
use serde::Deserialize;
|
||||
|
||||
/* Type defintions */
|
||||
|
||||
/* Router definition */
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
startup: Instant,
|
||||
latency: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// A lazily-created Router, to be used by the SSH client tunnels.
|
||||
pub async fn get_router() -> Router {
|
||||
Router::new()
|
||||
.route("/ws", get(ws_handler))
|
||||
.with_state(AppState {
|
||||
startup: Instant::now(),
|
||||
latency: Arc::new(AtomicU64::new(0)),
|
||||
})
|
||||
.route("/", get(index))
|
||||
.route("/htmx.js", get(htmx))
|
||||
.route("/htmx-ws.js", get(htmx_ext_ws))
|
||||
.route("/alpine.js", get(alpine))
|
||||
}
|
||||
|
||||
/* Main page elements */
|
||||
|
||||
static STYLE: &str = r#"
|
||||
body {
|
||||
color: #06060c;
|
||||
background-color: #fff;
|
||||
}
|
||||
@media(prefers-color-scheme: dark) {
|
||||
body {
|
||||
color: #ccc;
|
||||
background-color: #111;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
static SCRIPT: &str = r#"
|
||||
let netcodeVersion = null;
|
||||
document.addEventListener("netcodeVersion", (e) => {
|
||||
if (netcodeVersion === null) {
|
||||
netcodeVersion = e.detail.value;
|
||||
} else if (netcodeVersion !== e.detail.value) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
function getTimestamp() {
|
||||
return Date.now();
|
||||
}
|
||||
let ping = null;
|
||||
function getPing() {
|
||||
}
|
||||
let pong = null;
|
||||
function getPong() {
|
||||
}
|
||||
"#;
|
||||
|
||||
async fn htmx() -> &'static [u8] {
|
||||
include_bytes!("./htmx.js")
|
||||
}
|
||||
|
||||
async fn htmx_ext_ws() -> &'static [u8] {
|
||||
include_bytes!("./htmx-ws.js")
|
||||
}
|
||||
|
||||
async fn alpine() -> &'static [u8] {
|
||||
include_bytes!("./alpine.js")
|
||||
}
|
||||
|
||||
async fn index() -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
title { "Netcode test" }
|
||||
script src="/htmx.js" {}
|
||||
script src="/htmx-ws.js" {}
|
||||
script src="/alpine.js" {}
|
||||
style { (PreEscaped(STYLE)) }
|
||||
script { (PreEscaped(SCRIPT)) }
|
||||
}
|
||||
body {
|
||||
main hx-ext="ws" ws-connect="/ws" {
|
||||
#ping-infobox {
|
||||
label for="ping" { "Ping" }
|
||||
(ping_span(0))
|
||||
}
|
||||
(button("red"))
|
||||
(button("green"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* WebSocket handling */
|
||||
|
||||
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
ws.on_upgrade(|socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum WebSocketReadEvent {
|
||||
ButtonPress { pressed: String },
|
||||
ButtonRelease { released: String },
|
||||
Ping { ping: u64 },
|
||||
PingAck { ping: u64, pong: u64, ping_ack: u64 },
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
while let Some(message) = socket.next().await {
|
||||
if let Ok(Message::Text(text)) = message {
|
||||
match serde_json::from_str::<WebSocketReadEvent>(&text) {
|
||||
Ok(WebSocketReadEvent::ButtonPress { pressed }) => {
|
||||
println!("pressed {}", pressed)
|
||||
}
|
||||
Ok(WebSocketReadEvent::ButtonRelease { released }) => {
|
||||
println!("released {}", released)
|
||||
}
|
||||
Ok(WebSocketReadEvent::Ping { ping }) => {
|
||||
let pong = Instant::now().duration_since(state.startup).as_millis() as u64;
|
||||
if socket
|
||||
.send(Message::Text(
|
||||
ping_span_ack(state.latency.load(Ordering::Acquire), ping, pong)
|
||||
.into_string(),
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
};
|
||||
}
|
||||
Ok(WebSocketReadEvent::PingAck {
|
||||
ping,
|
||||
pong,
|
||||
ping_ack,
|
||||
}) => {
|
||||
let pong_ack = Instant::now().duration_since(state.startup).as_millis() as u64;
|
||||
let latency = (ping_ack - ping) - ((pong_ack - pong) / 2);
|
||||
state.latency.store(latency, Ordering::Release);
|
||||
if socket
|
||||
.send(Message::Text(ping_span(latency).into_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
};
|
||||
}
|
||||
Err(_) => println!("unknown {}", text),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* HTMX components */
|
||||
|
||||
fn button(id: &str) -> Markup {
|
||||
html! {
|
||||
div x-data="{ pressed: false }" {
|
||||
button x-on:pointerdown="pressed = true" x-on:pointerup="pressed = false" x-on:pointerleave="pressed = false" style={"width:200px;height:150px;background-color:"(id)} id={"button-"(id)} x-text="pressed ? 'Pressed' : 'Released'" {}
|
||||
div hx-vals={"{\"type\": \"ButtonPress\", \"pressed\": \""(id)"\"}"} hx-trigger={"pointerdown from:#button-"(id)} ws-send {}
|
||||
div hx-vals={"{\"type\": \"ButtonRelease\", \"released\": \""(id)"\"}"} hx-trigger={"pointerup from:#button-"(id)", pointerleave from:#button-"(id)} ws-send {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ping_span(latency: u64) -> Markup {
|
||||
html! {
|
||||
span #ping name="ping" hx-vals="js:{type: \"Ping\", ping: getTimestamp()}" hx-trigger="load delay:1500ms" ws-send {
|
||||
@if latency > 0 {
|
||||
(latency) "ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ping_span_ack(latency: u64, ping: u64, pong: u64) -> Markup {
|
||||
html! {
|
||||
span #ping name="ping" hx-vals={"js:{type: \"PingAck\", ping: "(ping)", pong: "(pong)", ping_ack: getTimestamp()}"} hx-trigger="load" ws-send {
|
||||
@if latency > 0 {
|
||||
(latency) "ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
use std::{
|
||||
mem,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{delete, get, put},
|
||||
Router,
|
||||
};
|
||||
use bitvec::{order::Lsb0, slice::BitSlice};
|
||||
use hyper::{HeaderMap, StatusCode};
|
||||
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
||||
use tokio::{
|
||||
task::JoinHandle,
|
||||
time::{sleep, Instant},
|
||||
};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/* Router definition */
|
||||
|
||||
struct PingPong {
|
||||
ping: (Instant, Some(Instant)),
|
||||
pong: (Instant, Some(Instant)),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
nonogram: Arc<Mutex<HashMap<String, PingPong>>>,
|
||||
}
|
||||
|
||||
/// A lazily-created Router, to be used by the SSH client tunnels.
|
||||
pub async fn get_router() -> Router {
|
||||
let state = AppState {
|
||||
nonogram: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/ping", put(ping))
|
||||
.route("/ping2/:id", put(ping2))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/* Main page elements */
|
||||
|
||||
fn style() -> &'static str {
|
||||
r#"
|
||||
"#
|
||||
}
|
||||
|
||||
fn script() -> &'static str {
|
||||
r#"
|
||||
document.oncontextmenu = (e) => {
|
||||
if (e.target.closest('.checkbox')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
let baseTimestamp = document.timeline.currentTime;
|
||||
let nonogramTimeLeft = null;
|
||||
document.addEventListener('someEvent', (e) => {
|
||||
baseTimestamp = document.timeline.currentTime;
|
||||
nonogramTimeLeft = e.detail.value;
|
||||
});
|
||||
function updateFrame(currentTimestamp) {
|
||||
if (Number.isInteger(nonogramTimeLeft)) {
|
||||
let timerElapsed = document.getElementById("timer-elapsed");
|
||||
let timerDone = document.getElementById("timer-done");
|
||||
let timeLeft = nonogramTimeLeft + baseTimestamp - currentTimestamp;
|
||||
if (timeLeft <= 0) {
|
||||
if (timerElapsed) {
|
||||
timerElapsed.classList.add("hidden");
|
||||
}
|
||||
if (timerDone) {
|
||||
done.classList.remove("hidden");
|
||||
}
|
||||
} else {
|
||||
if (timerElapsed) {
|
||||
let minutes = Math.floor(timeLeft / 60000);
|
||||
let seconds = Math.floor((timeLeft % 60000) / 1000);
|
||||
timerElapsed.innerText = "Time left: " + minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
|
||||
timerElapsed.classList.remove("hidden");
|
||||
}
|
||||
if (timerDone) {
|
||||
timerDone.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(updateFrame);
|
||||
}
|
||||
requestAnimationFrame(updateFrame);
|
||||
"#
|
||||
}
|
||||
|
||||
fn head() -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
title { "Netcode test" }
|
||||
script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous" {}
|
||||
style { (PreEscaped(style())) }
|
||||
script { (PreEscaped(script())) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn index() -> Markup {
|
||||
html! {
|
||||
(head())
|
||||
body {
|
||||
h1 { "Netcode test" }
|
||||
main {
|
||||
p {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* HTMX components */
|
||||
|
||||
fn timer(puzzle_state: NonogramState, time_left: Duration) -> Markup {
|
||||
if let NonogramState::Solved(success) = puzzle_state {
|
||||
let secs = success.as_secs();
|
||||
return html! {
|
||||
p #timer {
|
||||
"Solved in " (format!("{}:{:02}", secs / 60, secs % 60)) "!"
|
||||
}
|
||||
};
|
||||
};
|
||||
let secs = time_left.as_secs();
|
||||
html! {
|
||||
p #timer {
|
||||
span #timer-elapsed .hidden[time_left == Duration::ZERO] {
|
||||
"Time left: " (format!("{}:{:02}", secs / 60, secs % 60))
|
||||
}
|
||||
span #timer-done .hidden[time_left > Duration::ZERO] {
|
||||
"Time's up!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
pub mod entrypoint;
|
||||
pub mod http;
|
||||
pub mod nonogram;
|
||||
pub mod ssh;
|
||||
|
||||
pub fn unwrap_infallible<T>(result: Result<T, std::convert::Infallible>) -> T {
|
||||
|
|
25
src/main.rs
25
src/main.rs
|
@ -2,10 +2,10 @@ use std::path::PathBuf;
|
|||
|
||||
use anyhow::Result;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use htmx_ssh_games::{
|
||||
use clap::{Parser, Subcommand};
|
||||
use htmx_ssh_netcode_test::{
|
||||
entrypoint::{local_server_entrypoint, ssh_entrypoint},
|
||||
http::{checkbox, multipaint_by_numbers, ROUTER},
|
||||
http::{netcode, ROUTER},
|
||||
};
|
||||
use tracing::trace;
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
@ -54,21 +54,9 @@ enum OperationMode {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, ValueEnum)]
|
||||
enum ActivityRouter {
|
||||
/// 400 Checkboxes - A barebones clone of One Million Checkboxes.
|
||||
Checkboxes,
|
||||
/// Multipaint by Numbers - A multiplayer nonogram/picross.
|
||||
Multipaint,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct MainEntrypointArgs {
|
||||
/// Which activity router to serve.
|
||||
#[arg(value_enum, default_value_t = ActivityRouter::Checkboxes)]
|
||||
router: ActivityRouter,
|
||||
|
||||
/// Which mode to run this application as.
|
||||
#[command(subcommand)]
|
||||
mode: OperationMode,
|
||||
|
@ -82,12 +70,7 @@ async fn main() -> Result<()> {
|
|||
.init();
|
||||
trace!("Tracing is up!");
|
||||
let args = MainEntrypointArgs::parse();
|
||||
match args.router {
|
||||
ActivityRouter::Checkboxes => ROUTER.set(checkbox::get_router()).unwrap(),
|
||||
ActivityRouter::Multipaint => ROUTER
|
||||
.set(multipaint_by_numbers::get_router().await)
|
||||
.unwrap(),
|
||||
}
|
||||
ROUTER.set(netcode::get_router().await).unwrap();
|
||||
match args.mode {
|
||||
OperationMode::LocalServer { hostname, port } => {
|
||||
local_server_entrypoint(hostname.as_str(), port).await
|
||||
|
|
|
@ -1,181 +0,0 @@
|
|||
use std::collections::VecDeque;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bitvec::{slice::BitSlice, vec::BitVec};
|
||||
|
||||
pub mod nonogrammed;
|
||||
pub mod webpbn;
|
||||
|
||||
pub struct PopulatedBoard {
|
||||
pub rows: Vec<Vec<u8>>,
|
||||
pub columns: Vec<Vec<u8>>,
|
||||
pub solution: BitVec,
|
||||
}
|
||||
|
||||
pub fn populate_board(solution: &BitSlice, rows: u16, columns: u16) -> Result<PopulatedBoard> {
|
||||
let rows = rows as usize;
|
||||
let columns = columns as usize;
|
||||
if solution.len() != rows * columns {
|
||||
return Err(anyhow!("Invalid board size."));
|
||||
}
|
||||
let mut vec_rows: VecDeque<Vec<u8>> = VecDeque::from(vec![vec![0]; rows]);
|
||||
let mut vec_columns: VecDeque<Vec<u8>> = VecDeque::from(vec![vec![0]; columns]);
|
||||
for row in 0..rows {
|
||||
for column in 0..columns {
|
||||
if solution[row * columns + column] {
|
||||
let last_row = vec_rows[row].last_mut().unwrap();
|
||||
*last_row += 1;
|
||||
let last_column = vec_columns[column].last_mut().unwrap();
|
||||
*last_column += 1;
|
||||
} else {
|
||||
vec_rows[row].push(0);
|
||||
vec_columns[column].push(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
vec_rows.iter_mut().for_each(|row| row.retain(|&x| x > 0));
|
||||
vec_columns
|
||||
.iter_mut()
|
||||
.for_each(|column| column.retain(|&x| x > 0));
|
||||
let solution = BitVec::from_bitslice(solution);
|
||||
Ok(PopulatedBoard {
|
||||
rows: vec_rows.into(),
|
||||
columns: vec_columns.into(),
|
||||
solution,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bitvec::{bitvec, order::Lsb0};
|
||||
|
||||
#[test]
|
||||
fn it_creates_a_valid_board() {
|
||||
let rows = 10;
|
||||
let columns = 10;
|
||||
let solution = bitvec![
|
||||
0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0,
|
||||
0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0,
|
||||
1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0
|
||||
];
|
||||
let board = populate_board(&solution, rows, columns);
|
||||
assert!(board.is_ok());
|
||||
let board = board.unwrap();
|
||||
assert_eq!(
|
||||
board.rows,
|
||||
vec![
|
||||
vec![4],
|
||||
vec![1, 3],
|
||||
vec![10],
|
||||
vec![1, 1, 1, 1],
|
||||
vec![1, 2, 1],
|
||||
vec![1, 2, 1],
|
||||
vec![1, 1, 1, 1],
|
||||
vec![1, 2, 1],
|
||||
vec![1, 1],
|
||||
vec![8]
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
board.columns,
|
||||
vec![
|
||||
vec![1, 2],
|
||||
vec![2, 2, 1],
|
||||
vec![2, 2],
|
||||
vec![1, 2, 1, 1],
|
||||
vec![1, 1, 2, 1, 1],
|
||||
vec![3, 2, 1, 1],
|
||||
vec![4, 1, 1],
|
||||
vec![2, 2],
|
||||
vec![2, 2, 1],
|
||||
vec![1, 2],
|
||||
]
|
||||
);
|
||||
assert_eq!(board.solution, solution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_creates_rectangular_board() {
|
||||
let rows = 5;
|
||||
let columns = 8;
|
||||
let solution = bitvec![
|
||||
1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0,
|
||||
0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1
|
||||
];
|
||||
let board = populate_board(&solution, rows, columns);
|
||||
assert!(board.is_ok());
|
||||
let board = board.unwrap();
|
||||
assert_eq!(
|
||||
board.rows,
|
||||
vec![
|
||||
vec![4, 1],
|
||||
vec![1, 1],
|
||||
vec![1, 2, 1],
|
||||
vec![1, 2],
|
||||
vec![1, 2, 2],
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
board.columns,
|
||||
vec![
|
||||
vec![1, 1],
|
||||
vec![4],
|
||||
vec![1, 1],
|
||||
vec![1, 1, 1],
|
||||
vec![1],
|
||||
vec![],
|
||||
vec![4],
|
||||
vec![1, 2]
|
||||
]
|
||||
);
|
||||
assert_eq!(board.solution, solution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_does_not_trim_space_around_the_board() {
|
||||
let rows = 5;
|
||||
let columns = 7;
|
||||
let solution = bitvec![
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0
|
||||
];
|
||||
let board = populate_board(&solution, rows, columns);
|
||||
assert!(board.is_ok());
|
||||
let board = board.unwrap();
|
||||
assert_eq!(
|
||||
board.rows,
|
||||
vec![vec![], vec![2], vec![], vec![2, 1], vec![]]
|
||||
);
|
||||
assert_eq!(
|
||||
board.columns,
|
||||
vec![
|
||||
vec![],
|
||||
vec![1, 1],
|
||||
vec![1, 1],
|
||||
vec![],
|
||||
vec![1],
|
||||
vec![],
|
||||
vec![]
|
||||
]
|
||||
);
|
||||
assert_eq!(board.solution, solution);
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn it_trims_space_around_the_board() {
|
||||
// let rows = 5;
|
||||
// let columns = 7;
|
||||
// let solution = bitvec![
|
||||
// 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0,
|
||||
// 0, 0, 0, 0, 0, 0
|
||||
// ];
|
||||
// let board = populate_board(&solution, rows, columns);
|
||||
// assert!(board.is_ok());
|
||||
// let board = board.unwrap();
|
||||
// assert_eq!(board.rows, vec![vec![2], vec![], vec![2, 1]]);
|
||||
// assert_eq!(board.columns, vec![vec![1, 1], vec![1, 1], vec![], vec![1]]);
|
||||
// assert_eq!(board.solution, bitvec![1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1,]);
|
||||
// }
|
||||
}
|
|
@ -1,166 +0,0 @@
|
|||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use bitvec::{bitvec, order::Lsb0, vec::BitVec};
|
||||
use regex::Regex;
|
||||
|
||||
use super::{populate_board, PopulatedBoard};
|
||||
|
||||
/// List of monochrome puzzles obtained from https://nonogrammed.com/
|
||||
pub static NONOGRAMMED_PUZZLE_LIST: [u32; 608] = [
|
||||
// Small puzzles
|
||||
2704, 2692, 2671, 2670, 2668, 2667, 2666, 2665, 2664, 2663, 2659, 2658, 2657, 2656, 2655, 2654,
|
||||
2653, 2652, 2651, 2643, 2634, 2621, 2619, 2617, 2615, 2613, 2611, 2609, 2608, 2607, 2594, 2583,
|
||||
2562, 2532, 2494, 2476, 2468, 2443, 2386, 2380, 2377, 2337, 2322, 2321, 2308, 2275, 2257, 2252,
|
||||
2251, 2221, 2215, 2207, 2179, 2167, 2116, 2099, 2095, 2087, 2071, 2047, 2032, 1986, 1954, 1899,
|
||||
1897, 1891, 1855, 1853, 1850, 1839, 1835, 1829, 1824, 1815, 1811, 1810, 1809, 1801, 1798, 1796,
|
||||
1794, 1790, 1788, 1787, 1782, 1752, 1726, 1720, 1651, 1631, 1584, 1569, 1508, 1489, 1488, 1484,
|
||||
1459, 1440, 1423, 1421, 1416, 1398, 1397, 1395, 1353, 1337, 1327, 1248, 1101, 1098, 1085, 1055,
|
||||
1028, 1025, 1021, 1011, 979, 978, 964, 957, 955, 954, 949, 948, 947, 946, 941, 920, 914, 910,
|
||||
882, 880, 837, 812, 809, 807, 806, 763, 665, 659, 658, 648, 599, 557, 556, 555, 554, 553, 535,
|
||||
529, 503, 489, 486, 442, 432, 424, 420, 407, 404, 335, 281, 278, 267, 266, 265, 264, 263, 262,
|
||||
253, 252, 249, 248, 165, 135, 133, 128, 101, 77, 76, 64, 63, 59, 57, 56, 54, 53, 45, //
|
||||
// Medium puzzles
|
||||
2705, 2703, 2702, 2697, 2661, 2660, 2645, 2639, 2637, 2629, 2606, 2597, 2596, 2576, 2571, 2549,
|
||||
2548, 2543, 2542, 2541, 2535, 2533, 2529, 2526, 2525, 2524, 2515, 2508, 2505, 2487, 2485, 2482,
|
||||
2481, 2480, 2478, 2475, 2467, 2457, 2455, 2454, 2453, 2449, 2446, 2437, 2436, 2435, 2430, 2428,
|
||||
2427, 2426, 2424, 2419, 2418, 2417, 2407, 2399, 2398, 2397, 2396, 2391, 2385, 2379, 2372, 2366,
|
||||
2356, 2353, 2350, 2347, 2341, 2336, 2335, 2331, 2329, 2328, 2309, 2305, 2302, 2300, 2298, 2297,
|
||||
2296, 2295, 2292, 2287, 2285, 2278, 2276, 2269, 2267, 2264, 2244, 2236, 2226, 2213, 2212, 2191,
|
||||
2183, 2181, 2180, 2178, 2177, 2168, 2159, 2158, 2157, 2156, 2155, 2151, 2150, 2149, 2130, 2129,
|
||||
2128, 2127, 2126, 2125, 2124, 2122, 2110, 2107, 2106, 2100, 2094, 2093, 2090, 2070, 2068, 2051,
|
||||
2045, 2044, 2031, 2018, 1968, 1967, 1966, 1965, 1964, 1959, 1948, 1947, 1946, 1945, 1943, 1916,
|
||||
1879, 1862, 1849, 1845, 1822, 1820, 1819, 1812, 1795, 1775, 1773, 1772, 1763, 1762, 1760, 1758,
|
||||
1740, 1738, 1735, 1730, 1706, 1704, 1701, 1660, 1659, 1658, 1656, 1653, 1629, 1627, 1626, 1624,
|
||||
1623, 1616, 1611, 1607, 1604, 1589, 1562, 1548, 1537, 1534, 1510, 1509, 1502, 1501, 1498, 1497,
|
||||
1496, 1495, 1494, 1493, 1492, 1491, 1490, 1469, 1462, 1458, 1455, 1452, 1448, 1442, 1437, 1425,
|
||||
1419, 1418, 1402, 1400, 1377, 1371, 1369, 1364, 1348, 1338, 1336, 1300, 1287, 1286, 1284, 1245,
|
||||
1242, 1239, 1237, 1236, 1234, 1212, 1195, 1192, 1175, 1171, 1169, 1151, 1149, 1133, 1132, 1126,
|
||||
1125, 1120, 1117, 1112, 1103, 1100, 1096, 1090, 1080, 1079, 1071, 1068, 1066, 1062, 1059, 1053,
|
||||
1052, 1050, 1048, 1046, 1039, 1037, 1032, 1024, 1017, 1013, 1012, 1006, 998, 977, 975, 974,
|
||||
973, 972, 960, 958, 952, 950, 942, 940, 939, 921, 916, 913, 907, 904, 902, 898, 895, 893, 892,
|
||||
868, 857, 848, 839, 818, 817, 810, 808, 803, 801, 799, 794, 789, 764, 760, 738, 733, 732, 717,
|
||||
715, 702, 701, 700, 699, 695, 685, 656, 645, 643, 641, 635, 623, 621, 618, 617, 615, 606, 602,
|
||||
595, 559, 558, 544, 543, 534, 533, 526, 512, 502, 488, 485, 484, 470, 469, 425, 413, 412, 400,
|
||||
322, 321, 318, 302, 293, 291, 290, 288, 287, 285, 268, 236, 231, 203, 197, 196, 195, 194, 193,
|
||||
192, 191, 190, 176, 161, 144, 143, 142, 136, 129, 112, 111, 109, 102, 97, 94, 92, 91, 89, 88,
|
||||
87, 86, 85, 81, 78, 74, 55, 52, 51, 49, 48, 47, 46, 43, 34, 33, 32, 31, 30, 29, 26, 22, 21, 20,
|
||||
19, 18, 11, 7, 6, 5, 4, 3, 2, 1, //
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NonogrammedPuzzle {
|
||||
pub id: u32,
|
||||
pub title: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
pub rows: Vec<Vec<u8>>,
|
||||
pub columns: Vec<Vec<u8>>,
|
||||
pub solution: BitVec<usize, Lsb0>,
|
||||
}
|
||||
|
||||
static USERNAME_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new("<a href=['\"]user\\.php\\?NAME=(?P<username>[^'\"]+)['\"]>").unwrap()
|
||||
});
|
||||
|
||||
static TITLE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
"document\\.getElementById\\(['\"]title['\"]\\)\\.innerHTML\\s*=\\s*['\"]<[^>]+>(?P<title>[^<]+)<",
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
static SOLUTION_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new("var data\\s*=\\s*['\"](?P<solution>[01]+)['\"]").unwrap());
|
||||
|
||||
static ROWS_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new("var height\\s*=\\s*parseInt\\((?P<rows>\\d+)\\)").unwrap());
|
||||
|
||||
static COLUMNS_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new("var width\\s*=\\s*parseInt\\((?P<columns>\\d+)\\)").unwrap());
|
||||
|
||||
pub async fn get_puzzle_data(id: u32) -> Result<NonogrammedPuzzle> {
|
||||
let client = reqwest::Client::new();
|
||||
let html_response = client
|
||||
.get(format!("https://nonogrammed.com/index.php?NUM={id}"))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| "URL fetch error")?
|
||||
.text()
|
||||
.await
|
||||
.with_context(|| "Received non-text response")?;
|
||||
let mut title = None;
|
||||
let mut copyright = None;
|
||||
let mut rows = None;
|
||||
let mut columns = None;
|
||||
let mut solution = bitvec![];
|
||||
let mut lines = html_response.lines();
|
||||
loop {
|
||||
let Some(line) = lines.next() else {
|
||||
break;
|
||||
};
|
||||
if copyright.is_none() {
|
||||
if let Some(caps) = USERNAME_RE.captures(line) {
|
||||
let username = &caps["username"];
|
||||
copyright = Some(format!(
|
||||
r#"© <a href="https://nonogrammed.com/user.php?NAME={username}">{username}</a>"#
|
||||
));
|
||||
}
|
||||
}
|
||||
if title.is_none() {
|
||||
if let Some(caps) = TITLE_RE.captures(line) {
|
||||
title = Some(String::from(&caps["title"]));
|
||||
}
|
||||
}
|
||||
if solution.is_empty() {
|
||||
if let Some(caps) = SOLUTION_RE.captures(line) {
|
||||
solution.extend(caps["solution"].chars().map(|char| char == '1'));
|
||||
}
|
||||
}
|
||||
if rows.is_none() {
|
||||
if let Some(caps) = ROWS_RE.captures(line) {
|
||||
rows = Some(
|
||||
caps["rows"]
|
||||
.parse::<u16>()
|
||||
.with_context(|| "Invalid rows value.")?,
|
||||
);
|
||||
}
|
||||
}
|
||||
if columns.is_none() {
|
||||
if let Some(caps) = COLUMNS_RE.captures(line) {
|
||||
columns = Some(
|
||||
caps["columns"]
|
||||
.parse::<u16>()
|
||||
.with_context(|| "Invalid columns value.")?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if solution.is_empty() {
|
||||
return Err(anyhow!("Missing solution."));
|
||||
}
|
||||
if rows.is_none() {
|
||||
return Err(anyhow!("Missing rows."));
|
||||
}
|
||||
if columns.is_none() {
|
||||
return Err(anyhow!("Missing columns."));
|
||||
}
|
||||
// if title.is_none() {
|
||||
// return Err(anyhow!("Missing title."));
|
||||
// }
|
||||
// if copyright.is_none() {
|
||||
// return Err(anyhow!("Missing copyright."));
|
||||
// }
|
||||
let PopulatedBoard {
|
||||
rows,
|
||||
columns,
|
||||
solution,
|
||||
} = populate_board(&solution, rows.unwrap(), columns.unwrap())?;
|
||||
Ok(NonogrammedPuzzle {
|
||||
id,
|
||||
title,
|
||||
copyright,
|
||||
rows,
|
||||
columns,
|
||||
solution,
|
||||
})
|
||||
}
|
|
@ -1,240 +0,0 @@
|
|||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use bitvec::{bitvec, order::Lsb0, vec::BitVec};
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::thread_rng;
|
||||
use reqwest::redirect::Policy;
|
||||
|
||||
/// List of Nonogram puzzles obtained from https://webpbn.com/find.cgi with these parameters:
|
||||
///
|
||||
/// `search=1&status=0&minid=&maxid=&title=&author=&minsize=0&maxsize=400&minqual=4&maxqual=20&unqual=1&mindiff=4&maxdiff=15&undiff=1&mincolor=2&maxcolor=2&uniq=1&guess=3&blots=2&showcreate=1&order=0&perpage=0&save_settings=on`
|
||||
pub static WEBPBN_PUZZLE_LIST: LazyLock<[u32; 871]> = LazyLock::new(|| {
|
||||
let mut list = [
|
||||
23, 141, 252, 439, 748, 831, 1340, 1445, 1568, 1809, 1871, 1915, 2123, 2413, 2676, 3321,
|
||||
3339, 3375, 3791, 3994, 4005, 4015, 4051, 4374, 4492, 4494, 4533, 4573, 4610, 4699, 4705,
|
||||
4755, 5113, 5194, 5269, 5527, 5703, 5733, 5737, 5745, 5775, 5858, 5860, 5863, 5878, 5906,
|
||||
5919, 6022, 6120, 6162, 6182, 6195, 6200, 6275, 6300, 6302, 6324, 6336, 6394, 6425, 6430,
|
||||
6449, 6493, 6507, 6510, 6520, 6539, 6542, 6583, 6595, 6610, 6611, 6618, 6619, 6622, 6627,
|
||||
6633, 6637, 6640, 6645, 6648, 6659, 6664, 6670, 6673, 6688, 6695, 6696, 6763, 6769, 6772,
|
||||
6777, 6781, 6790, 6791, 6795, 6796, 6799, 6822, 6829, 6834, 6842, 6845, 6855, 6925, 6947,
|
||||
6953, 6965, 6986, 7033, 7134, 7163, 7199, 7200, 7282, 7296, 7306, 7405, 7432, 7550, 7694,
|
||||
7707, 7714, 7943, 7961, 7996, 8076, 8098, 8105, 8113, 8137, 8149, 8155, 8177, 8222, 8225,
|
||||
8232, 8256, 8275, 8283, 8302, 8339, 8357, 8363, 8381, 8389, 8396, 8463, 8565, 8686, 8764,
|
||||
8765, 8769, 8902, 8918, 8922, 9038, 9063, 9101, 9152, 9182, 9184, 9216, 9240, 9259, 9313,
|
||||
9398, 9409, 9417, 9450, 9542, 9720, 9727, 10004, 10043, 10045, 10121, 10152, 10289, 10365,
|
||||
10378, 10381, 10391, 10415, 10482, 10500, 10596, 10613, 10640, 10665, 10687, 10724, 10739,
|
||||
10854, 10873, 10979, 11007, 11120, 11145, 11192, 11194, 11309, 11392, 11399, 11419, 11713,
|
||||
11719, 11880, 11948, 11963, 11987, 12034, 12138, 12176, 12341, 12349, 12354, 12356, 12434,
|
||||
12466, 12620, 12692, 12917, 13181, 13187, 13362, 13486, 13497, 13510, 13522, 13593, 13716,
|
||||
13830, 13832, 13861, 14009, 14080, 14081, 14102, 14104, 14109, 14118, 14127, 14142, 14255,
|
||||
14274, 14279, 14280, 14287, 14300, 14302, 14351, 14361, 14375, 14396, 14551, 14660, 14957,
|
||||
15253, 15263, 15271, 15306, 15322, 15325, 15389, 15398, 15403, 15435, 15451, 15506, 15735,
|
||||
15816, 15855, 15883, 15890, 15910, 15912, 15928, 15937, 15939, 15949, 15954, 15962, 15982,
|
||||
15984, 15988, 15995, 15996, 16026, 16046, 16050, 16066, 16078, 16083, 16112, 16121, 16127,
|
||||
16129, 16153, 16163, 16174, 16187, 16191, 16232, 16270, 16293, 16342, 16344, 16366, 16390,
|
||||
16402, 16501, 16529, 16545, 16557, 16568, 16582, 16590, 16593, 16608, 16612, 16623, 16624,
|
||||
16648, 16649, 16650, 16652, 16668, 16677, 16682, 16691, 16707, 16711, 16742, 16771, 16784,
|
||||
16785, 16789, 16811, 16831, 16847, 16860, 16875, 16923, 16924, 16925, 16955, 16972, 17018,
|
||||
17022, 17024, 17082, 17104, 17141, 17187, 17203, 17342, 17376, 17394, 17485, 17532, 17579,
|
||||
17610, 17638, 17655, 17675, 17676, 17694, 17698, 17735, 17747, 17755, 17756, 17829, 17838,
|
||||
17884, 17890, 17893, 17992, 18029, 18044, 18045, 18058, 18060, 18478, 18490, 18560, 18592,
|
||||
18647, 18717, 18722, 18818, 18891, 18957, 19035, 19036, 19075, 19076, 19162, 19183, 19261,
|
||||
19314, 19326, 19391, 19392, 19394, 19491, 19651, 19672, 19689, 19723, 19777, 19806, 19815,
|
||||
19819, 19970, 20018, 20026, 20070, 20115, 20151, 20152, 20214, 20228, 20240, 20314, 20324,
|
||||
20327, 20328, 20329, 20342, 20358, 20360, 20369, 20455, 20466, 20486, 20496, 20506, 20572,
|
||||
20583, 20627, 20642, 20666, 20687, 20729, 20749, 20750, 20752, 20762, 20764, 20766, 20777,
|
||||
20816, 20830, 20845, 20854, 20865, 20881, 20887, 20890, 20966, 21010, 21024, 21033, 21052,
|
||||
21053, 21070, 21080, 21104, 21107, 21121, 21134, 21135, 21147, 21153, 21154, 21157, 21163,
|
||||
21168, 21173, 21235, 21241, 21298, 21309, 21311, 21312, 21323, 21328, 21337, 21465, 21467,
|
||||
21527, 21538, 21541, 21543, 21582, 21605, 21673, 21681, 21689, 21690, 21700, 21722, 21730,
|
||||
21739, 21769, 21892, 21971, 22044, 22118, 22147, 22205, 22249, 22320, 22361, 22421, 22444,
|
||||
22552, 22727, 22754, 22798, 22825, 22843, 22898, 23024, 23072, 23081, 23084, 23107, 23140,
|
||||
23144, 23196, 23218, 23230, 23234, 23236, 23249, 23251, 23261, 23264, 23369, 23393, 23452,
|
||||
23453, 23467, 23468, 23469, 23538, 23580, 23608, 23646, 23712, 23733, 23765, 23770, 23781,
|
||||
23788, 23790, 23795, 23796, 23803, 23804, 23811, 23859, 23860, 23861, 23868, 23869, 23870,
|
||||
24009, 24014, 24015, 24087, 24095, 24142, 24166, 24188, 24386, 24433, 24488, 24515, 24518,
|
||||
24524, 24550, 24555, 24563, 24564, 24571, 24582, 24598, 24606, 24618, 24620, 24622, 24625,
|
||||
24633, 24646, 24668, 24681, 24691, 24695, 24709, 24714, 24723, 24755, 24789, 24794, 24804,
|
||||
24809, 24813, 24830, 24834, 24854, 24856, 24868, 24871, 24879, 24899, 24900, 24901, 24915,
|
||||
24945, 24958, 24962, 24996, 25002, 25013, 25015, 25017, 25020, 25033, 25056, 25142, 25148,
|
||||
25154, 25197, 25223, 25327, 25345, 25349, 25404, 25518, 25785, 25851, 25904, 26021, 26028,
|
||||
26088, 26170, 26327, 26360, 26424, 26465, 26598, 26611, 26616, 26718, 26826, 26968, 26970,
|
||||
26971, 27021, 27030, 27053, 27170, 27178, 27244, 27266, 27289, 27312, 27330, 27362, 27450,
|
||||
27594, 27630, 27716, 27800, 27807, 27816, 27840, 27855, 27862, 27865, 27915, 27937, 28143,
|
||||
28237, 28270, 28429, 28432, 28466, 28528, 28667, 28786, 28837, 28845, 28916, 28993, 29017,
|
||||
29031, 29034, 29039, 29049, 29066, 29072, 29141, 29261, 29302, 29313, 29324, 29416, 29631,
|
||||
29654, 29658, 29660, 29661, 29674, 29678, 29755, 29788, 29847, 29848, 29857, 29888, 29904,
|
||||
30059, 30074, 30341, 30367, 30432, 30664, 30700, 30779, 31006, 31096, 31194, 31203, 31262,
|
||||
31263, 31544, 31552, 31559, 31595, 31601, 31611, 31715, 31732, 31825, 31831, 31931, 32004,
|
||||
32059, 32061, 32072, 32075, 32077, 32082, 32086, 32092, 32096, 32115, 32125, 32137, 32180,
|
||||
32247, 32251, 32288, 32323, 32359, 32379, 32611, 32612, 32622, 32657, 32658, 32682, 32699,
|
||||
32709, 32724, 32739, 32918, 32965, 32976, 33134, 33144, 33174, 33180, 33275, 33343, 33386,
|
||||
33414, 33471, 33494, 33548, 33560, 33570, 33632, 33635, 33639, 33683, 33697, 33781, 33816,
|
||||
33841, 33855, 33928, 33958, 34078, 34120, 34239, 34261, 34262, 34445, 34487, 34506, 34546,
|
||||
34636, 34654, 34672, 34788, 34847, 34870, 34893, 34897, 34926, 34928, 34947, 35036, 35124,
|
||||
35160, 35191, 35192, 35201, 35237, 35246, 35273, 35537, 35572, 35580, 35600, 35643, 35662,
|
||||
35665, 35745, 35751, 35800, 35801, 35846, 35851, 35909, 35912, 36261, 36264, 36291, 36306,
|
||||
36713, 36799, 36820, 36994, 37013, 37296, 37488, 37690, 38138, 38295, 38562, 38651, 38715,
|
||||
38765, 38771,
|
||||
];
|
||||
list.shuffle(&mut thread_rng());
|
||||
list
|
||||
});
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebpbnPuzzle {
|
||||
pub id: u32,
|
||||
pub title: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
pub rows: Vec<Vec<u8>>,
|
||||
pub columns: Vec<Vec<u8>>,
|
||||
pub solution: BitVec<usize, Lsb0>,
|
||||
}
|
||||
|
||||
pub async fn get_random_puzzle_id() -> Result<u32> {
|
||||
let client = reqwest::ClientBuilder::new()
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.with_context(|| "Reqwest client build error")?;
|
||||
let redirect_response = client
|
||||
.post("https://webpbn.com/random.cgi")
|
||||
.form(&[
|
||||
("sid", ""),
|
||||
("go", "1"),
|
||||
("psize", "1"),
|
||||
("pcolor", "1"),
|
||||
("pmulti", "1"),
|
||||
("pguess", "1"),
|
||||
("save", "1"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| "URL fetch error")?;
|
||||
let location = redirect_response
|
||||
.headers()
|
||||
.get("location")
|
||||
.with_context(|| "Missing Location header")?;
|
||||
let id = location
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split_once("id=")
|
||||
.with_context(|| "Missing ID field in Location")?
|
||||
.1
|
||||
.split('&')
|
||||
.next()
|
||||
.with_context(|| "Missing ID value in Location")?
|
||||
.parse::<u32>()
|
||||
.with_context(|| "ID value is not an integer")?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_data(id: u32) -> Result<WebpbnPuzzle> {
|
||||
let client = reqwest::Client::new();
|
||||
let export_response = client
|
||||
.post(format!("https://webpbn.com/export.cgi/webpbn{:06}.non", id))
|
||||
.form(&[
|
||||
("go", "1"),
|
||||
("sid", ""),
|
||||
("id", &id.to_string()),
|
||||
("xml_clue", "on"),
|
||||
("xml_soln", "on"),
|
||||
("fmt", "ss"),
|
||||
("ss_soln", "on"),
|
||||
("sg_clue", "on"),
|
||||
("sg_soln", "on"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| "URL fetch error")?
|
||||
.text()
|
||||
.await
|
||||
.with_context(|| "Received non-text response")?;
|
||||
let mut title = None;
|
||||
let mut copyright = None;
|
||||
let mut rows = vec![];
|
||||
let mut columns = vec![];
|
||||
let mut solution = bitvec![];
|
||||
enum GetPuzzleState {
|
||||
Start,
|
||||
ReadingRows,
|
||||
ReadingColumns,
|
||||
}
|
||||
let mut state = GetPuzzleState::Start;
|
||||
for line in export_response.lines() {
|
||||
match state {
|
||||
GetPuzzleState::Start => {
|
||||
if line.starts_with("title") {
|
||||
let mut iter = line.splitn(3, '"');
|
||||
iter.next().with_context(|| {
|
||||
"Expected 'title' to be followed by double-quoted string"
|
||||
})?;
|
||||
title = Some(String::from(iter.next().with_context(|| {
|
||||
"Expected 'title' to be contained within double-quoted string"
|
||||
})?));
|
||||
} else if line.starts_with("copyright") {
|
||||
let mut iter = line.splitn(3, '"');
|
||||
iter.next().with_context(|| {
|
||||
"Expected 'copyright' to be followed by double-quoted string"
|
||||
})?;
|
||||
copyright = Some(String::from(iter.next().with_context(|| {
|
||||
"Expected 'copyright' to be contained within double-quoted string"
|
||||
})?));
|
||||
} else if line.starts_with("rows") {
|
||||
state = GetPuzzleState::ReadingRows;
|
||||
} else if line.starts_with("columns") {
|
||||
state = GetPuzzleState::ReadingColumns;
|
||||
} else if line.starts_with("goal") {
|
||||
let mut iter = line.splitn(3, '"');
|
||||
iter.next().with_context(|| {
|
||||
"Expected 'goal' to be followed by double-quoted string"
|
||||
})?;
|
||||
solution.extend(
|
||||
iter.next()
|
||||
.with_context(|| {
|
||||
"Expected 'goal' to be contained within double-quoted string"
|
||||
})?
|
||||
.chars()
|
||||
.map(|char| char == '1'),
|
||||
);
|
||||
}
|
||||
}
|
||||
GetPuzzleState::ReadingRows => {
|
||||
if line.is_empty() {
|
||||
state = GetPuzzleState::Start;
|
||||
} else {
|
||||
let row = line
|
||||
.split(',')
|
||||
.flat_map(|text| str::parse::<u8>(text).ok())
|
||||
.filter(|&value| value > 0)
|
||||
.collect::<Vec<_>>();
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
GetPuzzleState::ReadingColumns => {
|
||||
if line.is_empty() {
|
||||
state = GetPuzzleState::Start;
|
||||
} else {
|
||||
let column = line
|
||||
.split(',')
|
||||
.flat_map(|text| str::parse::<u8>(text).ok())
|
||||
.filter(|&value| value > 0)
|
||||
.collect::<Vec<_>>();
|
||||
columns.push(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rows.is_empty() || columns.is_empty() || solution.is_empty() {
|
||||
Err(anyhow!("Invalid puzzle"))
|
||||
} else {
|
||||
Ok(WebpbnPuzzle {
|
||||
id,
|
||||
title,
|
||||
copyright,
|
||||
rows,
|
||||
columns,
|
||||
solution,
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue