New version of netcode test
Some checks failed
ci / docker (push) Failing after 14s

This commit is contained in:
Bad Manners 2024-09-29 11:12:45 -03:00
parent 46e10d8c8b
commit 1cec986ab1
Signed by: badmanners
GPG key ID: 8C88292CCB075609
16 changed files with 820 additions and 1673 deletions

117
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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
View 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])
}
}
}
})()

View file

@ -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();

View file

@ -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
View 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"
}
}
}
}

View file

@ -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!"
}
}
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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,]);
// }
}

View file

@ -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#"&copy; <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,
})
}

View file

@ -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,
})
}
}